mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Effect › 3D and Materials: Extrude & Bevel, Inflate and Rotate
The three 3D types besides Revolve, as live effects in the three-d crate, drawn as shaded vector faces by the canvas, SVG/PDF export and Expand Appearance alike, from the public 3D and Materials documentation: - Extrude & Bevel: depth (0-2000 pt), twist, taper, solid or hollow caps, and bevels in six original cross-sections (classic, rounded, cove, step, ogee) with width, height, repeat, spacing, inside and both sides. - Inflate: the shape as a membrane under pressure (a Poisson solve held at zero along the outline and holes), so it rises vertically from the edge and rounds without creases; volume, depth and both sides. Steep cells near the outline are refined without cracks. - Rotate: every fill and stroke turned in 3D and shaded as one plane. All three share Revolve's view and lighting parameters, the rotation and light gizmos (turning about the art's centre) and a dialog with Object, Rotation and Lighting tabs, with Position presets (Front ... Isometric Bottom, our own values). An object takes one 3D effect. Inflate cuts the grid cells that straddle a hole's edge with pathops, hence the new pathops -> three-d layer order. Docs: docs/three-d.md, mcp.md, and the 3D rows of the parity docs.
This commit is contained in:
1 parent
f4e72fe6a5
commit
8a3458a974
38 files changed
+2615
-57
No files matched your search
Generated
+1
@@ -4887,6 +4887,7 @@ name = "vectorcraft-three-d"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+3
-2
@@ -26,7 +26,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 29/57, 3D 1/4 (initial Revolve) |
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 29/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
||||
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
||||
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
|
||||
@@ -74,7 +74,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
|
||||
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
|
||||
| Live vector effects | 85% | 6–10 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 23–39 |
|
||||
| 3D and Materials | ~8% | 50–85 |
|
||||
| 3D and Materials | ~25% | 30–55 |
|
||||
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
||||
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 85% | 6–10 |
|
||||
@@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | Effect › 3D and Materials: Extrude & Bevel (depth, twist, taper, solid or hollow caps, six bevel shapes with repeat, spacing, inside and both sides), Inflate (volume, both sides) and Rotate, with Position presets and the rotation and light gizmos; live, exported as vector faces, expandable (#1407). |
|
||||
| 2026-10-12 | `convert --editing-data` (`editingData: "always"`) opens a `.ai` from its editing data even where its page, which can be older, draws differently, with a note. |
|
||||
| 2026-10-12 | `.ai` import reads an object's art style (a fill and a stroke, variable widths too, with an Outer Glow or Drop Shadow) as its own appearance, paints a clipping path's own fill under what it clips and keeps a group's transparency as its PDF part draws them. |
|
||||
| 2026-10-12 | `.ai` import reads gradients with their matrix (radials and skew too), focal point and stop order, gradient meshes and the artboards' names from the editing data. |
|
||||
|
||||
@@ -40,6 +40,7 @@ fn clear_item_effects(item: &mut AppearanceItem) {
|
||||
/// Does anything in `n`'s subtree need baking?
|
||||
pub fn needs_bake(n: &Node) -> bool {
|
||||
crate::has_revolve(n)
|
||||
|| crate::has_solid(n)
|
||||
|| has_adjustment(n)
|
||||
|| has_crop_marks(n)
|
||||
|| has_pathfinder(n)
|
||||
@@ -93,7 +94,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
if let Some(m) = crop_marks_art(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
if let Some(m) = crate::revolve_art(n) {
|
||||
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
@@ -232,7 +233,7 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &
|
||||
/// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and
|
||||
/// effects are dropped. `None` for other kinds of objects.
|
||||
pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<Node> {
|
||||
if let Some(m) = crate::revolve::expand_revolve_art(n) {
|
||||
if let Some(m) = crate::revolve::expand_revolve_art(n).or_else(|| crate::solid_art(n)) {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
@@ -299,7 +300,7 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
|
||||
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
|
||||
/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
|
||||
pub fn bake_appearance(n: &Node) -> Option<Node> {
|
||||
if let Some(m) = crate::revolve_art(n) {
|
||||
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
|
||||
return Some(m);
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
|
||||
|
||||
@@ -43,6 +43,7 @@ pub mod pixel;
|
||||
mod raster;
|
||||
mod reshape;
|
||||
mod revolve;
|
||||
mod solids;
|
||||
pub mod stroke;
|
||||
mod stylize;
|
||||
mod util;
|
||||
@@ -70,6 +71,10 @@ pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
|
||||
view_options,
|
||||
};
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
|
||||
/// Catalogue entry for one effect.
|
||||
@@ -125,6 +130,8 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"distort.transform" => always(&["moveH", "moveV"]),
|
||||
"path.offsetPath" => always(&["offset"]),
|
||||
"threeD.revolve" => always(&["offset"]),
|
||||
"threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]),
|
||||
"threeD.inflate" => always(&["depth"]),
|
||||
"path.outlineStroke" => always(&["width"]),
|
||||
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
|
||||
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
|
||||
@@ -196,6 +203,27 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspective: % (0..100, 0), segments: integer (8..128, 64), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspective":0.0,"segments":64,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0,"expandVisibleOnly":true}),
|
||||
),
|
||||
g(
|
||||
EXTRUDE,
|
||||
"Extrude & Bevel…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 50), twist: degrees (-360..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
|
||||
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
INFLATE,
|
||||
"Inflate…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 0; flat depth between front and back), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Painted with the top fill (else the top stroke).",
|
||||
json!({"depth":0.0,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
ROTATE,
|
||||
"Rotate…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
|
||||
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
"convertToShape.rectangle",
|
||||
"Rectangle…",
|
||||
@@ -669,7 +697,7 @@ pub fn is_geometry(id: &str) -> bool {
|
||||
&& !is_pathfinder(id)
|
||||
&& !is_adjustment(id)
|
||||
&& id != CROP_MARKS
|
||||
&& id != REVOLVE
|
||||
&& !is_three_d(id)
|
||||
&& (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Extrude & Bevel, Inflate and Rotate as live, shaded vector art, shared by the canvas and every
|
||||
//! exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces.
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, View};
|
||||
|
||||
use crate::util::{flag, num, text};
|
||||
use crate::{GeomContext, apply_geometry_with, is_geometry};
|
||||
|
||||
pub const EXTRUDE: &str = "threeD.extrude";
|
||||
pub const INFLATE: &str = "threeD.inflate";
|
||||
pub const ROTATE: &str = "threeD.rotate";
|
||||
/// The 3D effects this module draws (Revolve has its own).
|
||||
pub const SOLIDS: [&str; 3] = [EXTRUDE, INFLATE, ROTATE];
|
||||
|
||||
/// Is `id` one of the 3D and Materials effects (Revolve included)?
|
||||
pub fn is_three_d(id: &str) -> bool {
|
||||
id == crate::REVOLVE || SOLIDS.contains(&id)
|
||||
}
|
||||
|
||||
fn solid(fx: &[Effect]) -> Option<(usize, &Effect)> {
|
||||
fx.iter().enumerate().find(|(_, e)| e.visible && SOLIDS.contains(&e.id.as_str()))
|
||||
}
|
||||
|
||||
/// Does `n` carry a visible Extrude & Bevel, Inflate or Rotate effect?
|
||||
pub fn has_solid(n: &Node) -> bool {
|
||||
solid(&n.appearance.effects).is_some() || n.appearance.items.iter().any(|i| i.visible() && solid(i.effects()).is_some())
|
||||
}
|
||||
|
||||
/// The Position presets: (id, [rotation X, Y, Z]). Front, Back, Left, Right, Top and Bottom face
|
||||
/// the art that way; Off-Axis tilts each 20° down and 25° aside (showing the top and right);
|
||||
/// Isometric lays the art on the left, right, top or bottom plane of an isometric view (the
|
||||
/// visible axes foreshortened equally: atan(1/√2) ≈ 35.26° and 45°). Our own values, from the
|
||||
/// geometry of each view.
|
||||
pub const ROTATION_PRESETS: [(&str, [f64; 3]); 16] = [
|
||||
("front", [0.0, 0.0, 0.0]),
|
||||
("back", [0.0, 180.0, 0.0]),
|
||||
("left", [0.0, 90.0, 0.0]),
|
||||
("right", [0.0, -90.0, 0.0]),
|
||||
("top", [-90.0, 0.0, 0.0]),
|
||||
("bottom", [90.0, 0.0, 0.0]),
|
||||
("offAxisFront", [-21.88, -23.4, 9.06]),
|
||||
("offAxisBack", [-158.12, 23.4, -170.94]),
|
||||
("offAxisLeft", [-40.74, 58.39, -36.26]),
|
||||
("offAxisRight", [-139.26, -58.39, 143.74]),
|
||||
("offAxisTop", [-111.88, -23.4, 9.06]),
|
||||
("offAxisBottom", [68.12, -23.4, 9.06]),
|
||||
("isometricLeft", [-45.0, -35.26, 30.0]),
|
||||
("isometricRight", [-45.0, 35.26, -30.0]),
|
||||
("isometricTop", [45.0, -35.26, 30.0]),
|
||||
("isometricBottom", [-45.0, -35.26, -30.0]),
|
||||
];
|
||||
|
||||
/// The view parameters every 3D effect shares, named as Revolve's.
|
||||
pub fn view_options(p: &Value) -> View {
|
||||
View {
|
||||
rotation_x: num(p, "rotationX", ROTATION_PRESETS[6].1[0]),
|
||||
rotation_y: num(p, "rotationY", ROTATION_PRESETS[6].1[1]),
|
||||
rotation_z: num(p, "rotationZ", ROTATION_PRESETS[6].1[2]),
|
||||
perspective: num(p, "perspective", 0.0) / 100.0,
|
||||
light_azimuth: num(p, "lightAzimuth", -45.0),
|
||||
light_elevation: num(p, "lightElevation", 45.0),
|
||||
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
|
||||
ambient: num(p, "ambient", 25.0) / 100.0,
|
||||
shade: flag(p, "shade", true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrude & Bevel's parameters. `bevel` is `"none"` or a [`BevelShape`] id.
|
||||
pub fn extrude_options(p: &Value) -> Extrude {
|
||||
Extrude {
|
||||
depth: num(p, "depth", 50.0),
|
||||
twist: num(p, "twist", 0.0),
|
||||
taper: num(p, "taper", 100.0) / 100.0,
|
||||
cap: flag(p, "cap", true),
|
||||
bevel: BevelShape::parse(text(p, "bevel", "none")).map(|shape| Bevel {
|
||||
shape,
|
||||
width: num(p, "bevelWidth", 4.0),
|
||||
height: num(p, "bevelHeight", 4.0),
|
||||
repeat: num(p, "bevelRepeat", 1.0).clamp(1.0, 32.0) as usize,
|
||||
space: num(p, "bevelSpace", 0.0) / 100.0,
|
||||
inside: flag(p, "bevelInside", false),
|
||||
both_sides: flag(p, "bevelBothSides", false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inflate_options(p: &Value) -> Inflate {
|
||||
Inflate { depth: num(p, "depth", 0.0), volume: num(p, "volume", 50.0), both_sides: flag(p, "bothSides", false) }
|
||||
}
|
||||
|
||||
/// The shape a solid is made from: a path's or a compound path's outline.
|
||||
fn profile(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, guide: false, clipping: false, .. } => Some((path.clone(), *rule)),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn facets(id: &str, path: &PathData, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
|
||||
let view = view_options(p);
|
||||
match id {
|
||||
EXTRUDE => vectorcraft_three_d::extrude(path, bounds, extrude_options(p), view),
|
||||
INFLATE => vectorcraft_three_d::inflate(path, bounds, inflate_options(p), view),
|
||||
_ => vectorcraft_three_d::plane(path, bounds, view),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate before a command changes the document; the evaluator bounds loaded files too.
|
||||
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
||||
let (path, _) =
|
||||
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
|
||||
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
|
||||
if let Some(v) = p.get("bevel")
|
||||
&& !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some())
|
||||
{
|
||||
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
|
||||
return Err(format!("bevel must be none or one of {}", names.join(", ")));
|
||||
}
|
||||
facets(id, &path, b, p).map(|_| ())
|
||||
}
|
||||
|
||||
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
|
||||
/// blend mode (so overlapping facets don't add up their transparency).
|
||||
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode) -> Node {
|
||||
let children = facets
|
||||
.into_iter()
|
||||
.map(|face| {
|
||||
let mut items = vec![];
|
||||
if let Some(c) = paint.color() {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let s = face.brightness as f32;
|
||||
items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(r * s, g * s, b * s)))));
|
||||
} else {
|
||||
// Gradients and patterns keep their paint, darkened by a black layer.
|
||||
items.push(AppearanceItem::Fill(FillLayer::new(paint.clone())));
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
shadow.opacity = (1.0 - face.brightness) as f32;
|
||||
items.push(AppearanceItem::Fill(shadow));
|
||||
}
|
||||
// Adjacent facets, antialiased on their own, would leave hairline seams: a narrow
|
||||
// stroke of the same paint closes them. No wider than the facet is thick, though: seen
|
||||
// at a grazing angle a facet can be thinner than the stroke, and its stroke would then
|
||||
// paint over its neighbours.
|
||||
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
|
||||
let items = items
|
||||
.into_iter()
|
||||
.flat_map(|item| {
|
||||
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
|
||||
let mut edge = StrokeLayer::new(fill.paint.clone(), seam);
|
||||
edge.opacity = fill.opacity;
|
||||
vec![item, AppearanceItem::Stroke(edge)]
|
||||
})
|
||||
.collect();
|
||||
Arc::new(Node::path(n.id, face.path(), Appearance { items, ..Default::default() }))
|
||||
})
|
||||
.collect();
|
||||
let mut group = Node::group(n.id, children);
|
||||
group.opacity = opacity;
|
||||
group.blend = blend;
|
||||
group
|
||||
}
|
||||
|
||||
fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
|
||||
AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend),
|
||||
}
|
||||
}
|
||||
|
||||
/// The area `item` paints on `path`: the path itself for a fill, the stroke's outline for a stroke.
|
||||
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> PathData {
|
||||
match item {
|
||||
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => crate::stroke::outline_region(path, rule, s),
|
||||
_ => path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The 3D art of one effect `fx` on `path` (already through the effects before it).
|
||||
/// Extrude & Bevel and Inflate build a solid from the outline, painted with the top visible fill
|
||||
/// (or, without one, the top stroke); Rotate turns every fill and stroke, each in its own paint.
|
||||
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
|
||||
let b = path.bounds()?;
|
||||
let p = crate::merged_params(&fx.id, &fx.params);
|
||||
if fx.id == ROTATE {
|
||||
// Every part turns about the centre of the object's own bounds, so they stay together.
|
||||
let mut out = vec![];
|
||||
for item in items {
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let f = facets(ROTATE, ®ion(path, rule, item), b, &p).ok()?;
|
||||
out.push(Arc::new(surface(n, f, &paint, opacity, blend)));
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
let item = items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())?;
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
|
||||
// failing the frame; commands validate the same evaluator and report its errors.
|
||||
let f = facets(&fx.id, path, b, &p).ok()?;
|
||||
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
|
||||
}
|
||||
|
||||
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx)
|
||||
}
|
||||
|
||||
/// `n` drawn with its Extrude & Bevel, Inflate or Rotate effect: a group of shaded faces keeping
|
||||
/// the object's id and transparency, the source path untouched. `None` without such an effect.
|
||||
pub fn solid_art(n: &Node) -> Option<Node> {
|
||||
if !has_solid(n) {
|
||||
return None;
|
||||
}
|
||||
let (path, rule) = profile(n)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let painted: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect();
|
||||
let mut children = vec![];
|
||||
let remaining: Vec<Effect>;
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
|
||||
children.extend(art_for(n, fx, &path, rule, &painted)?);
|
||||
// Later geometry effects act on the projected art as a whole.
|
||||
remaining = n
|
||||
.appearance
|
||||
.effects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index))
|
||||
.map(|(_, e)| e.clone())
|
||||
.collect();
|
||||
} else {
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
||||
for item in &painted {
|
||||
if let Some((index, fx)) = solid(item.effects()) {
|
||||
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
|
||||
for mut art in art_for(n, fx, &p, rule, &[*item])? {
|
||||
Arc::make_mut(&mut art).appearance.effects =
|
||||
item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
|
||||
children.push(art);
|
||||
}
|
||||
} else {
|
||||
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![(*item).clone()], ..Default::default() })));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut art = n.clone();
|
||||
art.kind = NodeKind::Group { children, clip: false };
|
||||
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
|
||||
Some(art)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests_solids.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,158 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
use vectorcraft_geom::{Affine, BezPath};
|
||||
|
||||
use super::*;
|
||||
|
||||
const RED: Color = Color::Rgb { r: 0.8, g: 0.2, b: 0.1 };
|
||||
|
||||
fn square_node() -> Node {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 100.0));
|
||||
b.line_to((200.0, 100.0));
|
||||
b.line_to((200.0, 200.0));
|
||||
b.line_to((100.0, 200.0));
|
||||
b.close_path();
|
||||
Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 4.0))
|
||||
}
|
||||
|
||||
fn fx(id: &str, params: Value) -> Effect {
|
||||
crate::new_effect(id, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
/// The shaded faces of every surface of `art`, bottom first.
|
||||
fn faces(art: &Node) -> Vec<&Node> {
|
||||
art.children().unwrap().iter().flat_map(|s| s.children().unwrap().iter().map(|f| &**f)).collect()
|
||||
}
|
||||
|
||||
fn fill_rgb(n: &Node) -> [f32; 3] {
|
||||
match &n.appearance.items[0] {
|
||||
AppearanceItem::Fill(f) => f.paint.color().unwrap().to_rgb(),
|
||||
_ => panic!("a fill first"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() {
|
||||
for id in SOLIDS {
|
||||
let mut n = square_node();
|
||||
let source = n.path_data().unwrap().clone();
|
||||
n.appearance.effects.push(fx(id, json!({})));
|
||||
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}"));
|
||||
assert_eq!(n.path_data(), Some(&source), "{id}");
|
||||
assert!(!faces(&art).is_empty(), "{id}");
|
||||
// The faces are the fill's red, shaded: never brighter, never another hue.
|
||||
for f in faces(&art) {
|
||||
let [r, g, b] = fill_rgb(f);
|
||||
assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}");
|
||||
}
|
||||
// The art follows the source when it moves.
|
||||
let before = art.visual_bounds().unwrap();
|
||||
n.transform(Affine::translate((50.0, -30.0)), false);
|
||||
let after = solid_art(&n).unwrap().visual_bounds().unwrap();
|
||||
assert!((after.x0 - before.x0 - 50.0).abs() < 1e-6 && (after.y0 - before.y0 + 30.0).abs() < 1e-6, "{id}");
|
||||
// Hidden, the effect draws nothing of its own.
|
||||
n.appearance.effects[0].visible = false;
|
||||
assert!(solid_art(&n).is_none(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_turns_every_fill_and_stroke_and_the_solids_use_the_top_fill() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(ROTATE, json!({})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
// The fill's plane and the stroke's outline, each in its own paint.
|
||||
let surfaces = art.children().unwrap();
|
||||
assert_eq!(surfaces.len(), 2);
|
||||
let paint = |s: &Node| fill_rgb(&s.children().unwrap()[0]);
|
||||
assert!(paint(&surfaces[0])[0] > paint(&surfaces[0])[1], "the red fill below");
|
||||
assert!(paint(&surfaces[1]).iter().all(|c| *c < 0.05), "the black stroke above");
|
||||
// Turned 60° about y with no perspective, the stroke's outline spans half its width plus the
|
||||
// stroke's own outset, foreshortened as well.
|
||||
n.appearance.effects[0].params = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0});
|
||||
let b = solid_art(&n).unwrap().children().unwrap()[1].visual_bounds().unwrap();
|
||||
assert!((b.width() - 104.0 * 0.5 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
// Extrude & Bevel paints one surface with the top fill.
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
assert_eq!(solid_art(&n).unwrap().children().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_stays_on_the_whole_surface_and_items_take_their_own_effect() {
|
||||
let mut n = square_node();
|
||||
n.opacity = 0.5;
|
||||
if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
|
||||
f.opacity = 0.4;
|
||||
f.effects.push(fx(INFLATE, json!({})));
|
||||
}
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert_eq!(art.opacity, 0.5);
|
||||
let surfaces = art.children().unwrap();
|
||||
// The fill inflated, the stroke drawn flat as it was.
|
||||
assert_eq!(surfaces.len(), 2);
|
||||
assert_eq!(surfaces[0].opacity, 0.4);
|
||||
assert!(surfaces[0].children().unwrap().iter().all(|c| c.opacity == 1.0));
|
||||
assert!(matches!(surfaces[1].kind, NodeKind::Path { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_and_expand_bake_faces_with_their_own_ids() {
|
||||
for id in SOLIDS {
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
let mut n = square_node();
|
||||
n.id = d.alloc_id();
|
||||
n.appearance.effects.push(fx(id, json!({"bevel": "rounded"})));
|
||||
assert_eq!(crate::bake_appearance(&n), solid_art(&n), "{id}");
|
||||
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
|
||||
let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
fn visit(n: &Node, ids: &mut std::collections::HashSet<NodeId>) {
|
||||
assert!(ids.insert(n.id), "{:?} twice", n.id);
|
||||
assert!(!has_solid(n));
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, ids);
|
||||
}
|
||||
}
|
||||
visit(&art, &mut ids);
|
||||
assert!(ids.len() > 2, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 20})));
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspective": 0})));
|
||||
// Straight on, the extrusion shows its front: the offset square.
|
||||
let b = solid_art(&n).unwrap().visual_bounds().unwrap();
|
||||
assert!((b.width() - 140.0 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
n.appearance.effects.push(fx("distort.twist", json!({"angle": 30})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert!(art.appearance.effects.iter().any(|e| e.id == "distort.twist"), "applied to the result as a whole");
|
||||
assert!(art.appearance.effects.iter().all(|e| e.id != "path.offsetPath" && !is_three_d(&e.id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_are_validated_and_lengths_scale_with_the_object() {
|
||||
let n = square_node();
|
||||
assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "rounded", "depth": 80})).is_ok());
|
||||
assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "fancy"})).unwrap_err().contains("bevel must be"));
|
||||
let text = Node::group(NodeId(2), vec![]);
|
||||
assert!(validate_solid(&text, INFLATE, &json!({})).unwrap_err().contains("requires a path"));
|
||||
for (id, key) in [(EXTRUDE, "depth"), (EXTRUDE, "bevelWidth"), (EXTRUDE, "bevelHeight"), (INFLATE, "depth")] {
|
||||
assert!(crate::is_length(id, key, false), "{id} {key}");
|
||||
}
|
||||
assert!(!crate::is_length(EXTRUDE, "twist", false));
|
||||
for id in SOLIDS {
|
||||
assert!(!crate::is_geometry(id) && is_three_d(id));
|
||||
assert!(crate::default_params(id).is_some());
|
||||
}
|
||||
// Options read from parameters, with the documented ranges' defaults.
|
||||
let o = extrude_options(&json!({"taper": 40, "bevel": "step", "bevelSpace": 30, "bevelRepeat": 99}));
|
||||
assert_eq!(o.taper, 0.4);
|
||||
let b = o.bevel.unwrap();
|
||||
assert_eq!((b.shape, b.space, b.repeat), (BevelShape::Step, 0.3, 32));
|
||||
assert!(extrude_options(&json!({})).bevel.is_none());
|
||||
}
|
||||
@@ -119,13 +119,18 @@ fn edit_effects(
|
||||
}
|
||||
|
||||
fn check_revolve_count(n: &Node, cmd: &str) -> Result<()> {
|
||||
let count = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects())).filter(|e| e.id == effects::REVOLVE).count();
|
||||
if count > 1 {
|
||||
let all = || n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects()));
|
||||
if all().filter(|e| e.id == effects::REVOLVE).count() > 1 {
|
||||
return Err(bad(cmd, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
|
||||
}
|
||||
if all().filter(|e| effects::is_three_d(&e.id)).count() > 1 {
|
||||
return Err(bad(cmd, ONE_3D));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const ONE_3D: &str = "An object takes one 3D effect. Edit the existing effect's options, or remove it first.";
|
||||
|
||||
pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.apply";
|
||||
let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?;
|
||||
@@ -149,12 +154,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
|
||||
}
|
||||
let item = item_target(s, p, C)?;
|
||||
if id == effects::REVOLVE {
|
||||
for id in appearance_targets(s, p)? {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
effects::validate_revolve(n, &effect.params).map_err(|e| bad(C, e))?;
|
||||
if effects::has_revolve(n) {
|
||||
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
|
||||
if effects::is_three_d(id) {
|
||||
for target in appearance_targets(s, p)? {
|
||||
let n = s.doc()?.doc.node(target).ok_or(EngineError::NoNode(target))?;
|
||||
if id == effects::REVOLVE {
|
||||
effects::validate_revolve(n, &effect.params).map_err(|e| bad(C, e))?;
|
||||
if effects::has_revolve(n) {
|
||||
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
|
||||
}
|
||||
} else {
|
||||
effects::validate_solid(n, id, &effect.params).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
if effects::has_revolve(n) || effects::has_solid(n) {
|
||||
return Err(bad(C, ONE_3D));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,14 +293,18 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let target = item.effects_item(&n.appearance, C)?;
|
||||
if let Some(e) = n.appearance.effects_at(target).and_then(|fx| fx.get(index))
|
||||
&& e.id == effects::REVOLVE
|
||||
&& effects::is_three_d(&e.id)
|
||||
&& visible != Some(false)
|
||||
{
|
||||
let mut prospective = effects::merged_params(&e.id, &e.params);
|
||||
if let (Some(cur), Some(new)) = (prospective.as_object_mut(), params.as_object()) {
|
||||
cur.extend(new.clone());
|
||||
}
|
||||
effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?;
|
||||
if e.id == effects::REVOLVE {
|
||||
effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?;
|
||||
} else {
|
||||
effects::validate_solid(n, &e.id, &prospective).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
|
||||
@@ -533,6 +549,45 @@ mod tests {
|
||||
assert_eq!(effects_for_test(&s, id), before);
|
||||
}
|
||||
|
||||
/// Extrude & Bevel, Inflate and Rotate: applied by command, saved and reopened, edited,
|
||||
/// exported, expanded and undone; one 3D effect per object.
|
||||
#[test]
|
||||
fn solid_effects_apply_save_edit_export_expand_and_undo() {
|
||||
for effect in effects::SOLIDS {
|
||||
let (mut s, id) = session_with_rect();
|
||||
let source = node(&s, id).path_data().unwrap().clone();
|
||||
s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap();
|
||||
let art = effects::solid_art(&node(&s, id)).unwrap();
|
||||
assert_eq!(node(&s, id).path_data(), Some(&source), "{effect}: the source stays editable");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||||
assert_eq!(effects::solid_art(restored.node(id).unwrap()).unwrap(), art, "{effect}: reopens the same");
|
||||
// One 3D effect per object, whichever kind.
|
||||
for other in ["threeD.revolve", "threeD.extrude", "threeD.rotate"] {
|
||||
assert!(s.execute("effect.apply", &json!({"effect": other, "ids": [id.0], "item": null})).is_err(), "{effect} + {other}");
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "depth": 120}, "ids": [id.0], "item": null})).unwrap();
|
||||
assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "{effect}: options change the art");
|
||||
assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"bevel": "nope"}, "ids": [id.0], "item": null})).is_err());
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||||
let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string());
|
||||
assert!(svg.matches("<path").count() >= 2, "{effect}: exported as vector faces");
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap();
|
||||
assert!(matches!(node(&s, id).kind, NodeKind::Group { .. }), "{effect}");
|
||||
assert!(!effects::has_solid(&node(&s, id)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id).appearance.effects.len(), 0, "{effect}: back to the plain rectangle");
|
||||
}
|
||||
// Type has no outline to extrude.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let t = s.execute("text.create", &json!({"x": 50, "y": 50, "text": "3D"})).unwrap();
|
||||
let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [t["id"]], "item": null})).unwrap_err();
|
||||
assert!(err.to_string().contains("requires a path"), "{err}");
|
||||
}
|
||||
|
||||
fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
||||
effects::revolve_art(&node(s, id)).unwrap()
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ pub(crate) fn has_object_fx(n: &Node) -> bool {
|
||||
|| effects::has_crop_marks(n)
|
||||
|| effects::has_adjustment(n)
|
||||
|| effects::has_revolve(n)
|
||||
|| effects::has_solid(n)
|
||||
}
|
||||
|
||||
/// Is `n` a group or layer (whose evaluated art keeps its knockout setting)?
|
||||
@@ -138,7 +139,7 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] {
|
||||
|
||||
/// Visual bounds including geometry effects, stroke outsets and shadows/glows.
|
||||
pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
|
||||
if let Some(art) = effects::revolve_art(n) {
|
||||
if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) {
|
||||
return cull_bounds(&art).map(|b| {
|
||||
let o = effects::outset(&n.appearance.effects, b);
|
||||
b.inflate(o, o)
|
||||
@@ -786,13 +787,17 @@ impl Renderer {
|
||||
_ => None,
|
||||
};
|
||||
let container = is_container(a);
|
||||
let art = match effects::crop_marks_art(a).or_else(|| effects::revolve_art(a)).or_else(|| effects::reshape(a, symbol.as_ref())) {
|
||||
let art = match effects::crop_marks_art(a)
|
||||
.or_else(|| effects::revolve_art(a))
|
||||
.or_else(|| effects::solid_art(a))
|
||||
.or_else(|| effects::reshape(a, symbol.as_ref()))
|
||||
{
|
||||
Some(r) => r,
|
||||
None if container => effects::evaluate_container(a).unwrap_or_else(|| (**a).clone()),
|
||||
None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(),
|
||||
None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])),
|
||||
};
|
||||
let art = if effects::has_revolve(a) { effects::evaluate_container(&art).unwrap_or(art) } else { art };
|
||||
let art = if effects::has_revolve(a) || effects::has_solid(a) { effects::evaluate_container(&art).unwrap_or(art) } else { art };
|
||||
let mut art = self.adjusted_art(art, effects::color_map(&a.appearance.effects).as_ref());
|
||||
// A path's geometry effects still apply when it is drawn.
|
||||
let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
|
||||
|
||||
@@ -65,6 +65,38 @@ fn revolve_canvas_matches_baked_art_and_shades_its_surface() {
|
||||
assert_eq!(live.pixels, render_threads(&d, 4).pixels);
|
||||
}
|
||||
|
||||
/// Extrude & Bevel, Inflate and Rotate draw on the canvas exactly as they export, filling more
|
||||
/// than the flat square with faces of differing shades (Rotate: one shade, narrower).
|
||||
#[test]
|
||||
fn solid_effects_canvas_matches_baked_art_and_shades_their_faces() {
|
||||
for (effect, params) in [
|
||||
("threeD.extrude", json!({"depth": 30, "bevel": "rounded"})),
|
||||
("threeD.inflate", json!({"depth": 10})),
|
||||
("threeD.rotate", json!({"rotationX": 0, "rotationY": 55, "rotationZ": 0})),
|
||||
] {
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0)),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.9, 0.2, 0.1)), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects.push(fx(effect, params));
|
||||
let d = doc_with(Document::new(100.0, 100.0), n);
|
||||
let live = render(&d);
|
||||
let exported = render(&vectorcraft_effects::bake_document(&d).unwrap());
|
||||
assert_eq!(live.pixels, exported.pixels, "{effect}: canvas and baked/exported faces agree");
|
||||
assert_eq!(live.pixels, render_threads(&d, 4).pixels, "{effect}");
|
||||
let reds: Vec<_> =
|
||||
live.pixels.as_chunks::<4>().0.iter().filter(|p| p[0] as u16 > p[1] as u16 * 2 && p[0] as u16 > p[2] as u16 * 2).map(|p| p[0]).collect();
|
||||
let shades = reds.iter().max().unwrap() - reds.iter().min().unwrap();
|
||||
if effect == "threeD.rotate" {
|
||||
assert!(reds.len() < 40 * 40 && reds.len() > 600, "{effect}: the square foreshortened, {} px", reds.len());
|
||||
} else {
|
||||
assert!(reds.len() > 40 * 40, "{effect}: a solid larger than its front, {} px", reds.len());
|
||||
assert!(shades > 60, "{effect}: lighting varies across the faces ({shades})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_visible_surface_expansion_preserves_the_view_and_transparent_art() {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
|
||||
@@ -3,10 +3,11 @@ name = "vectorcraft-three-d"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Portable surface-of-revolution geometry and projected vector shading"
|
||||
description = "Portable 3D effect geometry (revolve, extrude and bevel, inflate, rotate) and projected vector shading"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
@@ -1,11 +1,14 @@
|
||||
//! A portable surface-of-revolution evaluator. No windowing, GPU or document dependencies.
|
||||
//! Portable 3D effect geometry: Revolve, Extrude & Bevel, Inflate and Rotate. No windowing, GPU or
|
||||
//! document dependencies.
|
||||
//!
|
||||
//! Open profiles generate uncapped surfaces. Faces are projected and sorted back to front,
|
||||
//! then flat shaded. This vector preview uses a painter's algorithm, not a depth buffer:
|
||||
//! intersecting profiles can have ambiguous visibility and are not solid modelling inputs.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod solid;
|
||||
mod visibility;
|
||||
pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, plane};
|
||||
pub use visibility::{VisibleFace, visible_faces};
|
||||
|
||||
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect};
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,289 @@
|
||||
use vectorcraft_geom::{BezPath, PathData, Point, Rect};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn square(x: f64, y: f64, s: f64) -> BezPath {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((x, y));
|
||||
b.line_to((x + s, y));
|
||||
b.line_to((x + s, y + s));
|
||||
b.line_to((x, y + s));
|
||||
b.close_path();
|
||||
b
|
||||
}
|
||||
|
||||
fn shape(b: &BezPath) -> (PathData, Rect) {
|
||||
let p = PathData::from_bezpath(b);
|
||||
let r = p.bounds().unwrap();
|
||||
(p, r)
|
||||
}
|
||||
|
||||
/// Straight on, without perspective.
|
||||
fn front() -> View {
|
||||
View { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, perspective: 0.0, ..View::default() }
|
||||
}
|
||||
|
||||
fn extent(f: &[Facet]) -> Rect {
|
||||
let pts: Vec<Point> = f.iter().flat_map(|f| f.contours.iter().flatten().copied()).collect();
|
||||
pts.iter().fold(Rect::from_points(pts[0], pts[0]), |r, p| r.union_pt(*p))
|
||||
}
|
||||
|
||||
/// The area the facets cover: each facet's contours counted with their winding (holes
|
||||
/// subtract), whichever way round the facet runs.
|
||||
fn area(f: &[Facet]) -> f64 {
|
||||
f.iter().map(|f| f.contours.iter().map(|c| signed_area(c)).sum::<f64>().abs()).sum()
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64, tol: f64) -> bool {
|
||||
(a - b).abs() <= tol
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seen_straight_on_an_extrusion_is_its_front_face_exactly_where_the_art_is() {
|
||||
let (p, b) = shape(&square(10.0, 20.0, 100.0));
|
||||
let f = extrude(&p, b, Extrude::default(), front()).unwrap();
|
||||
assert_eq!(f.len(), 1, "the walls and back face away: only the front cap shows");
|
||||
assert_eq!(f[0].contours.len(), 1);
|
||||
assert_eq!(extent(&f), Rect::new(10.0, 20.0, 110.0, 120.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turned_about_y_the_front_foreshortens_and_one_side_wall_shows() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let o = Extrude { depth: 40.0, ..Extrude::default() };
|
||||
let f = extrude(&p, b, o, View { rotation_y: 30.0, ..front() }).unwrap();
|
||||
// Orthographic: the silhouette is the front's width times cos 30° plus the depth times sin 30°.
|
||||
let w = extent(&f).width();
|
||||
assert!(close(w, 100.0 * 30f64.to_radians().cos() + 40.0 * 30f64.to_radians().sin(), 1e-6), "{w}");
|
||||
assert_eq!(f.len(), 2, "the front cap and one side wall");
|
||||
// Lit from the upper left by default: the cap and the wall are shaded differently.
|
||||
assert!(!close(f[0].brightness, f[1].brightness, 1e-6));
|
||||
// The nearest face is drawn last.
|
||||
assert!(f.windows(2).all(|w| w[0].depth <= w[1].depth));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_depth_is_the_flat_shape_and_no_cap_shows_the_inside() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() };
|
||||
let flat = extrude(&p, b, Extrude { depth: 0.0, ..Extrude::default() }, v).unwrap();
|
||||
assert_eq!(flat, plane(&p, b, v).unwrap());
|
||||
let solid = extrude(&p, b, Extrude::default(), v).unwrap();
|
||||
let hollow = extrude(&p, b, Extrude { cap: false, ..Extrude::default() }, v).unwrap();
|
||||
// Solid: front, right and top faces. Hollow: all four walls (two seen from inside), no caps.
|
||||
assert_eq!(solid.len(), 3);
|
||||
assert_eq!(hollow.len(), 4);
|
||||
assert!(hollow.iter().all(|f| f.contours.len() == 1 && f.contours[0].len() == 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taper_shrinks_the_back_and_twist_turns_it() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let side = View { rotation_y: 90.0, ..front() };
|
||||
let o = Extrude { depth: 60.0, cap: false, ..Extrude::default() };
|
||||
let straight = extent(&extrude(&p, b, o, side).unwrap());
|
||||
assert!(close(straight.height(), 100.0, 1e-6) && close(straight.width(), 60.0, 1e-6));
|
||||
// Seen from the side, a 50 % taper keeps the front's full height and halves the back's.
|
||||
let tapered = extrude(&p, b, Extrude { taper: 0.5, ..o }, side).unwrap();
|
||||
let back: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x0, 1e-6)).collect();
|
||||
let (lo, hi) = back.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.y), hi.max(q.y)));
|
||||
let at_front: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x1, 1e-6)).collect();
|
||||
let (flo, fhi) = at_front.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.y), hi.max(q.y)));
|
||||
let (back_h, front_h) = (hi - lo, fhi - flo);
|
||||
assert!(close(front_h, 100.0, 1e-6) && close(back_h, 50.0, 1e-6), "front {front_h}, back {back_h}");
|
||||
// Seen from behind, a 45° twist shows the back cap turned into a diamond.
|
||||
let back = View { rotation_y: 180.0, ..front() };
|
||||
let twisted = extrude(&p, b, Extrude { twist: 45.0, cap: true, ..o }, back).unwrap();
|
||||
assert_eq!(twisted.len(), 1, "only the back cap faces the viewer");
|
||||
assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted));
|
||||
// Its walls are cut into slices that follow the turn.
|
||||
let open = extrude(&p, b, Extrude { twist: 45.0, ..o }, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
|
||||
assert!(open.len() > 2 * 8, "{}", open.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let bevel = Bevel { width: 10.0, height: 10.0, ..Bevel::default() };
|
||||
let inside = extrude(&p, b, Extrude { bevel: Some(Bevel { inside: true, ..bevel }), ..Extrude::default() }, front()).unwrap();
|
||||
// Straight on: the cap inset by the width, and the chamfer around it filling the outline.
|
||||
let cap = inside.iter().find(|f| close(signed_area(&f.contours[0]).abs(), 80.0 * 80.0, 1e-6));
|
||||
assert!(cap.is_some(), "an 80 × 80 cap");
|
||||
assert_eq!(extent(&inside), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert!(close(area(&inside), 100.0 * 100.0, 1e-6));
|
||||
let outside = extrude(&p, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, front()).unwrap();
|
||||
assert_eq!(extent(&outside), Rect::new(-10.0, -10.0, 110.0, 110.0), "the walls stand 10 pt outside the art");
|
||||
assert!(outside.iter().any(|f| close(signed_area(&f.contours[0]).abs(), 100.0 * 100.0, 1e-6)), "the cap is the art");
|
||||
// The chamfer's faces lean towards the light differently from the cap.
|
||||
assert!(inside.iter().any(|f| !close(f.brightness, inside[inside.len() - 1].brightness, 1e-3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bevel_shapes_repeats_and_both_sides_change_the_surface() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() };
|
||||
let base = Bevel { width: 12.0, height: 12.0, ..Bevel::default() };
|
||||
let classic = extrude(&p, b, Extrude { bevel: Some(base), ..Extrude::default() }, v).unwrap();
|
||||
for shape in BevelShape::ALL.into_iter().skip(1) {
|
||||
assert_ne!(classic, extrude(&p, b, Extrude { bevel: Some(Bevel { shape, ..base }), ..Extrude::default() }, v).unwrap(), "{shape:?}");
|
||||
}
|
||||
for bevel in [Bevel { repeat: 3, ..base }, Bevel { repeat: 3, space: 0.3, ..base }, Bevel { both_sides: true, ..base }] {
|
||||
assert_ne!(classic, extrude(&p, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, v).unwrap(), "{bevel:?}");
|
||||
}
|
||||
// The step shape keeps its corners: half the width at half the height.
|
||||
assert_eq!(BevelShape::Step.at(0.5), (0.5, 0.5));
|
||||
assert_eq!(BevelShape::Step.at(0.25), (0.0, 0.5));
|
||||
for shape in BevelShape::ALL {
|
||||
assert_eq!(shape.at(0.0), (0.0, 0.0), "{shape:?}");
|
||||
let (x, y) = shape.at(1.0);
|
||||
assert!(close(x, 1.0, 1e-12) && close(y, 1.0, 1e-12), "{shape:?}");
|
||||
assert_eq!(BevelShape::parse(shape.id()), Some(shape));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holes_stay_holes_in_the_caps_and_get_walls_of_their_own() {
|
||||
let mut b = square(0.0, 0.0, 100.0);
|
||||
// The hole drawn the same way round as the outline: even-odd nesting still makes it a hole.
|
||||
b.extend(square(30.0, 30.0, 40.0));
|
||||
let (p, r) = shape(&b);
|
||||
let f = extrude(&p, r, Extrude::default(), front()).unwrap();
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].contours.len(), 2);
|
||||
assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-6));
|
||||
let turned = extrude(&p, r, Extrude::default(), View { rotation_y: 40.0, ..front() }).unwrap();
|
||||
// Front cap, the outline's right wall and the hole's left wall (seen through the hole).
|
||||
assert_eq!(turned.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plane_turns_about_the_given_centre() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let f = plane(&p, b, View { rotation_y: 60.0, ..front() }).unwrap();
|
||||
assert_eq!(f.len(), 1);
|
||||
let e = extent(&f);
|
||||
assert!(close(e.width(), 50.0, 1e-6) && close(e.center().x, 50.0, 1e-9));
|
||||
// Turned about the centre of a larger box, the square moves as well as narrowing.
|
||||
let wide = plane(&p, Rect::new(0.0, 0.0, 300.0, 100.0), View { rotation_y: 60.0, ..front() }).unwrap();
|
||||
assert!(close(extent(&wide).x0, 150.0 - 150.0 * 0.5, 1e-6));
|
||||
// Its back shows when turned past 90°: a plane is two-sided.
|
||||
assert_eq!(plane(&p, b, View { rotation_y: 150.0, ..front() }).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inflate_covers_the_shape_and_puffs_by_the_volume() {
|
||||
let mut c = BezPath::new();
|
||||
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01));
|
||||
let (p, b) = shape(&c);
|
||||
let f = inflate(&p, b, Inflate::default(), front()).unwrap();
|
||||
// Straight on, the cushion covers the circle (sampled as a polygon) and nothing else.
|
||||
assert!(close(area(&f), std::f64::consts::PI * 50.0 * 50.0, 60.0), "{}", area(&f));
|
||||
assert!(extent(&f).x0 >= 50.0 - 1e-6 && extent(&f).x1 <= 150.0 + 1e-6);
|
||||
// Its slopes catch the light differently.
|
||||
let (lo, hi) = f.iter().fold((1.0f64, 0.0f64), |(lo, hi), f| (lo.min(f.brightness), hi.max(f.brightness)));
|
||||
assert!(hi - lo > 0.3, "{lo}..{hi}");
|
||||
// From the side: 50 % volume rounds a disc into a half sphere on a flat back.
|
||||
let side = View { rotation_y: 90.0, ..front() };
|
||||
let thick = |o: Inflate| extent(&inflate(&p, b, o, side).unwrap()).width();
|
||||
assert!(close(thick(Inflate::default()), 50.0, 2.0), "{}", thick(Inflate::default()));
|
||||
assert!(close(thick(Inflate { both_sides: true, ..Inflate::default() }), 100.0, 4.0));
|
||||
assert!(close(thick(Inflate { depth: 30.0, ..Inflate::default() }), 80.0, 2.0));
|
||||
assert!(close(thick(Inflate { volume: 25.0, ..Inflate::default() }), 25.0, 2.0));
|
||||
assert!(thick(Inflate { volume: 0.0, depth: 10.0, ..Inflate::default() }) <= 10.0 + 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inflate_leaves_holes_open() {
|
||||
let mut b = square(0.0, 0.0, 100.0);
|
||||
b.extend(square(30.0, 30.0, 40.0));
|
||||
let (p, r) = shape(&b);
|
||||
let f = inflate(&p, r, Inflate::default(), front()).unwrap();
|
||||
assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-3), "{}", area(&f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_degenerate_input_is_bounded() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
for r in [Rect::new(f64::NAN, 0.0, 1.0, 1.0), Rect::new(0.0, 0.0, 1e9, 1.0)] {
|
||||
assert!(extrude(&p, r, Extrude::default(), front()).is_err());
|
||||
assert!(inflate(&p, r, Inflate::default(), front()).is_err());
|
||||
assert!(plane(&p, r, front()).is_err());
|
||||
}
|
||||
let wild = Extrude {
|
||||
depth: f64::INFINITY,
|
||||
twist: f64::NAN,
|
||||
taper: -3.0,
|
||||
cap: true,
|
||||
bevel: Some(Bevel { width: f64::NAN, height: 1e12, repeat: usize::MAX, space: 7.0, ..Bevel::default() }),
|
||||
};
|
||||
let f = extrude(&p, b, wild, View { rotation_x: f64::NAN, perspective: 9.0, ..front() }).unwrap();
|
||||
assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite()));
|
||||
assert!(inflate(&p, b, Inflate { depth: -5.0, volume: f64::NAN, both_sides: true }, front()).is_ok());
|
||||
assert!(extrude(&PathData::default(), b, Extrude::default(), front()).unwrap().is_empty());
|
||||
// A line has no area to extrude.
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((0.0, 0.0));
|
||||
line.line_to((100.0, 0.0));
|
||||
assert!(extrude(&PathData::from_bezpath(&line), b, Extrude::default(), front()).unwrap().is_empty());
|
||||
// Too many anchors: refused, not truncated.
|
||||
let mut many = BezPath::new();
|
||||
many.move_to((0.0, 0.0));
|
||||
for i in 0..3000 {
|
||||
many.line_to((f64::from(i), f64::from(i % 7)));
|
||||
}
|
||||
many.close_path();
|
||||
assert!(extrude(&PathData::from_bezpath(&many), b, Extrude::default(), front()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perspective_makes_the_near_face_larger() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let o = Extrude { depth: 100.0, ..Extrude::default() };
|
||||
let flat = extent(&extrude(&p, b, o, front()).unwrap());
|
||||
let deep = extent(&extrude(&p, b, o, View { perspective: 0.6, ..front() }).unwrap());
|
||||
assert!(deep.width() > flat.width() + 1.0);
|
||||
assert!(close(deep.center().x, flat.center().x, 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inflate_membrane_matches_known_solutions() {
|
||||
// ∇²φ = −1, zero on the outline: a disc of radius r peaks at r²/4, a square of side l at
|
||||
// about 0.07367 l².
|
||||
let mut c = BezPath::new();
|
||||
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01));
|
||||
let (p, b) = shape(&c);
|
||||
let disc = Membrane::solve(&rings(&p).unwrap(), b.origin(), b.width() / 48.0, 49, 49);
|
||||
assert!(close(disc.peak, 50.0 * 50.0 / 4.0, 625.0 * 0.02), "{}", disc.peak);
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let sq = Membrane::solve(&rings(&p).unwrap(), b.origin(), 100.0 / 48.0, 49, 49);
|
||||
assert!(close(sq.peak, 0.07367 * 100.0 * 100.0, 736.7 * 0.02), "{}", sq.peak);
|
||||
// Smooth across the middle: no crease where a distance-to-the-edge height would have one
|
||||
// (the diagonals). The second difference along a diagonal stays small and even.
|
||||
let along: Vec<f64> = (10..=90).step_by(5).map(|t| sq.at(Point::new(f64::from(t), f64::from(t)))).collect();
|
||||
// (Near the corners φ grows like r², so the bend changes sign there: that is the solution,
|
||||
// not a crease. A crease would be one bend far larger than its neighbours.)
|
||||
let bends: Vec<f64> = along.windows(3).map(|w| w[0] - 2.0 * w[1] + w[2]).collect();
|
||||
let largest = bends.iter().fold(0.0f64, |m, b| m.max(b.abs()));
|
||||
assert!(bends.windows(2).all(|w| (w[1] - w[0]).abs() < 0.4 * largest), "no kink: {bends:?}");
|
||||
// Zero (or just below, extrapolated) on the outline.
|
||||
assert!(sq.at(Point::new(0.0, 50.0)).abs() < 1.0 && sq.at(Point::new(50.0, 0.0)).abs() < 1.0);
|
||||
}
|
||||
|
||||
/// Grid cells across a hole's edge are cut to the shape itself: a hole's piece cancelling part of
|
||||
/// the outline's only works flat, and lifted onto the surface it left notches along the edge.
|
||||
#[test]
|
||||
fn inflate_cells_across_a_hole_edge_are_cut_to_the_shape() {
|
||||
let mut b = square(80.0, 330.0, 220.0);
|
||||
b.move_to((140.0, 390.0));
|
||||
b.line_to((140.0, 480.0));
|
||||
b.line_to((240.0, 480.0));
|
||||
b.line_to((240.0, 390.0));
|
||||
b.close_path();
|
||||
let (p, r) = shape(&b);
|
||||
let v = View { rotation_x: -21.88, rotation_y: -23.4, rotation_z: 9.06, ..front() };
|
||||
let f = inflate(&p, r, Inflate::default(), v).unwrap();
|
||||
assert!(f.iter().all(|f| f.contours.len() == 1), "every facet is one piece of the surface");
|
||||
// Straight on, the facets still cover exactly the shape.
|
||||
let flat = inflate(&p, r, Inflate::default(), front()).unwrap();
|
||||
assert!(close(area(&flat), 220.0 * 220.0 - 100.0 * 90.0, 1e-3), "{}", area(&flat));
|
||||
}
|
||||
@@ -127,7 +127,7 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String>
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let (id, relative) = (d.str("__effect"), d.bool("relative"));
|
||||
if id == vectorcraft_effects::REVOLVE {
|
||||
if vectorcraft_effects::is_three_d(&id) {
|
||||
super::revolve::body(app, ui, d);
|
||||
let (cmd, params) = command(d);
|
||||
form::preview(app, ui, d, &d.str("__label"), cmd, params);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Compact Revolve controls. The object and rotation gizmo live on the artboard.
|
||||
//! All document changes run through the effect dialog's existing preview/commit commands.
|
||||
//! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate.
|
||||
//! The object and the rotation and light gizmos live on the artboard. All document changes run
|
||||
//! through the effect dialog's existing preview/commit commands.
|
||||
|
||||
use egui::{Align2, FontId, Id, Pos2, Sense, Stroke, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Unit;
|
||||
use vectorcraft_effects::REVOLVE;
|
||||
use vectorcraft_effects::{EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS};
|
||||
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
@@ -32,7 +33,8 @@ pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.add(egui::Label::new(egui::RichText::new(super::revolve_gizmo::hint(d)).size(11.0).color(t.text_dim)).wrap());
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let defaults = vectorcraft_effects::default_params(REVOLVE).unwrap_or(json!({}));
|
||||
let id = d.str("__effect");
|
||||
let defaults = vectorcraft_effects::default_params(&id).unwrap_or(json!({}));
|
||||
let changed = defaults.as_object().is_some_and(|m| {
|
||||
m.iter().any(|(k, v)| v.as_f64().map_or_else(|| d.fields.get(k) != Some(v), |default| (d.f64(k, default) - default).abs() > 1e-6))
|
||||
});
|
||||
@@ -44,7 +46,9 @@ pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
ui.painter().text(r.left_center() + vec2(4.0, 0.0), Align2::LEFT_CENTER, tl!("Reset all"), FontId::proportional(11.5), t.text);
|
||||
dot(ui, r.right_center() - vec2(10.0, 0.0), changed);
|
||||
if response.on_hover_text(tl!("Reset all Revolve settings to their defaults.")).clicked()
|
||||
let tip =
|
||||
if id == REVOLVE { tl!("Reset all Revolve settings to their defaults.") } else { tl!("Reset all 3D settings to their defaults.") };
|
||||
if response.on_hover_text(tip).clicked()
|
||||
&& let Some(defaults) = defaults.as_object()
|
||||
{
|
||||
d.fields.extend(defaults.clone());
|
||||
@@ -55,6 +59,10 @@ pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
|
||||
fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.spacing_mut().item_spacing.y = 4.0;
|
||||
let id = d.str("__effect");
|
||||
if id != REVOLVE {
|
||||
return solid_controls(app, ui, d, &id);
|
||||
}
|
||||
let lighting = super::revolve_gizmo::lighting(d);
|
||||
if let Some(tab) = widgets::tab_bar(ui, &[tl!("Rotation"), tl!("Lighting")], usize::from(lighting)) {
|
||||
d.fields.insert("__gizmo".into(), json!(if tab == 1 { "light" } else { "rotation" }));
|
||||
@@ -110,6 +118,155 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `s` as the 3D effects mean it (a solid's cap, not a stroke's; a bevelled edge, not a join).
|
||||
fn c3(s: &'static str) -> &'static str {
|
||||
crate::i18n::tr_ctx(crate::i18n::current(), "3d", s)
|
||||
}
|
||||
|
||||
/// Extrude & Bevel and Inflate: Object, Rotation and Lighting tabs (Rotate has no Object tab).
|
||||
/// The rotation gizmo shows on the first two, the light gizmo on Lighting.
|
||||
fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) {
|
||||
let object = id == EXTRUDE || id == INFLATE;
|
||||
let mut tabs = vec![];
|
||||
if object {
|
||||
tabs.push(tl!("Object"));
|
||||
}
|
||||
tabs.extend([tl!("Rotation"), tl!("Lighting")]);
|
||||
let current = if super::revolve_gizmo::lighting(d) {
|
||||
tabs.len() - 1
|
||||
} else if object && d.str("__tab") != "rotation" {
|
||||
0
|
||||
} else {
|
||||
tabs.len() - 2
|
||||
};
|
||||
if let Some(tab) = widgets::tab_bar(ui, &tabs, current) {
|
||||
let light = tab + 1 == tabs.len();
|
||||
d.fields.insert("__gizmo".into(), json!(if light { "light" } else { "rotation" }));
|
||||
d.fields.insert("__tab".into(), json!(if object && tab == 0 { "object" } else { "rotation" }));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if super::revolve_gizmo::lighting(d) {
|
||||
lighting_controls(ui, d);
|
||||
} else if object && d.str("__tab") != "rotation" {
|
||||
let unit = app.session.general_unit();
|
||||
if id == EXTRUDE {
|
||||
extrude_controls(ui, d, unit);
|
||||
} else {
|
||||
heading(ui, tl!("Object"));
|
||||
number(ui, d, "depth", tl!("Depth"), (0.0, 2000.0), "", 1, Some(unit));
|
||||
number(ui, d, "volume", tl!("Volume"), (0.0, 100.0), "%", 1, None);
|
||||
toggle(ui, d, "bothSides", tl!("Both sides"), false);
|
||||
}
|
||||
} else {
|
||||
view_controls(ui, d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrude & Bevel's depth, twist, taper, cap and bevel.
|
||||
fn extrude_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
|
||||
heading(ui, tl!("Object"));
|
||||
number(ui, d, "depth", tl!("Depth"), (0.0, 2000.0), "", 1, Some(unit));
|
||||
number(ui, d, "twist", tl!("Twist"), (-360.0, 360.0), "°", 1, None);
|
||||
number(ui, d, "taper", tl!("Taper"), (1.0, 100.0), "%", 1, None);
|
||||
choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]);
|
||||
ui.add_space(10.0);
|
||||
heading(ui, c3("Bevel"));
|
||||
let shapes = [
|
||||
(json!("none"), tl!("None")),
|
||||
(json!("classic"), tl!("Classic")),
|
||||
(json!("rounded"), c3("Rounded")),
|
||||
(json!("cove"), tl!("Cove")),
|
||||
(json!("step"), tl!("Step")),
|
||||
(json!("ogee"), tl!("Ogee")),
|
||||
];
|
||||
choice(ui, d, "bevel", tl!("Shape"), &shapes);
|
||||
ui.add_enabled_ui(d.str("bevel") != "none", |ui| {
|
||||
number(ui, d, "bevelWidth", tl!("Width"), (0.0, 1000.0), "", 1, Some(unit));
|
||||
number(ui, d, "bevelHeight", tl!("Height"), (0.0, 1000.0), "", 1, Some(unit));
|
||||
number(ui, d, "bevelRepeat", tl!("Repeat"), (1.0, 32.0), "", 0, None);
|
||||
number(ui, d, "bevelSpace", tl!("Spacing"), (0.0, 90.0), "%", 1, None);
|
||||
toggle(ui, d, "bevelInside", c3("Inside"), false);
|
||||
toggle(ui, d, "bevelBothSides", tl!("Both sides"), false);
|
||||
});
|
||||
}
|
||||
|
||||
/// Position presets, rotation and perspective for Extrude & Bevel, Inflate and Rotate.
|
||||
fn view_controls(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
heading(ui, tl!("Rotation"));
|
||||
let names = [
|
||||
tl!("Front"),
|
||||
tl!("Back"),
|
||||
tl!("Left"),
|
||||
tl!("Right"),
|
||||
tl!("Top"),
|
||||
tl!("Bottom"),
|
||||
tl!("Off-Axis Front"),
|
||||
tl!("Off-Axis Back"),
|
||||
tl!("Off-Axis Left"),
|
||||
tl!("Off-Axis Right"),
|
||||
tl!("Off-Axis Top"),
|
||||
tl!("Off-Axis Bottom"),
|
||||
tl!("Isometric Left"),
|
||||
tl!("Isometric Right"),
|
||||
tl!("Isometric Top"),
|
||||
tl!("Isometric Bottom"),
|
||||
];
|
||||
let angles = [d.f64("rotationX", 0.0), d.f64("rotationY", 0.0), d.f64("rotationZ", 0.0)];
|
||||
let current = ROTATION_PRESETS.iter().position(|(_, a)| a.iter().zip(angles).all(|(a, b)| (a - b).abs() < 0.005));
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
label(ui, tl!("Position"));
|
||||
let width = VALUE_W + rail_width(ui) + 6.0;
|
||||
let shown = current.and_then(|i| names.get(i).copied()).unwrap_or(tl!("Custom"));
|
||||
if let Some(k) = widgets::dropdown(ui, "three-d-position", shown, &names, width)
|
||||
&& let Some((_, [x, y, z])) = ROTATION_PRESETS.get(k)
|
||||
{
|
||||
d.fields.extend([("rotationX".into(), json!(x)), ("rotationY".into(), json!(y)), ("rotationZ".into(), json!(z))]);
|
||||
}
|
||||
});
|
||||
for (key, name) in [("rotationX", tl!("Rotation X")), ("rotationY", tl!("Rotation Y")), ("rotationZ", tl!("Rotation Z"))] {
|
||||
number(ui, d, key, name, (-180.0, 180.0), "°", 2, None);
|
||||
}
|
||||
number(ui, d, "perspective", tl!("Perspective"), (0.0, 100.0), "%", 1, None);
|
||||
}
|
||||
|
||||
/// A dropdown of `options` (value, label) for `key`, with its reset dot.
|
||||
fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(Value, &str)]) {
|
||||
let id = d.str("__effect");
|
||||
let default = vectorcraft_effects::default_params(&id).and_then(|p| p.get(key).cloned()).unwrap_or(Value::Null);
|
||||
let value = d.fields.get(key).cloned().unwrap_or_else(|| default.clone());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
label(ui, name);
|
||||
let current = options.iter().find(|(v, _)| *v == value).map_or("", |(_, l)| *l);
|
||||
let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
|
||||
let width = VALUE_W + rail_width(ui) + 6.0;
|
||||
if let Some(k) = widgets::dropdown(ui, ("three-d-choice", key), current, &labels, width)
|
||||
&& let Some((v, _)) = options.get(k)
|
||||
{
|
||||
d.fields.insert(key.into(), v.clone());
|
||||
}
|
||||
let default_label = options.iter().find(|(v, _)| *v == default).map_or("", |(_, l)| *l);
|
||||
if reset_dot(ui, key, value != default, default_label) {
|
||||
d.fields.insert(key.into(), default.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A checkbox for `key`, with its reset dot.
|
||||
fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: bool) {
|
||||
ui.horizontal(|ui| {
|
||||
let on = d.bool(key);
|
||||
if widgets::check(ui, name, on, ui.is_enabled()) {
|
||||
d.fields.insert(key.into(), json!(!on));
|
||||
}
|
||||
ui.add_space((ui.available_width() - 24.0).max(0.0));
|
||||
if reset_dot(ui, key, on != default, if default { "true" } else { "false" }) {
|
||||
d.fields.insert(key.into(), json!(default));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.horizontal(|ui| {
|
||||
let shade = d.bool("shade");
|
||||
@@ -148,7 +305,7 @@ fn rail_width(ui: &egui::Ui) -> f32 {
|
||||
/// A value can be typed, dragged, scrubbed by its label, or moved on its slider.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, f64), suffix: &str, decimals: usize, unit: Option<Unit>) {
|
||||
let default = vectorcraft_effects::default_params(REVOLVE).and_then(|p| p.get(key).and_then(Value::as_f64)).unwrap_or(0.0);
|
||||
let default = vectorcraft_effects::default_params(&d.str("__effect")).and_then(|p| p.get(key).and_then(Value::as_f64)).unwrap_or(0.0);
|
||||
let from = |n| unit.map_or(n, |u| u.from_pt(n));
|
||||
let to = |n| unit.map_or(n, |u| u.to_pt(n));
|
||||
let typed = unit.map(|u| widgets::typed_unit(ui.ctx(), u));
|
||||
@@ -187,8 +344,12 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64,
|
||||
if let Some(n) = scrub::field(ui, response.id, response.rect, &value.to_string(), Some(value), decimals as i32) {
|
||||
next = Some(n);
|
||||
}
|
||||
// Offset's rail covers a useful working range; larger values can still be typed.
|
||||
let rail_max = if key == "offset" { from(500.0) } else { from(range.1) };
|
||||
// Lengths' rails cover a useful working range; larger values can still be typed.
|
||||
let rail_max = match key {
|
||||
"offset" | "depth" => from(500.0),
|
||||
"bevelWidth" | "bevelHeight" => from(100.0),
|
||||
_ => from(range.1),
|
||||
};
|
||||
let rail_min = from(range.0);
|
||||
let rail = rail_width(ui) + VALUE_W;
|
||||
let slider =
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Artboard rotation and light controls for the active Revolve dialog. Gestures edit fields;
|
||||
//! Artboard rotation and light controls for the active 3D effect dialog (Extrude & Bevel, Revolve,
|
||||
//! Inflate, Rotate). Gestures edit fields;
|
||||
//! the existing effect preview applies commands and owns OK, Cancel and undo.
|
||||
use egui::{Align2, FontId, Id, Pos2, Rect, Sense, Stroke, Vec2, vec2};
|
||||
use serde_json::json;
|
||||
@@ -12,7 +13,7 @@ const KEYS: [&str; 3] = ["rotationX", "rotationY", "rotationZ"];
|
||||
const SAMPLES: usize = 96;
|
||||
|
||||
pub(crate) fn active(d: &Dialog) -> bool {
|
||||
d.kind == "effect" && d.str("__effect") == REVOLVE
|
||||
d.kind == "effect" && vectorcraft_effects::is_three_d(&d.str("__effect"))
|
||||
}
|
||||
|
||||
pub(crate) fn lighting(d: &Dialog) -> bool {
|
||||
@@ -85,7 +86,9 @@ impl Geometry {
|
||||
let bounds = node.geometric_bounds()?;
|
||||
let xf = Xf::new(app.canvas_rect?, app.view()?);
|
||||
let options = revolve_options(&super::form::params(d));
|
||||
let center = xf.to_screen(options.rotation_origin(bounds));
|
||||
// Revolve turns about its axis; the others about the centre of the art's bounds.
|
||||
let origin = if d.str("__effect") == REVOLVE { options.rotation_origin(bounds) } else { bounds.center() };
|
||||
let center = xf.to_screen(origin);
|
||||
if !center.x.is_finite() || !center.y.is_finite() || !xf.rect.contains(center) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -256,6 +256,31 @@ fn revolve_dialog_preview_cancel_confirm_and_undo() {
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrude_inflate_and_rotate_dialogs_preview_confirm_and_undo() {
|
||||
for effect in vectorcraft_effects::SOLIDS {
|
||||
let mut app = app();
|
||||
app.run("path.create", json!({"d":"M100 100 L200 100 L200 200 L100 200 Z"})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
app.run("effect.dialog", json!({"effect": effect, "item": null})).unwrap();
|
||||
frame(&mut app, Default::default());
|
||||
assert!(app.session.in_interaction(), "{effect}: previewing");
|
||||
cancel(&mut app);
|
||||
assert_eq!(app.session.doc().unwrap().doc, before, "{effect}: Cancel restores");
|
||||
app.run("effect.dialog", json!({"effect": effect, "item": null})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("rotationY".into(), json!(45));
|
||||
frame(&mut app, Default::default());
|
||||
confirm(&mut app).unwrap();
|
||||
let doc = &app.session.doc().unwrap().doc;
|
||||
let n = doc.node(doc.layers[0].children().unwrap()[0].id).unwrap();
|
||||
assert_eq!(n.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), [effect]);
|
||||
assert_eq!(n.appearance.effects[0].params["rotationY"], json!(45));
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc, before, "{effect}: one undo step");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialogs_are_as_tall_as_their_content() {
|
||||
// The save prompt, shown after a taller dialog, is as tall as its text and buttons: no empty
|
||||
|
||||
@@ -13,11 +13,16 @@ struct Harness {
|
||||
|
||||
impl Harness {
|
||||
fn new(screen: Vec2) -> Self {
|
||||
Self::with("M150 40 L180 60 L180 140 L150 160", REVOLVE, screen)
|
||||
}
|
||||
|
||||
/// The dialog of 3D effect `effect` on a path drawn from SVG path data `d`.
|
||||
fn with(d: &str, effect: &str, screen: Vec2) -> Self {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width":300,"height":300})).unwrap();
|
||||
app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap();
|
||||
app.run("path.create", json!({"d": d})).unwrap();
|
||||
app.run("paint.setFill", json!({"color":"#338ad6"})).unwrap();
|
||||
app.run("effect.dialog", json!({"effect":REVOLVE,"item":null})).unwrap();
|
||||
app.run("effect.dialog", json!({"effect":effect,"item":null})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
@@ -199,3 +204,54 @@ fn advanced_visibility_option_is_saved_resettable_and_leaves_preview_geometry_co
|
||||
let loaded = vectorcraft_format::load(&vectorcraft_format::save(&state.doc, false)).unwrap();
|
||||
assert_eq!(loaded.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true));
|
||||
}
|
||||
|
||||
/// Extrude & Bevel, Inflate and Rotate open the same compact controls, with their own options, the
|
||||
/// Position presets and the artboard gizmo turning about the centre of the art.
|
||||
#[test]
|
||||
fn the_other_3d_effects_show_their_options_and_position_presets() {
|
||||
use vectorcraft_effects::{EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS};
|
||||
let square = "M100 100 L200 100 L200 200 L100 200 Z";
|
||||
for (effect, own) in
|
||||
[(EXTRUDE, &["Depth", "Twist", "Taper", "Cap", "Bevel"][..]), (INFLATE, &["Depth", "Volume", "Both sides"][..]), (ROTATE, &[][..])]
|
||||
{
|
||||
let mut h = Harness::with(square, effect, vec2(1400.0, 1000.0));
|
||||
h.settle();
|
||||
// Extrude & Bevel and Inflate open on their Object tab; Rotate on Rotation.
|
||||
for text in own.iter() {
|
||||
h.rect(text);
|
||||
}
|
||||
if !own.is_empty() {
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("__tab".into(), json!("rotation"));
|
||||
h.settle();
|
||||
}
|
||||
for text in ["Position", "Rotation X", "Perspective"] {
|
||||
h.rect(text);
|
||||
}
|
||||
// The defaults are the Off-Axis Front position, shown by name.
|
||||
h.rect("Off-Axis Front");
|
||||
assert!(!h.texts.iter().any(|(t, _)| t == "Revolve angle"), "{effect}: Revolve's own options stay out");
|
||||
let d = h.app.ui.dialog.as_ref().unwrap();
|
||||
assert!(dialogs::revolve_gizmo::active(d), "{effect}: the gizmo is on");
|
||||
// A preset's angles show its name; other angles are Custom.
|
||||
let [x, y, z] = ROTATION_PRESETS[14].1;
|
||||
let d = h.app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.extend([("rotationX".into(), json!(x)), ("rotationY".into(), json!(y)), ("rotationZ".into(), json!(z))]);
|
||||
h.settle();
|
||||
h.rect("Isometric Top");
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("rotationZ".into(), json!(12.5));
|
||||
h.settle();
|
||||
h.rect("Custom");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrude_bevel_options_enable_with_a_bevel_shape() {
|
||||
let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", vectorcraft_effects::EXTRUDE, vec2(1400.0, 1000.0));
|
||||
h.settle();
|
||||
let width = h.response(("revolve-reset", "bevelWidth"));
|
||||
assert!(!h.ctx.read_response(Id::new(("revolve-reset", "bevelWidth"))).unwrap().enabled(), "no bevel: its sizes are off");
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("bevel".into(), json!("rounded"));
|
||||
h.settle();
|
||||
assert!(h.ctx.read_response(Id::new(("revolve-reset", "bevelWidth"))).unwrap().enabled());
|
||||
assert_eq!(h.response(("revolve-reset", "bevelWidth")), width);
|
||||
}
|
||||
@@ -4510,3 +4510,32 @@ mark-style Japanese Japonské
|
||||
Use Outside Rectangle As Frame Použít vnější obdélník jako rámeček
|
||||
Create Compound Path From Ellipses Vytvořit složenou cestu z elips
|
||||
Fill Grid Vyplnit mřížku
|
||||
# 3D and Materials › Extrude & Bevel, Inflate and Rotate: options, Position presets and bevel shapes.
|
||||
Back Zezadu
|
||||
Both sides Obě strany
|
||||
Classic Klasické
|
||||
Cove Žlábek
|
||||
Depth Hloubka
|
||||
Front Zepředu
|
||||
Isometric Bottom Izometricky zdola
|
||||
Isometric Left Izometricky zleva
|
||||
Isometric Right Izometricky zprava
|
||||
Isometric Top Izometricky shora
|
||||
Off-Axis Back Šikmo zezadu
|
||||
Off-Axis Bottom Šikmo zdola
|
||||
Off-Axis Front Šikmo zepředu
|
||||
Off-Axis Left Šikmo zleva
|
||||
Off-Axis Right Šikmo zprava
|
||||
Off-Axis Top Šikmo shora
|
||||
Ogee Esovité
|
||||
Reset all 3D settings to their defaults. Obnoví výchozí hodnoty všech nastavení 3D.
|
||||
Step Schod
|
||||
Taper Zúžení
|
||||
Twist Zkroucení
|
||||
Volume Objem
|
||||
3d Cap Uzávěr
|
||||
3d Solid Plné
|
||||
3d Hollow Duté
|
||||
3d Bevel Zkosení
|
||||
3d Rounded Zaoblené
|
||||
3d Inside Dovnitř
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4161,3 +4161,32 @@ theme Appearance Erscheinungsbild
|
||||
Use Outside Rectangle As Frame Äußeres Rechteck als Rahmen verwenden
|
||||
Create Compound Path From Ellipses Zusammengesetzten Pfad aus Ellipsen erstellen
|
||||
Fill Grid Raster füllen
|
||||
# 3D and Materials › Extrude & Bevel, Inflate and Rotate: options, Position presets and bevel shapes.
|
||||
Back Hinten
|
||||
Both sides Beidseitig
|
||||
Classic Klassisch
|
||||
Cove Hohlkehle
|
||||
Depth Tiefe
|
||||
Front Vorne
|
||||
Isometric Bottom Isometrisch unten
|
||||
Isometric Left Isometrisch links
|
||||
Isometric Right Isometrisch rechts
|
||||
Isometric Top Isometrisch oben
|
||||
Off-Axis Back Schräg hinten
|
||||
Off-Axis Bottom Schräg unten
|
||||
Off-Axis Front Schräg vorne
|
||||
Off-Axis Left Schräg links
|
||||
Off-Axis Right Schräg rechts
|
||||
Off-Axis Top Schräg oben
|
||||
Ogee Karnies
|
||||
Reset all 3D settings to their defaults. Alle 3D-Einstellungen auf ihre Standardwerte zurücksetzen.
|
||||
Step Stufe
|
||||
Taper Verjüngung
|
||||
Twist Verdrehung
|
||||
Volume Volumen
|
||||
3d Cap Deckflächen
|
||||
3d Solid Massiv
|
||||
3d Hollow Hohl
|
||||
3d Bevel Abgeflachte Kante
|
||||
3d Rounded Abgerundet
|
||||
3d Inside Nach innen
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4578,3 +4578,32 @@ mark-style Japanese Japonesas
|
||||
X: X:
|
||||
Y: Y:
|
||||
@id file.close Cerrar archivo
|
||||
# 3D and Materials › Extrude & Bevel, Inflate and Rotate: options, Position presets and bevel shapes.
|
||||
Back Posterior
|
||||
Both sides Ambos lados
|
||||
Classic Clásico
|
||||
Cove Cóncavo
|
||||
Depth Profundidad
|
||||
Front Frontal
|
||||
Isometric Bottom Isométrica inferior
|
||||
Isometric Left Isométrica izquierda
|
||||
Isometric Right Isométrica derecha
|
||||
Isometric Top Isométrica superior
|
||||
Off-Axis Back Fuera de eje posterior
|
||||
Off-Axis Bottom Fuera de eje inferior
|
||||
Off-Axis Front Fuera de eje frontal
|
||||
Off-Axis Left Fuera de eje izquierda
|
||||
Off-Axis Right Fuera de eje derecha
|
||||
Off-Axis Top Fuera de eje superior
|
||||
Ogee Gola
|
||||
Reset all 3D settings to their defaults. Restablecer todos los ajustes 3D a sus valores predeterminados.
|
||||
Step Escalón
|
||||
Taper Estrechamiento
|
||||
Twist Torsión
|
||||
Volume Volumen
|
||||
3d Cap Tapa
|
||||
3d Solid Sólido
|
||||
3d Hollow Hueco
|
||||
3d Bevel Bisel
|
||||
3d Rounded Redondeado
|
||||
3d Inside Hacia dentro
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4214,3 +4214,32 @@ lighting Light Lumière
|
||||
Use Outside Rectangle As Frame Utiliser le rectangle extérieur comme cadre
|
||||
Create Compound Path From Ellipses Créer un tracé transparent à partir des ellipses
|
||||
Fill Grid Remplir la grille
|
||||
# 3D and Materials › Extrude & Bevel, Inflate and Rotate: options, Position presets and bevel shapes.
|
||||
Back Arrière
|
||||
Both sides Deux côtés
|
||||
Classic Classique
|
||||
Cove Cavet
|
||||
Depth Profondeur
|
||||
Front Avant
|
||||
Isometric Bottom Isométrique dessous
|
||||
Isometric Left Isométrique gauche
|
||||
Isometric Right Isométrique droite
|
||||
Isometric Top Isométrique dessus
|
||||
Off-Axis Back Désaxé arrière
|
||||
Off-Axis Bottom Désaxé dessous
|
||||
Off-Axis Front Désaxé avant
|
||||
Off-Axis Left Désaxé gauche
|
||||
Off-Axis Right Désaxé droite
|
||||
Off-Axis Top Désaxé dessus
|
||||
Ogee Doucine
|
||||
Reset all 3D settings to their defaults. Rétablir les valeurs par défaut de tous les réglages 3D.
|
||||
Step Gradin
|
||||
Taper Effilement
|
||||
Twist Torsion
|
||||
Volume Volume
|
||||
3d Cap Fermeture
|
||||
3d Solid Plein
|
||||
3d Hollow Creux
|
||||
3d Bevel Biseau
|
||||
3d Rounded Arrondi
|
||||
3d Inside Vers l'intérieur
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4604,3 +4604,32 @@ theme Appearance Kinézet
|
||||
Use Outside Rectangle As Frame Külső téglalap használata keretként
|
||||
Create Compound Path From Ellipses Összetett útvonal létrehozása az ellipszisekből
|
||||
Fill Grid Rács kitöltése
|
||||
# 3D and Materials › Extrude & Bevel, Inflate and Rotate: options, Position presets and bevel shapes.
|
||||
Back Hátulról
|
||||
Both sides Mindkét oldal
|
||||
Classic Klasszikus
|
||||
Cove Homorú
|
||||
Depth Mélység
|
||||
Front Elölről
|
||||
Isometric Bottom Izometrikus alsó
|
||||
Isometric Left Izometrikus bal
|
||||
Isometric Right Izometrikus jobb
|
||||
Isometric Top Izometrikus felső
|
||||
Off-Axis Back Ferdén hátulról
|
||||
Off-Axis Bottom Ferdén alulról
|
||||
Off-Axis Front Ferdén elölről
|
||||
Off-Axis Left Ferdén balról
|
||||
Off-Axis Right Ferdén jobbról
|
||||
Off-Axis Top Ferdén felülről
|
||||
Ogee S-ív
|
||||
Reset all 3D settings to their defaults. Az összes 3D-beállítás visszaállítása az alapértékekre.
|
||||
Step Lépcső
|
||||
Taper Elvékonyodás
|
||||
Twist Csavarás
|
||||
Volume Térfogat
|
||||
3d Cap Zárólap
|
||||
3d Solid Tömör
|
||||
3d Hollow Üreges
|
||||
3d Bevel Élletörés
|
||||
3d Rounded Lekerekített
|
||||
3d Inside Befelé
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4206,3 +4206,32 @@ lighting Light Luce
|
||||
Use Outside Rectangle As Frame Usa rettangolo esterno come cornice
|
||||
Create Compound Path From Ellipses Crea tracciato composto dalle ellissi
|
||||
Fill Grid Riempi griglia
|
||||
# 3D and Materials › Extrude & Bevel, Inflate and Rotate: options, Position presets and bevel shapes.
|
||||
Back Retro
|
||||
Both sides Entrambi i lati
|
||||
Classic Classico
|
||||
Cove Guscio
|
||||
Depth Profondità
|
||||
Front Fronte
|
||||
Isometric Bottom Isometrica sotto
|
||||
Isometric Left Isometrica sinistra
|
||||
Isometric Right Isometrica destra
|
||||
Isometric Top Isometrica sopra
|
||||
Off-Axis Back Fuori asse retro
|
||||
Off-Axis Bottom Fuori asse sotto
|
||||
Off-Axis Front Fuori asse fronte
|
||||
Off-Axis Left Fuori asse sinistra
|
||||
Off-Axis Right Fuori asse destra
|
||||
Off-Axis Top Fuori asse sopra
|
||||
Ogee Gola
|
||||
Reset all 3D settings to their defaults. Ripristina i valori predefiniti di tutte le impostazioni 3D.
|
||||
Step Gradino
|
||||
Taper Rastremazione
|
||||
Twist Torsione
|
||||
Volume Volume
|
||||
3d Cap Chiusura
|
||||
3d Solid Pieno
|
||||
3d Hollow Cavo
|
||||
3d Bevel Smusso
|
||||
3d Rounded Arrotondato
|
||||
3d Inside Verso l'interno
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4200,3 +4200,32 @@ lighting Light ライト
|
||||
Use Outside Rectangle As Frame 外側の長方形をフレームとして使用
|
||||
Create Compound Path From Ellipses 楕円形から複合パスを作成
|
||||
Fill Grid グリッドを塗りつぶす
|
||||
# 3D and Materials › Extrude & Bevel, Inflate and Rotate: options, Position presets and bevel shapes.
|
||||
Back 背面
|
||||
Both sides 両面
|
||||
Classic クラシック
|
||||
Cove 凹面
|
||||
Depth 奥行き
|
||||
Front 正面
|
||||
Isometric Bottom 等角図(底面)
|
||||
Isometric Left 等角図(左)
|
||||
Isometric Right 等角図(右)
|
||||
Isometric Top 等角図(上面)
|
||||
Off-Axis Back 斜め(背面)
|
||||
Off-Axis Bottom 斜め(底面)
|
||||
Off-Axis Front 斜め(正面)
|
||||
Off-Axis Left 斜め(左)
|
||||
Off-Axis Right 斜め(右)
|
||||
Off-Axis Top 斜め(上面)
|
||||
Ogee S字
|
||||
Reset all 3D settings to their defaults. すべての 3D 設定を初期値に戻します。
|
||||
Step 段
|
||||
Taper テーパー
|
||||
Twist ねじれ
|
||||
Volume ボリューム
|
||||
3d Cap キャップ
|
||||
3d Solid ソリッド
|
||||
3d Hollow 中空
|
||||
3d Bevel ベベル
|
||||
3d Rounded 丸み
|
||||
3d Inside 内側
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4170,3 +4170,32 @@ lighting Light 광원
|
||||
Use Outside Rectangle As Frame 바깥쪽 사각형을 프레임으로 사용
|
||||
Create Compound Path From Ellipses 원에서 컴파운드 패스 만들기
|
||||
Fill Grid 격자 칠하기
|
||||
# 3D and Materials › Extrude & Bevel, Inflate and Rotate: options, Position presets and bevel shapes.
|
||||
Back 뒷면
|
||||
Both sides 양면
|
||||
Classic 클래식
|
||||
Cove 오목
|
||||
Depth 깊이
|
||||
Front 앞면
|
||||
Isometric Bottom 등각 밑면
|
||||
Isometric Left 등각 왼쪽
|
||||
Isometric Right 등각 오른쪽
|
||||
Isometric Top 등각 윗면
|
||||
Off-Axis Back 비스듬한 뒷면
|
||||
Off-Axis Bottom 비스듬한 밑면
|
||||
Off-Axis Front 비스듬한 앞면
|
||||
Off-Axis Left 비스듬한 왼쪽
|
||||
Off-Axis Right 비스듬한 오른쪽
|
||||
Off-Axis Top 비스듬한 윗면
|
||||
Ogee S자
|
||||
Reset all 3D settings to their defaults. 모든 3D 설정을 기본값으로 재설정합니다.
|
||||
Step 계단
|
||||
Taper 테이퍼
|
||||
Twist 비틀기
|
||||
Volume 볼륨
|
||||
3d Cap 캡
|
||||
3d Solid 솔리드
|
||||
3d Hollow 속이 빈
|
||||
3d Bevel 베벨
|
||||
3d Rounded 둥글게
|
||||
3d Inside 안쪽
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4202,3 +4202,32 @@ lighting Light Свет
|
||||
Use Outside Rectangle As Frame Использовать внешний прямоугольник как рамку
|
||||
Create Compound Path From Ellipses Создать составной контур из эллипсов
|
||||
Fill Grid Залить сетку
|
||||
# 3D and Materials › Extrude & Bevel, Inflate and Rotate: options, Position presets and bevel shapes.
|
||||
Back Сзади
|
||||
Both sides С обеих сторон
|
||||
Classic Классический
|
||||
Cove Выкружка
|
||||
Depth Глубина
|
||||
Front Спереди
|
||||
Isometric Bottom Изометрия снизу
|
||||
Isometric Left Изометрия слева
|
||||
Isometric Right Изометрия справа
|
||||
Isometric Top Изометрия сверху
|
||||
Off-Axis Back Под углом сзади
|
||||
Off-Axis Bottom Под углом снизу
|
||||
Off-Axis Front Под углом спереди
|
||||
Off-Axis Left Под углом слева
|
||||
Off-Axis Right Под углом справа
|
||||
Off-Axis Top Под углом сверху
|
||||
Ogee Гусёк
|
||||
Reset all 3D settings to their defaults. Сбросить все параметры 3D к значениям по умолчанию.
|
||||
Step Ступенька
|
||||
Taper Сужение
|
||||
Twist Закручивание
|
||||
Volume Объём
|
||||
3d Cap Торцы
|
||||
3d Solid Сплошной
|
||||
3d Hollow Полый
|
||||
3d Bevel Фаска
|
||||
3d Rounded Скруглённый
|
||||
3d Inside Внутрь
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4186,3 +4186,32 @@ lighting Light Світло
|
||||
Use Outside Rectangle As Frame Використовувати зовнішній прямокутник як рамку
|
||||
Create Compound Path From Ellipses Створити складений контур з еліпсів
|
||||
Fill Grid Залити сітку
|
||||
# 3D and Materials › Extrude & Bevel, Inflate and Rotate: options, Position presets and bevel shapes.
|
||||
Back Ззаду
|
||||
Both sides З обох боків
|
||||
Classic Класичний
|
||||
Cove Викружка
|
||||
Depth Глибина
|
||||
Front Спереду
|
||||
Isometric Bottom Ізометрія знизу
|
||||
Isometric Left Ізометрія ліворуч
|
||||
Isometric Right Ізометрія праворуч
|
||||
Isometric Top Ізометрія згори
|
||||
Off-Axis Back Під кутом ззаду
|
||||
Off-Axis Bottom Під кутом знизу
|
||||
Off-Axis Front Під кутом спереду
|
||||
Off-Axis Left Під кутом ліворуч
|
||||
Off-Axis Right Під кутом праворуч
|
||||
Off-Axis Top Під кутом згори
|
||||
Ogee Гусик
|
||||
Reset all 3D settings to their defaults. Скинути всі параметри 3D до типових значень.
|
||||
Step Сходинка
|
||||
Taper Звуження
|
||||
Twist Закручування
|
||||
Volume Об'єм
|
||||
3d Cap Торці
|
||||
3d Solid Суцільний
|
||||
3d Hollow Порожнистий
|
||||
3d Bevel Фаска
|
||||
3d Rounded Заокруглений
|
||||
3d Inside Усередину
|
||||
|
Can't render this file because it contains an unexpected character in line 659 and column 40.
|
@@ -3797,3 +3797,32 @@ lighting Light 光源
|
||||
Use Outside Rectangle As Frame 使用外部矩形作为框架
|
||||
Create Compound Path From Ellipses 从椭圆形创建复合路径
|
||||
Fill Grid 网格填色
|
||||
# 3D and Materials › Extrude & Bevel, Inflate and Rotate: options, Position presets and bevel shapes.
|
||||
Back 背面
|
||||
Both sides 双面
|
||||
Classic 经典
|
||||
Cove 凹圆
|
||||
Depth 深度
|
||||
Front 正面
|
||||
Isometric Bottom 等角底部
|
||||
Isometric Left 等角左侧
|
||||
Isometric Right 等角右侧
|
||||
Isometric Top 等角顶部
|
||||
Off-Axis Back 离轴背面
|
||||
Off-Axis Bottom 离轴底部
|
||||
Off-Axis Front 离轴正面
|
||||
Off-Axis Left 离轴左侧
|
||||
Off-Axis Right 离轴右侧
|
||||
Off-Axis Top 离轴顶部
|
||||
Ogee 反曲线
|
||||
Reset all 3D settings to their defaults. 将所有 3D 设置重置为默认值。
|
||||
Step 阶梯
|
||||
Taper 锥化
|
||||
Twist 扭转
|
||||
Volume 体积
|
||||
3d Cap 封口
|
||||
3d Solid 实心
|
||||
3d Hollow 空心
|
||||
3d Bevel 斜角
|
||||
3d Rounded 圆角
|
||||
3d Inside 向内
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3578,3 +3578,32 @@ lighting Light 光源
|
||||
Use Outside Rectangle As Frame 使用外部矩形作為框架
|
||||
Create Compound Path From Ellipses 從橢圓形建立複合路徑
|
||||
Fill Grid 格線填色
|
||||
# 3D and Materials › Extrude & Bevel, Inflate and Rotate: options, Position presets and bevel shapes.
|
||||
Back 背面
|
||||
Both sides 雙面
|
||||
Classic 經典
|
||||
Cove 凹圓
|
||||
Depth 深度
|
||||
Front 正面
|
||||
Isometric Bottom 等角底部
|
||||
Isometric Left 等角左側
|
||||
Isometric Right 等角右側
|
||||
Isometric Top 等角頂部
|
||||
Off-Axis Back 離軸背面
|
||||
Off-Axis Bottom 離軸底部
|
||||
Off-Axis Front 離軸正面
|
||||
Off-Axis Left 離軸左側
|
||||
Off-Axis Right 離軸右側
|
||||
Off-Axis Top 離軸頂部
|
||||
Ogee 反曲線
|
||||
Reset all 3D settings to their defaults. 將所有 3D 設定重設為預設值。
|
||||
Step 階梯
|
||||
Taper 錐化
|
||||
Twist 扭轉
|
||||
Volume 體積
|
||||
3d Cap 封口
|
||||
3d Solid 實心
|
||||
3d Hollow 空心
|
||||
3d Bevel 斜角
|
||||
3d Rounded 圓角
|
||||
3d Inside 向內
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3483,7 +3483,13 @@ fn effect_menu() -> Vec<Item> {
|
||||
} else {
|
||||
let mut items = items;
|
||||
if sub_name == "3D and Materials" {
|
||||
items.extend([todo("Extrude & Bevel…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")]);
|
||||
// In the panel's order of 3D types, then Materials (not built yet).
|
||||
let rank = |label: &str| ["Extrude & Bevel…", "Revolve…", "Inflate…", "Rotate…"].iter().position(|l| *l == label).unwrap_or(9);
|
||||
items.sort_by_key(|i| match i {
|
||||
Item::Cmd(label, ..) => rank(label),
|
||||
_ => 9,
|
||||
});
|
||||
items.push(todo("Materials…"));
|
||||
}
|
||||
out.push(sub(sub_name, items));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Effects parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G2: Film Grain added; Photoshop effects 29/57; 28 filters remain) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (3D and Materials: Extrude & Bevel, Inflate and Rotate added, ~8% → ~25%; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
|
||||
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
|
||||
@@ -13,7 +13,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md](
|
||||
|---|---:|---:|---:|---:|
|
||||
| Illustrator effects (vector and stylize) | 50 | 44 | 88% | 6–10 |
|
||||
| Photoshop effects (raster, Effect Gallery) | 57 | 29 | 51% | 23–39 |
|
||||
| 3D and Materials | 4 (+3 Classic) | 1 (initial Revolve) | ~8% | 45–75 |
|
||||
| 3D and Materials | 4 (+3 Classic) | 4 (initial: Extrude & Bevel, Revolve, Inflate, Rotate) | ~25% | 25–45 |
|
||||
| Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — |
|
||||
|
||||
## Illustrator effects
|
||||
@@ -85,20 +85,21 @@ not an accuracy guarantee for arbitrary artwork. Tests: `pixel::tests::film_grai
|
||||
| Item | Ours |
|
||||
|---|---|
|
||||
| Revolve | initial: live, editable profile, rotation and light gizmos, flat shading, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) |
|
||||
| Extrude & Bevel, Inflate, Rotate | none (menu stubs) |
|
||||
| Materials panel, lighting, ray-traced render | none |
|
||||
| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, six original bevel shapes, Inflate volume and both sides, Rotate of every fill and stroke, Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
|
||||
| Materials panel, lighting, ray-traced render | one directional light with flat shading only; no materials, several lights, light colour, shadows or ray tracing |
|
||||
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | none |
|
||||
| Turntable (30.0 beta) | none |
|
||||
| Export 3D objects (OBJ, USDA, glTF) | none |
|
||||
|
||||
The `three-d` crate (depends only on `geom`) builds, rotates, projects, shades and depth-sorts the surfaces;
|
||||
the effects crate turns them into art. What's left (Extrude & Bevel, Inflate, Rotate, caps, materials, ray-traced
|
||||
rendering, Turntable, 3D export) is 45–75 h, still the largest single gap.
|
||||
the effects crate turns them into art. What's left (materials, several lights and shadows, ray-traced rendering,
|
||||
Revolve caps, 3D Classic, Turntable, 3D export) is 25–45 h.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Extrude & Bevel, Inflate and Rotate (initial, [three-d.md](three-d.md)); 3D ~8% → ~25%, 45–75 → 25–45 h |
|
||||
| 2026-10-12 | minor | G2: Artistic › Film Grain added with live controls and raster exports; measured Photoshop filters 29/57, 28 remaining (23–39 h). |
|
||||
| 2026-10-11 | minor | PDF cap warnings identify original objects after appearance splitting and report the lowest achieved ppi; cap/rendering and scores unchanged |
|
||||
| 2026-10-11 | minor | Zig Zag's dialog: Size and Ridges per segment sliders, Points as Smooth / Corner radio buttons (#1133) |
|
||||
|
||||
+10
-7
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertiLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, caLine truncated
|
||||
|
||||
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
|
||||
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
|
||||
@@ -26,7 +26,7 @@ stage ([ROADMAP.md](../ROADMAP.md)).
|
||||
| 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 15–25 | no | [target-app-parity.md](target-app-parity.md) |
|
||||
| 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 8–16 | macOS pressure | [hardware-parity.md](hardware-parity.md) |
|
||||
| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 17–28 | no | [target-app-parity.md](target-app-parity.md) |
|
||||
| 12 | [G3 3D and Materials](#g3-3d-and-materials) | 45–75 | no | [effects-parity.md](effects-parity.md) |
|
||||
| 12 | [G3 3D and Materials](#g3-3d-and-materials) | 25–45 | no | [effects-parity.md](effects-parity.md) |
|
||||
| 13 | [G7 Views and windows](#g7-views-and-windows) | 15–25 | no | [ui-parity.md](ui-parity.md) |
|
||||
| 14 | [G6 Automation and scripting](#g6-automation-and-scripting) | 8–14 | no | [target-app-parity.md](target-app-parity.md) |
|
||||
| 15 | [G15 Performance budgets and a GPU renderer](#g15-performance-budgets-and-a-gpu-renderer) | 15–25 | budgets only | [hardware-parity.md](hardware-parity.md) |
|
||||
@@ -171,11 +171,13 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
|
||||
## G3 3D and Materials
|
||||
|
||||
- **Done:** an initial live Revolve in the `three-d` crate (#846, from the contributor who offered in #605).
|
||||
- **Missing:** Extrude & Bevel, Inflate and Rotate, caps, materials and better lighting, exact visibility for
|
||||
intersecting surfaces, 3D Classic, Turntable, 3D export (OBJ, USDA, glTF).
|
||||
- **Impact:** specialist, but a visible menu of stubs.
|
||||
- **Estimate:** 45–75 h, the largest single gap; plan in `plan/` before coding.
|
||||
- **Done:** an initial live Revolve in the `three-d` crate (#846, from the contributor who offered in #605);
|
||||
Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407).
|
||||
- **Missing:** materials (base, Substance, graphics), several lights, light colour and softness, shadows,
|
||||
ray-traced rendering, Revolve caps, exact visibility for intersecting and concave surfaces, 3D Classic,
|
||||
Turntable, 3D export (OBJ, USDA, glTF).
|
||||
- **Impact:** specialist; Materials is the one menu stub left.
|
||||
- **Estimate:** 25–45 h; plan in `plan/` before coding.
|
||||
|
||||
## G7 Views and windows
|
||||
|
||||
@@ -234,6 +236,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | G3: Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407); materials, lights, shadows, Classic, Turntable and export remain, 45–75 → 25–45 h |
|
||||
| 2026-10-12 | minor | G9: a `.ai` compound path takes its last path's paint and keeps paths grouped inside it (#1410); PDF and `.ai` import keep overprint (#1388); a `.ai` whose PDF part draws only text opens from it (#1387); G1: the Transform dialogs preview and step with the arrow keys (#1412), the transform tools and commands act on direct-selected anchors (#1408), the bounding box hides during a corner drag (#1229); G11: a stroke wider than its closed shape paints solid on the canvas (#1396); estimates unchanged |
|
||||
| 2026-10-12 | minor | G9: `.ai` art styles, clipping fills and compound looks read from the editing data; estimate unchanged |
|
||||
| 2026-10-12 | minor | G9: `.ai` gradient matrices and meshes read from the editing data; estimate unchanged |
|
||||
|
||||
+15
@@ -924,6 +924,21 @@ See [Revolve](revolve.md) for the scope and limitations.
|
||||
{"name":"run_command","arguments":{"command":"effect.expandAppearance","params":{}}}
|
||||
```
|
||||
|
||||
## Extrude & Bevel, Inflate and Rotate
|
||||
|
||||
`effect.apply {effect: "threeD.extrude" | "threeD.inflate" | "threeD.rotate", params: {...}, ids?, item?}` applies
|
||||
the other 3D types to a path or compound path, live like Revolve (the source stays editable; SVG/PDF exports and
|
||||
`effect.expandAppearance` use the same shaded faces). An object takes one 3D effect, Revolve included.
|
||||
|
||||
`threeD.extrude`: `depth` (points, 0..2000, default 50), `twist` (degrees, -360..360, 0), `taper` (1..100%, 100),
|
||||
`cap` (true solid, false hollow), `bevel` (`none`, `classic`, `rounded`, `cove`, `step`, `ogee`), `bevelWidth`
|
||||
and `bevelHeight` (points, default 4), `bevelRepeat` (1..32), `bevelSpace` (0..90%), `bevelInside`,
|
||||
`bevelBothSides`. `threeD.inflate`: `depth` (points, default 0), `volume` (0..100%, default 50), `bothSides`.
|
||||
All three take `rotationX/Y/Z` (degrees, -180..180, default the Off-Axis Front position -21.88/-23.4/9.06),
|
||||
`perspective` and the lighting parameters as Revolve. See [three-d.md](three-d.md) for the Position presets,
|
||||
the scope and what differs from the public documentation.
|
||||
|
||||
|
||||
## Edit Colors and Recolor Artwork
|
||||
|
||||
`edit.colors.invert`, `edit.colors.toCMYK`, `edit.colors.toGrayscale`, `edit.colors.toRGB`, `edit.colors.saturate`,
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
|
||||
@@ -57,7 +57,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; auto/slider/advanced UI remains open); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Film Grain done (29/57 Photoshop filters); initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Film Grain done (29/57 Photoshop filters); initial Revolve, Extrude & Bevel, Inflate and Rotate; SVG Filters, remaining raster effects and 3D materials, lights and shadows pending | 81–130 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
|
||||
@@ -80,6 +80,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | M9: Extrude & Bevel, Inflate and Rotate (initial 3D types, [three-d.md](three-d.md)) |
|
||||
| 2026-10-12 | minor | M4: a `.ai` compound path takes its last path's paint (#1410), PDF and `.ai` import keep overprint (#1388), a `.ai` whose PDF part draws only text opens from it (#1387); M2: the Transform dialogs preview and step with the arrow keys (#1412), the transform tools and commands act on direct-selected anchors (#1408), the bounding box hides during a corner drag (#1229); M5: a stroke wider than its closed shape paints solid on the canvas (#1396); estimates unchanged |
|
||||
| 2026-10-12 | minor | M4: `.ai` editing data follow-ups to #1264 (gradient stops #1390, type placement #1391, short area-type frames #1392) and PDF bleed with editing data (#1379); M14: dialogs take the keyboard focus on every open (#1367, #1395); M5: symbol instance art kept between frames (#1386); M7: characters no font had are looked for again after a font rescan (#1376); estimates unchanged |
|
||||
| 2026-10-12 | minor | M4: gradient-filled shapes from a PDF or `.ai` open as paths (#1304); constant alpha soft masks export white (#1286, in part); M2: a Direct Selection click on a Live Corners widget selects its corner (#1239); estimates unchanged |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
|
||||
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
|
||||
@@ -11,7 +11,7 @@ the work list made from it. Deep areas have their own checklists: [UI and intera
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (does each Illustrator menu item, tool, effect and format exist?) | **~88%** | measured in part: menu tree 348 of 375 items wired (92.8%), tools 89 of 92 (97%), vector effects 44 of 50 (88%), Photoshop-style effects 28 of 57 (49%), 3D 1 of 4 (initial Revolve); blended by the area weights below |
|
||||
| **Feature breadth** (does each Illustrator menu item, tool, effect and format exist?) | **~88%** | measured in part: menu tree 353 of 375 items wired (94.1%), tools 89 of 92 (97%), vector effects 44 of 50 (88%), Photoshop-style effects 28 of 57 (49%), 3D 4 of 4 (initial: Extrude & Bevel, Revolve, Inflate, Rotate); blended by the area weights below |
|
||||
| **Feature depth** (each area scored by behaviour, weighted) | **~75%** | estimated, area table below |
|
||||
| **Ready for real work, mainstream illustrator** | **~65%** (60–70%) | estimated, see [Ready for real work](#ready-for-real-work) |
|
||||
| **Ready for real work, full Illustrator** (decides the stage) | **~62%** (57–67%) | estimated: additive weighted sum over the dimensions, see [Ready for real work](#ready-for-real-work) |
|
||||
@@ -39,7 +39,7 @@ tests and the CI gate. See [Calibration](#calibration).
|
||||
- **From the code (measured, origin/main `01e165af`):**
|
||||
- Menu tree (`crates/ui-egui/src/menus.rs`, laid out in Illustrator's menu order): 347 items bound to
|
||||
commands, 27 stubs (`todo(…)`/`todos(…)`), so 92.8% of menu items do something (348 and 27 after the
|
||||
Revolve merge; 350 and 25, 93.3%, after Graph › Design and Marker). The stubs: Effect › 3D and Materials (4), Effect › SVG Filters, Edit › Spelling (3), Graph › Column, Live Paint
|
||||
Revolve merge; 350 and 25, 93.3%, after Graph › Design and Marker; 353 and 22, 94.1%, after Extrude & Bevel, Inflate and Rotate). The stubs: Effect › 3D and Materials › Materials, Effect › SVG Filters, Edit › Spelling (3), Graph › Column, Live Paint
|
||||
Gap Options, Optical Margin Alignment, Start Global Edit, global and video rulers (2), Snap to Glyph,
|
||||
Window › Arrange (4), SVG Interactivity, the library placeholders (3), Other Script…, Help.
|
||||
- Engine commands: ~710 ids registered with `cmd!` across `crates/engine/src/cmd` (plus macro-generated
|
||||
@@ -75,7 +75,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated |
|
||||
| Raster effects (Effect Gallery) | 4 | 49% | 28 Photoshop-style filters (14 Artistic, 14 Sketch), the Effect Gallery dialog, Load Texture | 23–39 | measured count 29/57; texture redraw distances optimized (#1257), pixels verified; depth estimated |
|
||||
| 3D and Materials (and Turntable) | 4 | ~8% | initial live Revolve done (flat shading, one light, no caps, approximate visibility for intersecting surfaces); Extrude & Bevel, Inflate, Rotate, materials, Turntable views, 3D export | 50–85 | estimated (Revolve landed after the first measure, #846) |
|
||||
| 3D and Materials (and Turntable) | 4 | ~25% | initial live Revolve, Extrude & Bevel (twist, taper, caps, six bevel shapes), Inflate and Rotate with Position presets (flat shading, one light, painter visibility); materials, several lights, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) |
|
||||
| Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258) |
|
||||
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 85% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape | 6–10 | estimated (lowered from 88%: target moved) |
|
||||
@@ -314,7 +314,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Glass's and Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 |
|
||||
| 3D and Materials | 4 | ~8% | Initial Revolve only, with simple lighting and Revolve-specific visible-surface expansion; Extrude & Bevel, Inflate, Rotate, caps, materials, shadows and more complete visibility remain | 45–75 |
|
||||
| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, several lights, shadows, ray tracing, 3D Classic and more complete visibility remain | 25–45 |
|
||||
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034) | 14–19 |
|
||||
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
|
||||
@@ -356,7 +356,7 @@ since 2026-10-01.
|
||||
|---|---|---|
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (~8%, initial Revolve only), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (~25%, initial 3D types without materials), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec |
|
||||
@@ -457,6 +457,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans; single-result shape operations retain source names and SVG IDs, #1293), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
|
||||
- Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map retains its 4,096-segment cap and filled-area fallback; other Pathfinder operations are unchanged.
|
||||
- Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, six original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume and both sides, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)).
|
||||
- Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends are uncapped, shading is flat with one light, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)).
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Simplify feedback and component selection (#1272):** the existing tolerance dialog previews from the original interaction snapshot and shows original/new anchor counts. Preview off and Cancel restore the document and selection; OK is one Undo. A refit or Add Anchor Points clears component indexes only on paths whose geometry changes, keeping their objects selected whole and unchanged paths' components intact. Engine and real-egui frame regressions cover document/selection restoration and grouped paths.
|
||||
@@ -522,6 +523,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | 3D and Materials: Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md)); 3D ~8% → ~25%, 50–85 → 30–55 h (estimated); menu 353/375 wired (measured from the stubs removed) |
|
||||
| 2026-10-12 | minor | File formats: an Affinity text frame too short for its first line is made taller with a warning instead of showing nothing (#1423); synthetic-file test; score and hours unchanged (estimated) |
|
||||
| 2026-10-12 | minor | File formats: a `.ai` compound path takes its last path's paint and keeps paths grouped inside it (#1426, fixes #1410; hand-written editing data test, no public documentation of which member carries the paint); a `.ai` whose PDF part draws only text opens from it when its editing data can't be read (#1414, fixes #1387); score and hours unchanged (estimated) |
|
||||
| 2026-10-12 | minor | Transform: with anchors selected with Direct Selection, the transform commands, dialogs and tools act on those anchors alone (#1427, fixes #1408); the five Transform dialogs preview on the canvas and their angle and percentage fields step with the arrow keys (#1428, fixes #1412); the Preview default (off) and the anchors' default reference point are our choices; scores and hours unchanged (estimated) |
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Extrude & Bevel, Inflate and Rotate
|
||||
|
||||
The other three 3D types of **Effect › 3D and Materials** next to [Revolve](revolve.md). Like Revolve each is a
|
||||
live effect: the source path stays editable, the effect draws shaded vector faces, and SVG/PDF export, native
|
||||
saves and **Object › Expand Appearance** use the same evaluator.
|
||||
|
||||
Select a path or compound path, then choose **Effect › 3D and Materials › Extrude & Bevel…**, **Inflate…** or
|
||||
**Rotate…**. The options dialog opens beside the artboard with tabs:
|
||||
|
||||
- **Object** (Extrude & Bevel and Inflate): the shape's own options, below.
|
||||
- **Rotation**: a **Position** preset, **Rotation X/Y/Z** (−180° to 180°) and **Perspective**. The rotation rings
|
||||
on the artboard turn the object about the centre of its bounds; Shift snaps to 15°.
|
||||
- **Lighting**: **Shading**, the light's **Direction** and **Elevation** (or drag the light on the artboard),
|
||||
**Intensity** and **Ambient**, as for Revolve.
|
||||
|
||||
Preview changes are temporary until OK; Cancel restores the document; OK is one undo step. An object takes one
|
||||
3D effect: edit its options in the Appearance panel, or remove it before applying another.
|
||||
|
||||
## Extrude & Bevel
|
||||
|
||||
The art is the front face; its outline is extruded backwards by **Depth** (0–2,000 pt), so seen straight on the
|
||||
object is where the art was.
|
||||
|
||||
| Option | What it does |
|
||||
|---|---|
|
||||
| Depth | 0–2,000 pt. At 0 the object is flat (the art turned like Rotate). |
|
||||
| Twist | −360° to 360°: the back face turned against the front, the walls following in slices. |
|
||||
| Taper | 1–100 %: the back face's size against the front's. |
|
||||
| Cap | **Solid** closes the front and back; **Hollow** leaves them open, showing the insides of the walls. |
|
||||
| Bevel | **None**, **Classic** (a straight chamfer), **Rounded** (convex quarter round), **Cove** (concave), **Step** (two steps) or **Ogee** (an S curve). Our own cross-sections. |
|
||||
| Width, Height | How far the bevel reaches across the face and along the depth (0–1,000 pt each). The height is limited to the depth (half of it on each side with Both sides). |
|
||||
| Repeat, Spacing | The cross-section repeated 1–32 times across the width, with a share (0–90 %) of the width flat between repeats. |
|
||||
| Inside | The bevel carved into the shape (the front face shrinks) instead of added around it (the walls stand outside the art). |
|
||||
| Both sides | The back is bevelled too. |
|
||||
|
||||
Holes stay holes, with walls of their own. Bevels move each corner along its bisector (mitres are limited to four
|
||||
times the width), so a bevel wider than a narrow part of the shape folds over itself.
|
||||
|
||||
## Inflate
|
||||
|
||||
The shape puffed out like a cushion: its outline stays where the art is and the surface rises towards the
|
||||
middle.
|
||||
|
||||
| Option | What it does |
|
||||
|---|---|
|
||||
| Depth | 0–2,000 pt of flat side between the front and the back. |
|
||||
| Volume | 0–100 %. The surface is a membrane under even pressure, held along the outline (and around holes): it rises vertically from the edge and rounds smoothly inside, without creases along the shape's middle. At 50 % a circle becomes a half sphere; 100 % puffs twice as high, 0 % is flat. |
|
||||
| Both sides | The back puffs out too; otherwise it is flat. |
|
||||
|
||||
The surface is cut into a grid of 48 facets across the larger side, halved or quartered where it rises steeply near the outline (the cells meet without cracks), each clipped to the shape and flat shaded.
|
||||
|
||||
## Rotate
|
||||
|
||||
The flat art turned in 3D about the centre of its bounds. Every fill and stroke is turned with its own paint (a
|
||||
stroke as its outline, so it foreshortens with the art) and shaded as one plane; a plane has two sides, so its
|
||||
back shows when it is turned past 90°.
|
||||
|
||||
## Position presets
|
||||
|
||||
**Front**, **Back**, **Left**, **Right**, **Top** and **Bottom** face the art that way. **Off-Axis** tilts each 20°
|
||||
down and 25° aside, so the top and the right show. **Isometric Left/Right/Top/Bottom** lay the art on that plane
|
||||
of an isometric view (each visible axis foreshortened equally). These are our own values, from the geometry of
|
||||
each view; the dialog shows a preset's name when the angles match it and **Custom** otherwise. The new effects
|
||||
open at Off-Axis Front.
|
||||
|
||||
## Parameters (`effect.apply`, `effect.setParams`)
|
||||
|
||||
`threeD.extrude`: `depth`, `twist`, `taper` (%), `cap`, `bevel` (`none`, `classic`, `rounded`, `cove`, `step`,
|
||||
`ogee`), `bevelWidth`, `bevelHeight`, `bevelRepeat`, `bevelSpace` (%), `bevelInside`, `bevelBothSides`.
|
||||
`threeD.inflate`: `depth`, `volume` (%), `bothSides`. All three take the view and lighting parameters Revolve
|
||||
uses: `rotationX`, `rotationY`, `rotationZ`, `perspective` (%), `shade`, `lightAzimuth`, `lightElevation`,
|
||||
`lightIntensity` (%), `ambient` (%). Lengths are points and scale with the object (Scale Strokes & Effects).
|
||||
Commands validate the shape and the parameters before the document changes.
|
||||
|
||||
## What differs from the public documentation
|
||||
|
||||
Behaviour reference: the public [3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html)
|
||||
and [Create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)
|
||||
pages, and the archived Extrude & Bevel option list (depth 0–2,000, cap on/off, bevel height, bevel extent in or
|
||||
out, perspective). No Adobe software, assets, presets or bevel shapes were used.
|
||||
|
||||
- **Materials** (base, Adobe Substance and graphics), **several lights**, light colour and softness, **shadows**
|
||||
and **ray-traced rendering** are not built: shading is one directional light, flat per face, painted with the
|
||||
object's top fill (else its top stroke) for Extrude & Bevel and Inflate.
|
||||
- **Perspective** is a 0–100 % strength, as Revolve's, not a 0–160° lens angle.
|
||||
- The bevel shapes and their names are our own; custom bevel paths are not supported.
|
||||
- Visibility is a painter's algorithm: faces of a closed solid turned away are left out, the rest drawn back to
|
||||
front. Concave solids seen at steep angles can overlap wrongly; open (hollow) solids show both sides.
|
||||
- Shading is flat per facet: an inflated rim seen almost edge-on shows faint banding where the facets get finer.
|
||||
- Paths and compound paths only: convert type, images and groups to outlines first. One 3D effect per object.
|
||||
- **3D (Classic)** (the track cube, surface types, blend steps, map art), Turntable and 3D export are separate,
|
||||
unbuilt items.
|
||||
@@ -80,6 +80,8 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[
|
||||
&["pdf", "eps"],
|
||||
&["plugins", "effects"],
|
||||
&["three-d", "effects"],
|
||||
// Inflate cuts its surface's grid cells to the shape with Pathfinder's booleans.
|
||||
&["pathops", "three-d"],
|
||||
];
|
||||
|
||||
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||
|
||||
Reference in new issue
Block a user