From d56e241a43e76d501575ebce626b691a426fd3a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:30:45 -0800 Subject: [PATCH 1/9] 3D and Materials: Expand as Wireframes turns a 3D object into its edges as paths Window > 3D and Materials > Quick Actions > Expand as Wireframes, and the command effect.expandAsWireframes, replace each selected object that has a visible 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Revolve, Inflate or Rotate) with a group of paths: the edges of the faces the effect draws for the object as it is turned and projected now. - Each edge appears once. Edges that meet end to end form one path, closed where it comes back to its start, and curved edges stay curves. Geometry effects after the 3D effect reshape the edges as they reshape the drawn surface. - The paths have a 0.5 pt black stroke with round joins and no fill. The group keeps the object's id, name, transparency, attributes, slice and text wrap, and is selected. A layer stays a layer holding the edges. - It is one undo step. Nothing selected, no visible 3D effect, a solid that can't be built or is drawn as its source art, or more than 1,000,000 edges in one object is an error, and the document does not change. Docs: docs/mcp.md, docs/three-d.md, docs/effects-parity.md, docs/target-app-parity.md. Fixes #6354 --- crates/effects/src/lib.rs | 4 +- crates/effects/src/revolve.rs | 49 ++++ crates/effects/src/solids.rs | 3 + crates/effects/src/solids/wireframe.rs | 262 +++++++++++++++++++ crates/effects/src/solids/wireframe/tests.rs | 156 +++++++++++ crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/wireframe.rs | 118 +++++++++ crates/engine/src/cmd/wireframe/tests.rs | 143 ++++++++++ crates/ui-egui/src/panels/tests_three_d.rs | 26 ++ crates/ui-egui/src/panels/three_d.rs | 29 +- docs/effects-parity.md | 2 +- docs/mcp.md | 22 ++ docs/target-app-parity.md | 1 + docs/three-d.md | 13 + 14 files changed, 826 insertions(+), 4 deletions(-) create mode 100644 crates/effects/src/solids/wireframe.rs create mode 100644 crates/effects/src/solids/wireframe/tests.rs create mode 100644 crates/engine/src/cmd/wireframe.rs create mode 100644 crates/engine/src/cmd/wireframe/tests.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 67b4b464fd..1a838fb28f 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -87,8 +87,8 @@ pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_art_in, revolve_opt pub use solids::{ CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, solid_art_in, three_d_meshes, three_d_meshes_in, - three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, validate_three_d, validate_three_d_at, - validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, + three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, validate_solid_in, validate_three_d, + validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 4795e8848a..31377318cc 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -455,6 +455,55 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul Ok(out) } +/// The outline of every face [`revolve_art`] draws for `n` (its symbol instances, blends and clipping +/// groups already resolved), in document coordinates: with the effect on the object, the surface in +/// its top visible fill (else its stroke); with it on fills and strokes, one surface for each of +/// them, reshaped by the geometry effects after each one's Revolve; on a group or layer, one for +/// each member, about one shared axis. +pub(crate) fn revolve_face_paths(n: &Node) -> Result, String> { + let faces = |path: &PathData, b: Rect, options: Revolve| -> Result, String> { + Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(vectorcraft_three_d::Face::path).collect()) + }; + if is_container(n) { + let index = + n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE).ok_or("a 3D effect on a group's fill or stroke draws nothing")?; + let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; + let mut shapes = members(n, "Revolve")?; + reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); + let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?; + let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let mut out = vec![]; + for s in shapes.iter().filter(|s| surface_item(&s.items).is_some()) { + out.extend(faces(&s.path, b, options)?); + } + return Ok(out); + } + let (path, _) = object_profile(n, "Revolve")?; + let ctx = GeomContext::of(n); + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { + let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; + let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); + if surface_item(&solid_paints(n)).is_none() { + return Err("the revolved object has no fill or stroke".into()); + } + let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; + return faces(&path, b, revolve_options(&merged_params(REVOLVE, &fx.params))); + } + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut out = vec![]; + for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { + if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) + && let Some(fx) = item.effects().get(index) + { + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let b = p.bounds().ok_or("Revolve requires a shape with at least two anchors")?; + let surface = faces(&p, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &ctx)); + } + } + Ok(out) +} + /// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis. fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { let index = n diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index ff5b1c9711..f9d3a68ac9 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -22,6 +22,9 @@ pub(crate) use source::{resolve, resolve_for}; pub(crate) mod shadow; +pub(crate) mod wireframe; +pub use wireframe::three_d_wireframe_in; + pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; pub const ROTATE: &str = "threeD.rotate"; diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs new file mode 100644 index 0000000000..eb74546225 --- /dev/null +++ b/crates/effects/src/solids/wireframe.rs @@ -0,0 +1,262 @@ +//! Expand as Wireframes: the edges of the faces a 3D effect draws, as open and closed paths that +//! can be edited on their own. +use std::collections::{HashMap, HashSet, VecDeque}; + +use vectorcraft_doc::{AppearanceItem, Document, Effect, Node}; +use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; +use vectorcraft_three_d::Facet; + +use super::{ + INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_three_d, label_of, members, object_profile, region, + reshape_members, resolve, solid, solid_paints, solid_parts, union_bounds, view_options, +}; +use crate::{GeomContext, apply_geometry_with, is_geometry}; + +/// The most distinct edges one object's wireframe may have. +pub(crate) const MAX_WIRE_EDGES: usize = 1_000_000; + +/// The grid points are rounded to before edges are compared, in points: two faces that share an +/// edge give it once. +const GRID: f64 = 1.0 / 1024.0; + +const NO_EFFECT: &str = "the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)"; + +/// The wireframe of `n`'s 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Revolve, Inflate +/// or Rotate), in document coordinates: the edges of every face the effect draws for the object as +/// it is turned and projected, each edge once. Edges that meet end to end are joined into one path; +/// a run that comes back to where it started is closed. A curved edge stays a curve. The geometry +/// effects after the object's 3D effect reshape the wireframe as they reshape the drawn art. On a +/// group or layer, the edges of the solids of all its members. An error when `n` has no visible 3D +/// effect, when its solids can't be built, when the canvas draws the source art in their place, when +/// they draw no face, or past [`MAX_WIRE_EDGES`] edges. +pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result, String> { + if !crate::has_revolve(n) && !has_solid(n) { + return Err(NO_EFFECT.into()); + } + let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); + // The faces come from the effect whose art the canvas draws (Revolve first, as the canvas tries + // it first). When neither draws, past their limits, the canvas shows the source art and there + // is no surface to expand. + let revolve = crate::has_revolve(n) && crate::revolve_art_in(doc, n).is_some(); + if !revolve && !(has_solid(n) && super::solid_art_in(doc, n).is_some()) { + let n = &*resolve(doc, n, label)?; + // The builders' own error says why, when there is one. + if crate::has_revolve(n) { + crate::revolve::revolve_face_paths(n)?; + } else { + solid_face_paths(n)?; + } + return Err(format!("{label} draws the source art for this object instead of its 3D surface")); + } + let n = &*resolve(doc, n, label)?; + let faces = if revolve { crate::revolve::revolve_face_paths(n)? } else { solid_face_paths(n)? }; + let faces = after_geometry(n, faces); + let wire = edges(&faces)?; + if wire.is_empty() { + return Err(format!("{label} draws no faces for this object")); + } + Ok(wire) +} + +/// `faces` reshaped by the visible geometry effects after `n`'s own 3D effect, which act on the +/// projected art as a whole, about the bounds of all the faces. +fn after_geometry(n: &Node, faces: Vec) -> Vec { + let fx = &n.appearance.effects; + let Some(index) = fx.iter().position(|e| e.visible && is_three_d(&e.id)) else { return faces }; + reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext::of(n)) +} + +/// `faces` reshaped by the visible geometry effects in `later` (the effects after a 3D effect), +/// which act on the faces as a whole, about their bounds, as they act on the drawn surface. +pub(crate) fn reshaped(faces: Vec, later: &[Effect], ctx: &GeomContext) -> Vec { + let after: Vec = later.iter().filter(|e| e.visible && is_geometry(&e.id)).cloned().collect(); + if after.is_empty() { + return faces; + } + let joined = PathData::new(faces.into_iter().flat_map(|f| f.subpaths).collect()); + let Some(bounds) = joined.bounds() else { return vec![joined] }; + vec![apply_geometry_with(&after, &joined, bounds, ctx)] +} + +/// The outline of every face [`super::solid_art`] draws for `n` (already resolved): with the effect +/// on the object, its solids; with it on fills and strokes, the solid of each; on a group or layer, +/// the solids of its members, about one shared axis. +fn solid_face_paths(n: &Node) -> Result, String> { + if is_container(n) { + let (index, fx) = solid(&n.appearance.effects).ok_or("a 3D effect on a group's fill or stroke draws nothing")?; + let mut shapes = members(n, label_of(&fx.id))?; + reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); + return facet_paths(fx, &shapes); + } + let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); + let (path, rule) = object_profile(n, label)?; + let ctx = GeomContext::of(n); + let paints = solid_paints(n); + if let Some((index, fx)) = solid(&n.appearance.effects) { + let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); + return facet_paths(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]); + } + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut out = vec![]; + for item in &paints { + if let Some((index, fx)) = solid(item.effects()) { + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let faces = facet_paths(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; + out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &ctx)); + } + } + Ok(out) +} + +/// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: +/// Rotate turns every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's +/// top fill and from the outline of each stroke. A stroke whose solid is past the limits is left out, +/// as it is left out of the drawn art. +fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> { + let label = label_of(&fx.id); + let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; + let p = crate::merged_params(&fx.id, &fx.params); + let mut out = vec![]; + if fx.id == ROTATE { + for s in shapes { + for item in &s.items { + let (area, rule) = region(&s.path, s.rule, item); + out.extend(facets(ROTATE, &area, rule, b, &p)?.iter().map(Facet::path)); + } + } + return Ok(out); + } + let (parts, bounds) = solid_parts(shapes, b); + let built: Vec, String>> = if fx.id == INFLATE { + let shapes: Vec<(&PathData, vectorcraft_geom::FillRule)> = parts.iter().map(|(_, _, area, rule, ..)| (area, *rule)).collect(); + vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p)) + } else { + parts.iter().map(|(_, _, area, rule, ..)| facets(&fx.id, area, *rule, bounds, &p)).collect() + }; + for ((item, ..), f) in parts.iter().zip(built) { + match f { + Ok(f) => out.extend(f.iter().map(Facet::path)), + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} + Err(e) => return Err(e), + } + } + Ok(out) +} + +/// A point on the [`GRID`]. +type Snap = (i64, i64); + +fn snap(p: Point) -> Snap { + ((p.x / GRID).round() as i64, (p.y / GRID).round() as i64) +} + +fn reversed(c: CubicBez) -> CubicBez { + CubicBez::new(c.p3, c.p2, c.p1, c.p0) +} + +/// The edges of `faces`, each once, joined end to end into paths ([`three_d_wireframe_in`]). +pub(crate) fn edges(faces: &[PathData]) -> Result, String> { + let mut seen: HashSet<[Snap; 4]> = HashSet::new(); + let mut segments: Vec = vec![]; + for sp in faces.iter().flat_map(|f| &f.subpaths) { + for i in 0..sp.segment_count() { + let mut c = sp.segment(i); + if sp.segment_is_line(i) { + c = CubicBez::new(c.p0, c.p0, c.p3, c.p3); + } + if [c.p0, c.p1, c.p2, c.p3].iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + continue; + } + let key = [c.p0, c.p1, c.p2, c.p3].map(snap); + // A segment that starts and ends on one grid point with its handles there too draws nothing. + if key.iter().all(|k| *k == key[0]) { + continue; + } + let back = [key[3], key[2], key[1], key[0]]; + if seen.contains(&back) || !seen.insert(key) { + continue; + } + if segments.len() >= MAX_WIRE_EDGES { + return Err(format!("the wireframe has more than {MAX_WIRE_EDGES} edges")); + } + segments.push(c); + } + } + Ok(chain(&segments)) +} + +/// `segments` joined end to end: each path starts at the first segment not yet used and takes in +/// the unused segments that meet its ends until none is left at either end, or it closes. +fn chain(segments: &[CubicBez]) -> Vec { + let mut at: HashMap> = HashMap::new(); + for (i, c) in segments.iter().enumerate() { + at.entry(snap(c.p0)).or_default().push(i); + if snap(c.p3) != snap(c.p0) { + at.entry(snap(c.p3)).or_default().push(i); + } + } + let mut used = vec![false; segments.len()]; + // The next unused segment at `point`, turned to leave it (`leave`) or to arrive at it. + let mut take = |used: &mut Vec, point: Snap, leave: bool| -> Option { + let list = at.get_mut(&point)?; + while let Some(&i) = list.last() { + if used.get(i).copied().unwrap_or(true) { + list.pop(); + continue; + } + if let Some(u) = used.get_mut(i) { + *u = true; + } + let c = *segments.get(i)?; + let starts = snap(c.p0) == point; + return Some(if starts == leave { c } else { reversed(c) }); + } + None + }; + let mut out = vec![]; + for (i, first) in segments.iter().enumerate() { + if used.get(i).copied().unwrap_or(true) { + continue; + } + if let Some(u) = used.get_mut(i) { + *u = true; + } + let mut run: VecDeque = VecDeque::from([*first]); + // A path of one anchor has no segment (`SubPath::segment_count`), so a run of one segment + // that comes back to its start stays open, with its two ends on one point. + let closes = |run: &VecDeque| run.len() > 1 && run.front().zip(run.back()).is_some_and(|(f, b)| snap(f.p0) == snap(b.p3)); + while !closes(&run) { + let Some(end) = run.back().map(|c| snap(c.p3)) else { break }; + let Some(c) = take(&mut used, end, true) else { break }; + run.push_back(c); + } + while !closes(&run) { + let Some(start) = run.front().map(|c| snap(c.p0)) else { break }; + let Some(c) = take(&mut used, start, false) else { break }; + run.push_front(c); + } + out.push(PathData::single(subpath(&run, closes(&run)))); + } + out +} + +/// `run`, segments that meet end to end, as one subpath; a closed one ends at its first anchor. +fn subpath(run: &VecDeque, closed: bool) -> SubPath { + let mut anchors: Vec = vec![]; + if let Some(first) = run.front() { + let h_in = if closed { run.back().map_or(first.p0, |b| b.p2) } else { first.p0 }; + anchors.push(Anchor::with_handles(first.p0, h_in, first.p1)); + } + for (k, c) in run.iter().enumerate() { + let h_out = run.get(k + 1).map_or(c.p3, |next| next.p1); + anchors.push(Anchor::with_handles(c.p3, c.p2, h_out)); + } + // A closed run's last anchor is its first, whose in handle already comes from the last segment. + if closed { + anchors.pop(); + } + SubPath::new(anchors, closed) +} + +#[cfg(test)] +mod tests; diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs new file mode 100644 index 0000000000..980e67bbeb --- /dev/null +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -0,0 +1,156 @@ +use serde_json::{Value, json}; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Node, NodeId}; +use vectorcraft_geom::{BezPath, Rect}; + +use super::*; + +fn square(x: f64, y: f64, size: f64) -> PathData { + let mut b = BezPath::new(); + b.move_to((x, y)); + b.line_to((x + size, y)); + b.line_to((x + size, y + size)); + b.line_to((x, y + size)); + b.close_path(); + PathData::from_bezpath(&b) +} + +/// A filled 100 pt square without a stroke, with effect `id` and `params`. +fn solid_square(id: &str, params: Value) -> Node { + let mut n = Node::path(NodeId(1), square(100.0, 100.0, 100.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + n.appearance.effects.push(crate::new_effect(id, ¶ms).unwrap()); + n +} + +/// Every segment of `wire`, each as its two ends on the grid. +fn segments(wire: &[PathData]) -> Vec<(Snap, Snap)> { + wire.iter() + .flat_map(|p| &p.subpaths) + .flat_map(|sp| (0..sp.segment_count()).map(move |i| sp.segment(i))) + .map(|c| (snap(c.p0), snap(c.p3))) + .collect() +} + +fn bounds(paths: &[PathData]) -> Rect { + paths.iter().filter_map(PathData::bounds).reduce(|a, b| a.union(b)).unwrap() +} + +/// The paths of the faces `art` draws (every leaf of the art). +fn face_paths(art: &Node) -> Vec { + let mut out = vec![]; + art.walk(&mut |c| out.extend(c.path_data().cloned())); + out +} + +#[test] +fn two_faces_that_share_an_edge_give_it_once() { + let wire = edges(&[square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)]).unwrap(); + let segs = segments(&wire); + assert_eq!(segs.len(), 7, "{segs:?}"); + for (i, a) in segs.iter().enumerate() { + for b in segs.iter().skip(i + 1) { + assert!(a != b && (a.1, a.0) != *b, "{a:?} twice"); + } + } +} + +#[test] +fn a_loop_of_edges_is_one_closed_path_and_a_curve_stays_a_curve() { + let wire = edges(&[square(0.0, 0.0, 10.0)]).unwrap(); + assert_eq!(wire.len(), 1); + assert!(wire[0].subpaths[0].closed); + assert_eq!(wire[0].subpaths[0].anchors.len(), 4); + + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.curve_to((0.0, 10.0), (10.0, 10.0), (10.0, 0.0)); + b.close_path(); + let wire = edges(&[PathData::from_bezpath(&b)]).unwrap(); + let curved = wire.iter().flat_map(|p| &p.subpaths).any(|sp| (0..sp.segment_count()).any(|i| !sp.segment_is_line(i))); + assert!(curved, "{wire:?}"); +} + +/// A single curve that comes back to its start keeps its segment: it stays open, with two anchors on +/// one point, since a path of one anchor has no segment. +#[test] +fn a_single_curve_that_comes_back_to_its_start_keeps_its_segment() { + let p = Point::new(0.0, 0.0); + let anchors = vec![Anchor::with_handles(p, p, Point::new(0.0, 10.0)), Anchor::with_handles(p, Point::new(10.0, 10.0), p)]; + let face = PathData::single(SubPath::new(anchors, false)); + assert_eq!(face.subpaths[0].segment_count(), 1); + let wire = edges(&[face]).unwrap(); + assert_eq!(wire.len(), 1); + let sp = &wire[0].subpaths[0]; + assert_eq!(sp.segment_count(), 1, "{sp:?}"); + assert!(!sp.segment_is_line(0)); +} + +#[test] +fn edges_without_length_or_with_non_finite_points_are_left_out() { + let point = PathData::single(SubPath::polyline(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0)], false)); + let bad = PathData::single(SubPath::polyline(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 1.0)], false)); + assert!(edges(&[point, bad]).unwrap().is_empty()); + assert!(edges(&[]).unwrap().is_empty()); +} + +#[test] +fn a_box_turned_off_axis_shows_the_nine_edges_of_its_three_faces() { + let n = solid_square(super::super::EXTRUDE, json!({})); + let wire = three_d_wireframe_in(None, &n).unwrap(); + assert_eq!(segments(&wire).len(), 9, "{wire:?}"); + // The wireframe covers what the effect draws, in the same place. + let art = super::super::solid_art(&n).unwrap(); + let (w, a) = (bounds(&wire), bounds(&face_paths(&art))); + for (x, y) in [(w.x0, a.x0), (w.y0, a.y0), (w.x1, a.x1), (w.y1, a.y1)] { + assert!((x - y).abs() < 1e-6, "{w:?} {a:?}"); + } +} + +#[test] +fn the_classic_wireframe_surface_expands_to_all_twelve_edges() { + let n = solid_square(super::super::CLASSIC_EXTRUDE, json!({"surface": "wireframe"})); + assert_eq!(segments(&three_d_wireframe_in(None, &n).unwrap()).len(), 12); +} + +#[test] +fn revolve_inflate_and_rotate_expand_too() { + for id in [crate::REVOLVE, super::super::INFLATE, ROTATE] { + let wire = three_d_wireframe_in(None, &solid_square(id, json!({}))).unwrap_or_else(|e| panic!("{id}: {e}")); + assert!(!wire.is_empty(), "{id}"); + assert!(wire.iter().flat_map(|p| &p.subpaths).flat_map(|sp| &sp.anchors).all(|a| a.p.x.is_finite() && a.p.y.is_finite()), "{id}"); + } + // Rotate is one flat face: its outline. + let plane = three_d_wireframe_in(None, &solid_square(ROTATE, json!({}))).unwrap(); + assert_eq!(segments(&plane).len(), 4); +} + +/// A geometry effect after a fill's 3D effect moves that fill's edges, as it moves its drawn surface. +#[test] +fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() { + for id in [super::super::EXTRUDE, crate::REVOLVE] { + let mut n = solid_square(id, json!({})); + let fx = n.appearance.effects.remove(0); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(fx.clone()); + } + } + let plain = bounds(&three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id}: {e}"))); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap()); + } + } + let moved = bounds(&three_d_wireframe_in(None, &n).unwrap()); + assert!((moved.x0 - plain.x0 - 50.0).abs() < 1e-6 && (moved.y0 - plain.y0).abs() < 1e-6, "{id}: {plain:?} {moved:?}"); + } +} + +#[test] +fn an_object_without_a_visible_3d_effect_is_an_error() { + let mut n = solid_square(super::super::EXTRUDE, json!({})); + n.appearance.effects[0].visible = false; + assert!(three_d_wireframe_in(None, &n).unwrap_err().contains("no 3D effect")); + n.appearance.effects.clear(); + assert!(three_d_wireframe_in(None, &n).is_err()); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 72e33b8993..f773c8716d 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -105,6 +105,7 @@ mod variablecmds; pub(crate) mod views; pub mod wand; pub mod webexport; +mod wireframe; mod xform; use serde::Serialize; @@ -308,6 +309,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(draw2::specs()); v.extend(xform::specs()); v.extend(effectcmd::specs()); + v.extend(wireframe::specs()); v.extend(pathops::specs()); v.extend(compoundshape::specs()); v.extend(typecmd::specs()); diff --git a/crates/engine/src/cmd/wireframe.rs b/crates/engine/src/cmd/wireframe.rs new file mode 100644 index 0000000000..5e7dd3f0d4 --- /dev/null +++ b/crates/engine/src/cmd/wireframe.rs @@ -0,0 +1,118 @@ +//! Window › 3D and Materials › Quick Actions › Expand as Wireframes: each object with a 3D effect +//! becomes a group of stroked paths, the edges of the faces its effect draws. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::appearance::AppearanceItem; +use vectorcraft_doc::{Appearance, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_render::effects; + +use super::{CommandSpec, Result, Session, bad, checked_ids_or_selection, cmd, has_doc}; + +const C: &str = "effect.expandAsWireframes"; + +/// The name of the undo step. +const LABEL: &str = "Expand as Wireframes"; + +/// The weight of a wireframe edge's stroke, in points (VectorCraft's own). +pub const WIRE_WEIGHT: f64 = 0.5; + +pub fn specs() -> Vec { + vec![cmd!( + "effect.expandAsWireframes", + "Expand as Wireframes", + [], + None, + "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Revolve, Inflate or Rotate, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", + has_doc, + expand_as_wireframes + )] +} + +/// A wireframe edge's appearance: a 0.5 pt black stroke with round joins and no fill. +fn wire_appearance() -> Appearance { + let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), WIRE_WEIGHT); + stroke.join = LineJoin::Round; + Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Default::default() } +} + +fn expand_as_wireframes(s: &mut Session, p: &Value) -> Result { + let roots = checked_ids_or_selection(s, p, C)?; + if roots.is_empty() { + return Err(bad(C, "select an object with a 3D effect, or give its id in ids")); + } + // Every wireframe is built before the edit, so a failure leaves the document and its history as + // they are. + let d = &s.doc()?.doc; + let mut wires = vec![]; + for id in roots { + let Some(n) = d.node(id) else { continue }; + if !effects::has_revolve(n) && !effects::has_solid(n) { + continue; + } + let wire = effects::three_d_wireframe_in(Some(d), n).map_err(|e| bad(C, format!("object {}: {e}", id.0)))?; + wires.push((id, wire)); + } + if wires.is_empty() { + return Err(bad(C, "no selected or given object has a visible 3D effect")); + } + let ids: Vec = s.edit(LABEL, |d, sel| { + let mut out = vec![]; + let mut selected = vec![]; + for (id, wire) in wires { + let Some(source) = d.node(id) else { continue }; + if matches!(source.kind, NodeKind::Layer { .. }) { + // A layer stays a layer: its members become the edges, and its 3D effect is removed. + let mut layer = source.clone(); + let children: Vec> = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect(); + selected.extend(children.iter().map(|c| c.id)); + if let Some(members) = layer.children_mut() { + *members = children; + } + layer.appearance = Default::default(); + if let Some(slot) = d.node_mut(id) { + *slot = layer; + out.push(id); + } + continue; + } + // The group keeps the object's name, visibility, lock, bounding box angle, transparency + // (opacity, blending mode, isolation, knockout and opacity mask), attributes, slice and text + // wrap. + let mut group = Node::group(id, vec![]); + group.name = source.name.clone(); + group.visible = source.visible; + group.locked = source.locked; + group.opacity = source.opacity; + group.blend = source.blend; + group.isolate = source.isolate; + group.knockout = source.knockout; + group.knockout_shape = source.knockout_shape; + group.bbox_angle = source.bbox_angle; + let extras = source.extras(); + let (mask, attrs, slice, wrap) = (extras.mask.clone(), extras.attrs.clone(), extras.slice.clone(), extras.wrap); + if mask.is_some() || attrs.is_some() || slice.is_some() || wrap.is_some() { + let mut e = group.extras_mut(); + e.mask = mask; + e.attrs = attrs; + e.slice = slice; + e.wrap = wrap; + } + let children = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect(); + group.kind = NodeKind::Group { children, clip: false }; + if let Some(slot) = d.node_mut(id) { + *slot = group; + out.push(id); + selected.push(id); + } + } + sel.set(selected); + Ok(out) + })?; + Ok(json!({ "ids": ids.iter().map(|id| id.0).collect::>() })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/wireframe/tests.rs b/crates/engine/src/cmd/wireframe/tests.rs new file mode 100644 index 0000000000..69e83d4b4a --- /dev/null +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -0,0 +1,143 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use serde_json::{Value, json}; +use vectorcraft_doc::appearance::AppearanceItem; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::Session; + +/// A new document with a filled 100 pt square; `effect` is applied to it when given. +fn setup(effect: Option<&str>) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":400})).unwrap(); + let id = NodeId(s.execute("path.create", &json!({"d":"M100 100L200 100L200 200L100 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color":"#338ad6"})).unwrap(); + if let Some(effect) = effect { + s.execute("effect.apply", &json!({"effect":effect,"item":null,"params":{}})).unwrap(); + } + (s, id) +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn run(s: &mut Session, p: Value) -> crate::Result { + s.execute("effect.expandAsWireframes", &p) +} + +#[test] +fn a_3d_object_becomes_a_selected_group_of_stroked_edges_in_one_undo_step() { + for effect in ["threeD.extrude", "threeD.classicExtrude", "threeD.revolve", "threeD.inflate", "threeD.rotate"] { + let (mut s, id) = setup(Some(effect)); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().name = Some("Box".into()); + let before = s.doc().unwrap().doc.clone(); + let history = undo_len(&s); + let out = run(&mut s, json!({})).unwrap_or_else(|e| panic!("{effect}: {e}")); + assert_eq!(out["ids"], json!([id.0]), "{effect}"); + let st = s.doc().unwrap(); + let group = st.doc.node(id).unwrap(); + let NodeKind::Group { children, clip: false } = &group.kind else { panic!("{effect}: a group") }; + assert!(!children.is_empty(), "{effect}"); + assert_eq!(group.name.as_deref(), Some("Box"), "{effect}"); + assert!(group.appearance.effects.is_empty(), "{effect}: the effect is gone"); + for c in children { + assert!(c.path_data().is_some(), "{effect}: paths only"); + let [AppearanceItem::Stroke(stroke)] = c.appearance.items.as_slice() else { panic!("{effect}: one stroke and no fill") }; + assert_eq!(stroke.width, super::WIRE_WEIGHT); + } + assert_eq!(st.selection.objects, vec![id], "{effect}"); + assert_eq!(undo_len(&s), history + 1, "{effect}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before, "{effect}"); + } +} + +#[test] +fn the_group_keeps_the_object_s_transparency() { + let (mut s, id) = setup(Some("threeD.extrude")); + s.execute("transparency.set", &json!({"opacity": 50, "knockout": "on"})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().bbox_angle = 0.25; + let before = s.doc().unwrap().doc.node(id).unwrap().clone(); + run(&mut s, json!({})).unwrap(); + let group = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!((group.opacity - 0.5).abs() < 1e-6, "{}", group.opacity); + assert_eq!((group.opacity, group.blend, group.knockout), (before.opacity, before.blend, before.knockout)); + assert_eq!(group.bbox_angle, 0.25, "the bounding box keeps its angle"); +} + +#[test] +fn a_layer_with_a_3d_effect_stays_a_layer_holding_the_edges() { + let (mut s, _) = setup(None); + let layer = s.doc().unwrap().doc.layers[0].id; + s.execute("effect.apply", &json!({"ids":[layer.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + let history = undo_len(&s); + let out = run(&mut s, json!({"ids":[layer.0]})).unwrap(); + assert_eq!(out["ids"], json!([layer.0])); + let st = s.doc().unwrap(); + let node = st.doc.node(layer).unwrap(); + assert!(matches!(node.kind, NodeKind::Layer { .. })); + assert!(node.appearance.effects.is_empty()); + let children = node.children().unwrap(); + assert!(!children.is_empty() && children.iter().all(|c| c.path_data().is_some())); + assert_eq!(st.selection.objects, children.iter().map(|c| c.id).collect::>()); + assert_eq!(undo_len(&s), history + 1); +} + +/// The number of segments in the wireframe group `id`. +fn segments(s: &Session, id: NodeId) -> usize { + let st = s.doc().unwrap(); + st.doc.node(id).unwrap().children().unwrap().iter().flat_map(|c| &c.path_data().unwrap().subpaths).map(|sp| sp.segment_count()).sum() +} + +#[test] +fn a_box_expands_to_the_nine_edges_of_its_three_faces() { + let (mut s, id) = setup(Some("threeD.extrude")); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + run(&mut s, json!({"ids":[id.0]})).unwrap(); + assert_eq!(segments(&s, id), 9); + // A stroke is outlined before the effect, so its own solid adds its edges. + let (mut s, id) = setup(Some("threeD.extrude")); + run(&mut s, json!({"ids":[id.0]})).unwrap(); + assert!(segments(&s, id) > 9); +} + +#[test] +fn objects_without_a_3d_effect_are_left_and_none_at_all_is_an_error() { + let (mut s, plain) = setup(None); + let history = undo_len(&s); + let before = s.doc().unwrap().doc.clone(); + let e = run(&mut s, json!({})).unwrap_err().to_string(); + assert!(e.contains("no selected or given object has a visible 3D effect"), "{e}"); + assert_eq!(undo_len(&s), history, "no undo step when nothing changes"); + assert_eq!(s.doc().unwrap().doc, before); + + // A hidden 3D effect draws nothing to expand. + s.execute("effect.apply", &json!({"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + s.execute("effect.setParams", &json!({"index":0,"item":null,"visible":false})).unwrap(); + assert!(run(&mut s, json!({})).is_err()); + + // With nothing selected. + s.execute("select.none", &json!({})).unwrap(); + let e = run(&mut s, json!({})).unwrap_err().to_string(); + assert!(e.contains("select an object with a 3D effect"), "{e}"); + + // A second object with a 3D effect expands; the plain one is left as it is. + let other = NodeId(s.execute("path.create", &json!({"d":"M250 250L300 250L300 300Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.remove", &json!({"ids":[plain.0],"index":0,"item":null})).unwrap(); + s.execute("paint.setFill", &json!({"ids":[other.0],"color":"#ff0000"})).unwrap(); + s.execute("effect.apply", &json!({"ids":[other.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + let kept = s.doc().unwrap().doc.node(plain).unwrap().clone(); + let out = run(&mut s, json!({"ids":[plain.0, other.0]})).unwrap(); + assert_eq!(out["ids"], json!([other.0])); + assert_eq!(s.doc().unwrap().doc.node(plain).unwrap(), &kept); +} + +#[test] +fn bad_ids_are_errors() { + let (mut s, _) = setup(Some("threeD.extrude")); + for p in [json!({"ids":[9999]}), json!({"ids":"x"}), json!({"ids":[-1]})] { + let history = undo_len(&s); + assert!(run(&mut s, p.clone()).is_err(), "{p}"); + assert_eq!(undo_len(&s), history, "{p}"); + } +} diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index 3c94bf88a5..e8bfede3fd 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -401,6 +401,32 @@ fn a_mixed_revolve_cap_shows_a_dash() { assert_eq!(undo_len(&app), history); } +/// Quick Actions › Expand as Wireframes shows for every object type, and turns the selected 3D +/// object into a group of stroked edges in one undo step. +#[test] +fn expand_as_wireframes_replaces_the_object_with_its_edges() { + for kind in ["extrude", "inflate", "revolve", "plane"] { + let mut app = app(); + set_type(&mut app, kind).unwrap(); + let text = painted_text(&mut app, show); + assert!(text.contains("Quick Actions") && text.contains("Expand as Wireframes"), "{kind}: {text}"); + let id = app.session.active().unwrap().selection.objects[0]; + let history = undo_len(&app); + super::expand_as_wireframes(&mut app).unwrap(); + assert_eq!(undo_len(&app), history + 1, "{kind}"); + let st = app.session.active().unwrap(); + assert!(matches!(st.doc.node(id).unwrap().kind, vectorcraft_doc::NodeKind::Group { .. }), "{kind}"); + assert_eq!(st.selection.objects, vec![id], "{kind}"); + // The group has no 3D effect: the panel offers the object types again. + assert!(target(&app).is_none(), "{kind}"); + } + // Without a 3D effect in the selection, nothing changes. + let mut app = app(); + let history = undo_len(&app); + assert!(super::expand_as_wireframes(&mut app).is_err()); + assert_eq!(undo_len(&app), history); +} + #[test] fn changed_settings_lists_only_new_values() { let before = json!({"depth": 50.0, "cap": true, "bevel": "none"}); diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs index 25dfaa6842..5d4763090e 100644 --- a/crates/ui-egui/src/panels/three_d.rs +++ b/crates/ui-egui/src/panels/three_d.rs @@ -7,11 +7,12 @@ use egui::Ui; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Effect, Node}; -use vectorcraft_effects::{THREE_D_TYPES, is_three_d}; +use vectorcraft_effects::{CLASSIC_EXTRUDE, EXTRUDE, INFLATE, THREE_D_TYPES, is_three_d}; use super::{live_run, pstate, set_pstate}; use crate::VectorcraftApp; use crate::state::Dialog; +use crate::theme::Tokens; use crate::widgets::{self, Live, dim_label}; /// The panel's id (`window.panel`). @@ -242,6 +243,32 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let changed = changed_settings(&before, &after); let dragging = ctx.input(|i| i.pointer.any_down()); send(app, &ctx, changed, dragging); + if quick_actions_shown(&d) { + ui.add_space(10.0); + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!("Quick Actions")).font(crate::theme::semibold(13.0)).color(t.text)); + let tip = tl!("Replace the selected 3D objects with the edges of their faces, as stroked paths."); + if ui.button(tl!("Expand as Wireframes")).on_hover_text(tip).clicked() { + finish(app, &ctx); + if let Err(e) = expand_as_wireframes(app) { + app.ui.status = e; + } + } + } +} + +/// Does the panel show its Quick Actions? On the Object tab of the effects that have one, and on +/// the first tab of the others (Revolve, Rotate), never on Lighting. +fn quick_actions_shown(d: &Dialog) -> bool { + let effect = d.str("__effect"); + let object_tab = [EXTRUDE, INFLATE, CLASSIC_EXTRUDE].contains(&effect.as_str()); + !crate::dialogs::revolve_gizmo::lighting(d) && (!object_tab || d.str("__tab") != "rotation") +} + +/// Quick Actions › Expand as Wireframes: each selected object with a 3D effect becomes a group of +/// the edges of its faces (`effect.expandAsWireframes`), one undo step. +pub(crate) fn expand_as_wireframes(app: &mut VectorcraftApp) -> Result<(), String> { + app.run("effect.expandAsWireframes", json!({})).map(|_| ()) } /// Sets to null each setting in `fields` (the first selected object's 3D effect) that the 3D effect diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2602b78bc4..d9752aff51 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -180,7 +180,7 @@ VectorCraft choices; an original approximation, never compared with another appl |---|---| | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | -| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing | +| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic), Rotate (Classic) and Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | diff --git a/docs/mcp.md b/docs/mcp.md index da260b6823..98b64d58f6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3094,6 +3094,28 @@ checked as that effect's. It is one undo step. Any other `threeDType` value, or {"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"threeDType":"inflate","params":{"volume":70}}}} ``` +## Expand as Wireframes + +`effect.expandAsWireframes {ids?}` (**Window › 3D and Materials** › Quick Actions › Expand as Wireframes) replaces +each selected object, or each object in `ids`, that has a visible 3D effect (Extrude & Bevel, Extrude & Bevel +(Classic), Revolve, Inflate or Rotate, on the object or on its fills and strokes) with a group of paths: the edges of +the faces the effect draws for the object as it is turned and projected now +([three-d.md](three-d.md#3d-and-materials-panel)). Each edge appears once; edges that meet end to end form one +path, closed where it comes back to its start, and curved edges stay curves. Geometry effects after a 3D effect, +on the object or on a fill or stroke, reshape its edges as they reshape the drawn surface. The paths have a 0.5 pt +black stroke with round joins and no fill (VectorCraft's own choice). The group keeps the object's place, id, name, +visibility, lock, opacity, blending mode, knockout, opacity mask, attributes, slice and text wrap, and the groups +are selected. +A layer in `ids` stays a layer: its members are replaced by the edges, which are selected. Objects without a visible 3D effect do not change. It is one undo step and +returns `{ids}`, the groups. It is an error, and nothing changes, when nothing is selected or given, when no object +has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than +1,000,000 edges. + +```json +{"name":"run_command","arguments":{"command":"effect.expandAsWireframes","params":{}}} +{"name":"run_command","arguments":{"command":"effect.expandAsWireframes","params":{"ids":[12]}}} +``` + ## Extrude & Bevel, Inflate and Rotate `effect.apply {effect: "threeD.extrude" | "threeD.inflate" | "threeD.rotate", params: {...}, ids?, item?}` applies diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d50447ff..b3276d581b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -560,6 +560,7 @@ since 2026-10-01. - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. - 3D shadows: Revolve, Extrude & Bevel, Inflate and Rotate take a Shadows switch with Position (Behind Object or Below Object), Distance from Object (0–100 %) and Shadow Bounds (10–400 %) on the Lighting tab ([three-d.md](three-d.md#shadows)); the first light casts one hard-edged vector shadow, drawn behind the solids, in exports and kept by Expand Appearance. Evidence: `crates/three-d/src/shadow/tests.rs`, `crates/effects/src/solids/tests_shadow.rs`, `effectcmd::tests::a_3d_shadow_is_one_undo_step_saved_exported_and_expanded`. Softness and the shadows of lights after the first are not built. Scores unchanged. - Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. +- 3D and Materials › Quick Actions › Expand as Wireframes (#6354): `effect.expandAsWireframes` replaces each selected 3D object with a group of 0.5 pt black stroked paths, the edges of the faces its effect draws, each edge once and joined end to end, curves kept, in one undo step ([three-d.md](three-d.md#3d-and-materials-panel)); `crates/effects/src/solids/wireframe/tests.rs`, `crates/engine/src/cmd/wireframe/tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. Scores and hours unchanged. - 3D effects on clipping groups, blends and symbol instances (#6140, #5725): every 3D type builds from the art the object shows (the art inside the clipping path, the blend's keys and steps, the symbol's art where the instance places it), alone or in a group, on the canvas, in exports, Object › Rasterize and `assets.add3D`; an image is refused with a pointer to Object › Image Trace › Make. `crates/effects/src/solids/tests_sources.rs`, `crates/engine/src/cmd/effectcmd/three_d_source_tests.rs` and `fx::tests::a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids` cover it. Scores and estimates unchanged. - **Status-bar pointer readout (2026-10-18):** uses the same effective ruler origin as the position fields, then formats the relative X/Y in General units. The absolute pointer position, document, selection, Undo/Redo and saved state are preserved. Three `chrome::tests::status_coordinates_*` painted-text regressions cover zero/positive/negative origins, unit conversion, global/artboard mode, active artboard, reset and missing pointer/document. Original headless status-bar captures were reviewed before and after the change. Scores and hours are unchanged. diff --git a/docs/three-d.md b/docs/three-d.md index 1026a9e1be..283d4a3632 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -51,6 +51,19 @@ pages describe it. unavailable while a setting of a single light differs between the selected effects. - Each change is one undo step. Dragging a value or a slider previews it on the artboard and records one step when the pointer is released; a typed value is recorded when it is entered. Drawing the panel records nothing. +- **Quick Actions › Expand as Wireframes** sits under the Object tab (under the first tab for Revolve and Rotate, + which have no Object tab). It replaces each selected object that has a 3D effect with a group of paths, the + edges of the faces the effect draws for the object as it is turned and projected now (`effect.expandAsWireframes`), + as the public [Render 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/render-3d-vector-artwork.html) + page describes for converting the wireframe into editable paths. Each edge appears once: edges that meet end to + end form one path, closed where it comes back to its start, and curved edges stay curves. A face partly covered + by another face gives its whole outline. Faces turned away from the viewer are included where the effect draws + them (Draw Hidden Faces and the Wireframe surface of Extrude & Bevel (Classic)). Geometry effects after a 3D + effect, on the object or on a fill or stroke, reshape the edges as they reshape the drawn art. Shadows are not + part of the wireframe. The paths have a 0.5 pt black stroke with round joins and no fill, which is VectorCraft's + own choice. The group keeps the object's place, id, name, opacity, blending mode, opacity mask, attributes + and text wrap and is selected; objects without a visible 3D effect do not change. It is one undo step, and an error changes nothing + when no selected object has a visible 3D effect. - The Lighting tab also holds the Shadows switch and its settings ([Shadows](#shadows)). The Materials tab and ray-traced rendering are not built (see below). The rotation and light rings on the artboard are part of the options dialog only. From a90a803cfba2856f748a0d9132bbe4f19c0bbe27 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:42:57 -0800 Subject: [PATCH 2/9] 3D and Materials: Expand as Wireframes expands an id given twice once An object or layer named twice in ids is expanded once, so the selection holds only the edges that are in the document. --- crates/engine/src/cmd/wireframe.rs | 5 +++++ crates/engine/src/cmd/wireframe/tests.rs | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/wireframe.rs b/crates/engine/src/cmd/wireframe.rs index 5e7dd3f0d4..27a35a793b 100644 --- a/crates/engine/src/cmd/wireframe.rs +++ b/crates/engine/src/cmd/wireframe.rs @@ -47,7 +47,12 @@ fn expand_as_wireframes(s: &mut Session, p: &Value) -> Result { // they are. let d = &s.doc()?.doc; let mut wires = vec![]; + let mut seen = std::collections::HashSet::new(); for id in roots { + // An id given twice is expanded once. + if !seen.insert(id) { + continue; + } let Some(n) = d.node(id) else { continue }; if !effects::has_revolve(n) && !effects::has_solid(n) { continue; diff --git a/crates/engine/src/cmd/wireframe/tests.rs b/crates/engine/src/cmd/wireframe/tests.rs index 69e83d4b4a..67e338b781 100644 --- a/crates/engine/src/cmd/wireframe/tests.rs +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -71,7 +71,8 @@ fn a_layer_with_a_3d_effect_stays_a_layer_holding_the_edges() { let layer = s.doc().unwrap().doc.layers[0].id; s.execute("effect.apply", &json!({"ids":[layer.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); let history = undo_len(&s); - let out = run(&mut s, json!({"ids":[layer.0]})).unwrap(); + // An id given twice is expanded once. + let out = run(&mut s, json!({"ids":[layer.0, layer.0]})).unwrap(); assert_eq!(out["ids"], json!([layer.0])); let st = s.doc().unwrap(); let node = st.doc.node(layer).unwrap(); From 6ccb7c907756400aa6c360edaa350834716ecac7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:36:03 -0800 Subject: [PATCH 3/9] 3D and Materials: Expand as Wireframes expands Revolve (Classic) and Rotate (Classic) effect.expandAsWireframes returned "draws no faces" for an object with Revolve (Classic). It now takes the faces of Revolve (Classic) with that effect's own options, and Rotate (Classic) is expanded as Rotate is: every fill and stroke turned as one plane. Docs: docs/mcp.md, docs/three-d.md. --- crates/effects/src/revolve.rs | 24 ++++++++------ crates/effects/src/solids/wireframe.rs | 14 ++++----- crates/effects/src/solids/wireframe/tests.rs | 33 ++++++++++++++++++++ crates/engine/src/cmd/wireframe.rs | 2 +- crates/engine/src/cmd/wireframe/tests.rs | 10 +++++- docs/mcp.md | 4 +-- docs/three-d.md | 2 +- 7 files changed, 67 insertions(+), 22 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 9d31e8372d..8d05ab38ed 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -507,9 +507,9 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul Ok(out) } -/// The outline of every face [`revolve_art`] draws for `n` (its symbol instances, blends and clipping -/// groups already resolved), in document coordinates: with the effect on the object, the surface in -/// its top visible fill (else its stroke); with it on fills and strokes, one surface for each of +/// The outline of every face [`revolve_art`] draws for `n`'s Revolve or Revolve (Classic) (its symbol +/// instances, blends and clipping groups already resolved), in document coordinates: with the +/// effect on the object, the surface in its top visible fill (else its stroke); with it on fills and strokes, one surface for each of /// them, reshaped by the geometry effects after each one's Revolve; on a group or layer, one for /// each member, about one shared axis. pub(crate) fn revolve_face_paths(n: &Node) -> Result, String> { @@ -517,13 +517,17 @@ pub(crate) fn revolve_face_paths(n: &Node) -> Result, String> { Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(vectorcraft_three_d::Face::path).collect()) }; if is_container(n) { - let index = - n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE).ok_or("a 3D effect on a group's fill or stroke draws nothing")?; + let index = n + .appearance + .effects + .iter() + .position(|e| e.visible && is_revolve(&e.id)) + .ok_or("a 3D effect on a group's fill or stroke draws nothing")?; let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; let mut shapes = members(n, "Revolve")?; reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?; - let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let options = shared_options(&shapes, fx_options(fx))?; let mut out = vec![]; for s in shapes.iter().filter(|s| surface_item(&s.items).is_some()) { out.extend(faces(&s.path, b, options)?); @@ -532,24 +536,24 @@ pub(crate) fn revolve_face_paths(n: &Node) -> Result, String> { } let (path, _) = object_profile(n, "Revolve")?; let ctx = GeomContext::of(n); - if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) { let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); if surface_item(&solid_paints(n)).is_none() { return Err("the revolved object has no fill or stroke".into()); } let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; - return faces(&path, b, revolve_options(&merged_params(REVOLVE, &fx.params))); + return faces(&path, b, fx_options(fx)); } let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { - if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) + if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) && let Some(fx) = item.effects().get(index) { let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); let b = p.bounds().ok_or("Revolve requires a shape with at least two anchors")?; - let surface = faces(&p, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let surface = faces(&p, b, fx_options(fx))?; out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &ctx)); } } diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index eb74546225..e2656222b3 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -7,8 +7,8 @@ use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; use vectorcraft_three_d::Facet; use super::{ - INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_three_d, label_of, members, object_profile, region, - reshape_members, resolve, solid, solid_paints, solid_parts, union_bounds, view_options, + INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_plane, is_three_d, label_of, members, object_profile, + region, reshape_members, resolve, solid, solid_paints, solid_parts, union_bounds, view_options, }; use crate::{GeomContext, apply_geometry_with, is_geometry}; @@ -21,8 +21,8 @@ const GRID: f64 = 1.0 / 1024.0; const NO_EFFECT: &str = "the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)"; -/// The wireframe of `n`'s 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Revolve, Inflate -/// or Rotate), in document coordinates: the edges of every face the effect draws for the object as +/// The wireframe of `n`'s 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D +/// (Classic) forms), in document coordinates: the edges of every face the effect draws for the object as /// it is turned and projected, each edge once. Edges that meet end to end are joined into one path; /// a run that comes back to where it started is closed. A curved edge stays a curve. The geometry /// effects after the object's 3D effect reshape the wireframe as they reshape the drawn art. On a @@ -108,8 +108,8 @@ fn solid_face_paths(n: &Node) -> Result, String> { Ok(out) } -/// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: -/// Rotate turns every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's +/// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: Rotate and +/// Rotate (Classic) turn every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's /// top fill and from the outline of each stroke. A stroke whose solid is past the limits is left out, /// as it is left out of the drawn art. fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> { @@ -117,7 +117,7 @@ fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; let p = crate::merged_params(&fx.id, &fx.params); let mut out = vec![]; - if fx.id == ROTATE { + if is_plane(&fx.id) { for s in shapes { for item in &s.items { let (area, rule) = region(&s.path, s.rule, item); diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 980e67bbeb..8498885776 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -124,6 +124,39 @@ fn revolve_inflate_and_rotate_expand_too() { assert_eq!(segments(&plane).len(), 4); } +/// Revolve (Classic) and Rotate (Classic) expand to the edges of the faces they draw, in the same +/// place. +#[test] +fn revolve_classic_and_rotate_classic_expand_too() { + let n = solid_square(crate::CLASSIC_REVOLVE, json!({})); + let wire = three_d_wireframe_in(None, &n).unwrap(); + assert!(!wire.is_empty()); + let art = crate::revolve_art(&n).unwrap(); + let (w, a) = (bounds(&wire), bounds(&face_paths(&art))); + for (x, y) in [(w.x0, a.x0), (w.y0, a.y0), (w.x1, a.x1), (w.y1, a.y1)] { + assert!((x - y).abs() < 1e-6, "{w:?} {a:?}"); + } + // On a fill, with a geometry effect after it. + let mut n = solid_square(crate::CLASSIC_REVOLVE, json!({})); + let fx = n.appearance.effects.remove(0); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(fx.clone()); + } + } + let plain = bounds(&three_d_wireframe_in(None, &n).unwrap()); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap()); + } + } + let moved = bounds(&three_d_wireframe_in(None, &n).unwrap()); + assert!((moved.x0 - plain.x0 - 50.0).abs() < 1e-6 && (moved.y0 - plain.y0).abs() < 1e-6, "{plain:?} {moved:?}"); + // Rotate (Classic) is one flat face: its outline. + let plane = three_d_wireframe_in(None, &solid_square(super::super::CLASSIC_ROTATE, json!({}))).unwrap(); + assert_eq!(segments(&plane).len(), 4); +} + /// A geometry effect after a fill's 3D effect moves that fill's edges, as it moves its drawn surface. #[test] fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() { diff --git a/crates/engine/src/cmd/wireframe.rs b/crates/engine/src/cmd/wireframe.rs index 27a35a793b..42dfaf8a33 100644 --- a/crates/engine/src/cmd/wireframe.rs +++ b/crates/engine/src/cmd/wireframe.rs @@ -25,7 +25,7 @@ pub fn specs() -> Vec { "Expand as Wireframes", [], None, - "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Revolve, Inflate or Rotate, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", + "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", has_doc, expand_as_wireframes )] diff --git a/crates/engine/src/cmd/wireframe/tests.rs b/crates/engine/src/cmd/wireframe/tests.rs index 67e338b781..ce881982cd 100644 --- a/crates/engine/src/cmd/wireframe/tests.rs +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -27,7 +27,15 @@ fn run(s: &mut Session, p: Value) -> crate::Result { #[test] fn a_3d_object_becomes_a_selected_group_of_stroked_edges_in_one_undo_step() { - for effect in ["threeD.extrude", "threeD.classicExtrude", "threeD.revolve", "threeD.inflate", "threeD.rotate"] { + for effect in [ + "threeD.extrude", + "threeD.classicExtrude", + "threeD.revolve", + "threeD.classicRevolve", + "threeD.inflate", + "threeD.rotate", + "threeD.classicRotate", + ] { let (mut s, id) = setup(Some(effect)); std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().name = Some("Box".into()); let before = s.doc().unwrap().doc.clone(); diff --git a/docs/mcp.md b/docs/mcp.md index a1d1f60c2f..10e3905982 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3228,8 +3228,8 @@ checked as that effect's. It is one undo step. Any other `threeDType` value, or ## Expand as Wireframes `effect.expandAsWireframes {ids?}` (**Window › 3D and Materials** › Quick Actions › Expand as Wireframes) replaces -each selected object, or each object in `ids`, that has a visible 3D effect (Extrude & Bevel, Extrude & Bevel -(Classic), Revolve, Inflate or Rotate, on the object or on its fills and strokes) with a group of paths: the edges of +each selected object, or each object in `ids`, that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, +Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws for the object as it is turned and projected now ([three-d.md](three-d.md#3d-and-materials-panel)). Each edge appears once; edges that meet end to end form one path, closed where it comes back to its start, and curved edges stay curves. Geometry effects after a 3D effect, diff --git a/docs/three-d.md b/docs/three-d.md index ca21b815e2..820bc1e805 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -52,7 +52,7 @@ pages describe it. - Each change is one undo step. Dragging a value or a slider previews it on the artboard and records one step when the pointer is released; a typed value is recorded when it is entered. Drawing the panel records nothing. - **Quick Actions › Expand as Wireframes** sits under the Object tab (under the first tab for Revolve and Rotate, - which have no Object tab). It replaces each selected object that has a 3D effect with a group of paths, the + which have no Object tab). It replaces each selected object that has a 3D effect, the 3D (Classic) effects included, with a group of paths, the edges of the faces the effect draws for the object as it is turned and projected now (`effect.expandAsWireframes`), as the public [Render 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/render-3d-vector-artwork.html) page describes for converting the wireframe into editable paths. Each edge appears once: edges that meet end to From ac79ae55ba055830d6a9372eb2071bba80a57795 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:52:39 -0800 Subject: [PATCH 4/9] 3D and Materials: Expand as Wireframes keeps a layer's ruler guides and reshapes each face - Expanding a layer removed its ruler guides with its members. The guides of the layer and of its sublayers now stay on the layer, after the edges. - Geometry effects after a 3D effect reshaped all the faces as one path, so Offset Path merged faces that touch. Each effect now reshapes each face on its own, about the bounds of all the faces, within one shared budget of anchors, as it reshapes the drawn surface. Docs: docs/mcp.md. --- crates/effects/src/solids/wireframe.rs | 34 ++++++++++++-------- crates/effects/src/solids/wireframe/tests.rs | 13 ++++++++ crates/engine/src/cmd/wireframe.rs | 27 ++++++++++++++-- crates/engine/src/cmd/wireframe/tests.rs | 20 ++++++++++++ docs/mcp.md | 2 +- 5 files changed, 80 insertions(+), 16 deletions(-) diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index e2656222b3..5f2275d8eb 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -1,8 +1,10 @@ //! Expand as Wireframes: the edges of the faces a 3D effect draws, as open and closed paths that //! can be edited on their own. use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; -use vectorcraft_doc::{AppearanceItem, Document, Effect, Node}; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId}; use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; use vectorcraft_three_d::Facet; @@ -10,7 +12,7 @@ use super::{ INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_plane, is_three_d, label_of, members, object_profile, region, reshape_members, resolve, solid, solid_paints, solid_parts, union_bounds, view_options, }; -use crate::{GeomContext, apply_geometry_with, is_geometry}; +use crate::{GeomContext, is_geometry}; /// The most distinct edges one object's wireframe may have. pub(crate) const MAX_WIRE_EDGES: usize = 1_000_000; @@ -50,7 +52,7 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result, n: &Node) -> Result) -> Vec { +/// `faces` reshaped by the visible geometry effects after `n`'s own 3D effect ([`reshaped`]). +fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec) -> Vec { let fx = &n.appearance.effects; let Some(index) = fx.iter().position(|e| e.visible && is_three_d(&e.id)) else { return faces }; - reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext::of(n)) + reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..GeomContext::of(n) }) } -/// `faces` reshaped by the visible geometry effects in `later` (the effects after a 3D effect), -/// which act on the faces as a whole, about their bounds, as they act on the drawn surface. +/// `faces` reshaped by the visible geometry effects in `later` (the effects after a 3D effect), as +/// they reshape the faces of the drawn surface ([`crate::reshape::reshape_leaves`]): each effect +/// reshapes each face on its own, about the bounds all the faces have before it, and the faces keep +/// to one budget of anchors together. `ctx` gives the object's id, the seed of a random Transform, +/// and the document. pub(crate) fn reshaped(faces: Vec, later: &[Effect], ctx: &GeomContext) -> Vec { - let after: Vec = later.iter().filter(|e| e.visible && is_geometry(&e.id)).cloned().collect(); + let after: Vec<&Effect> = later.iter().filter(|e| e.visible && is_geometry(&e.id)).collect(); if after.is_empty() { return faces; } - let joined = PathData::new(faces.into_iter().flat_map(|f| f.subpaths).collect()); - let Some(bounds) = joined.bounds() else { return vec![joined] }; - vec![apply_geometry_with(&after, &joined, bounds, ctx)] + let id = NodeId(ctx.seed); + let fill = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); + let mut art = Node::group(id, faces.into_iter().map(|f| Arc::new(Node::path(id, f, fill.clone()))).collect()); + crate::reshape::reshape_leaves(&mut art, &after, later, ctx.doc); + let mut out = vec![]; + art.walk(&mut |c| out.extend(c.path_data().cloned())); + out } /// The outline of every face [`super::solid_art`] draws for `n` (already resolved): with the effect diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 8498885776..158e42d98f 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -157,6 +157,19 @@ fn revolve_classic_and_rotate_classic_expand_too() { assert_eq!(segments(&plane).len(), 4); } +/// The geometry effects after a 3D effect reshape each face on its own, as they reshape each face +/// of the drawn surface: two faces that touch stay two outlines. +#[test] +fn later_geometry_effects_reshape_each_face_on_its_own() { + let n = solid_square(super::super::EXTRUDE, json!({})); + let fx = [crate::new_effect("path.offsetPath", &json!({"offset": 1.0})).unwrap()]; + let out = reshaped(vec![square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)], &fx, &GeomContext::of(&n)); + assert_eq!(out.len(), 2, "{out:?}"); + let (a, b) = (out[0].bounds().unwrap(), out[1].bounds().unwrap()); + assert!((a.x0 + 1.0).abs() < 1e-3 && (a.x1 - 11.0).abs() < 1e-3, "{a:?}"); + assert!((b.x0 - 9.0).abs() < 1e-3 && (b.x1 - 21.0).abs() < 1e-3, "{b:?}"); +} + /// A geometry effect after a fill's 3D effect moves that fill's edges, as it moves its drawn surface. #[test] fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() { diff --git a/crates/engine/src/cmd/wireframe.rs b/crates/engine/src/cmd/wireframe.rs index 42dfaf8a33..ccd82dd0c3 100644 --- a/crates/engine/src/cmd/wireframe.rs +++ b/crates/engine/src/cmd/wireframe.rs @@ -25,7 +25,7 @@ pub fn specs() -> Vec { "Expand as Wireframes", [], None, - "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", + "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected, and its ruler guides); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", has_doc, expand_as_wireframes )] @@ -38,6 +38,25 @@ fn wire_appearance() -> Appearance { Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Default::default() } } +/// The ruler guides of layer `n` and then those of the sublayers inside it, in order, added to +/// `out`. A guide from a hidden or locked sublayer, or from one inside a hidden or locked group or +/// sublayer, is hidden or locked itself. +fn ruler_guides(n: &Node, hidden: bool, locked: bool, out: &mut Vec>) { + let Some(children) = n.children() else { return }; + for c in children.iter().filter(|c| c.is_ruler_guide()) { + let mut g = c.clone(); + if hidden || locked { + let g = Arc::make_mut(&mut g); + g.visible &= !hidden; + g.locked |= locked; + } + out.push(g); + } + for c in children.iter().filter(|c| !c.is_ruler_guide()) { + ruler_guides(c, hidden || !c.visible, locked || c.locked, out); + } +} + fn expand_as_wireframes(s: &mut Session, p: &Value) -> Result { let roots = checked_ids_or_selection(s, p, C)?; if roots.is_empty() { @@ -70,9 +89,13 @@ fn expand_as_wireframes(s: &mut Session, p: &Value) -> Result { let Some(source) = d.node(id) else { continue }; if matches!(source.kind, NodeKind::Layer { .. }) { // A layer stays a layer: its members become the edges, and its 3D effect is removed. + // Its ruler guides and those of its sublayers are not art: they stay, after the edges. let mut layer = source.clone(); - let children: Vec> = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect(); + let mut guides = vec![]; + ruler_guides(source, false, false, &mut guides); + let mut children: Vec> = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect(); selected.extend(children.iter().map(|c| c.id)); + children.extend(guides); if let Some(members) = layer.children_mut() { *members = children; } diff --git a/crates/engine/src/cmd/wireframe/tests.rs b/crates/engine/src/cmd/wireframe/tests.rs index ce881982cd..ab51c81282 100644 --- a/crates/engine/src/cmd/wireframe/tests.rs +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -92,6 +92,26 @@ fn a_layer_with_a_3d_effect_stays_a_layer_holding_the_edges() { assert_eq!(undo_len(&s), history + 1); } +/// A layer's ruler guides are not art: they stay on the layer, after the edges, and are not selected. +#[test] +fn a_layer_keeps_its_ruler_guides() { + let (mut s, _) = setup(None); + let layer = s.doc().unwrap().doc.layers[0].id; + s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap(); + let guides = |s: &Session| -> Vec { + s.doc().unwrap().doc.node(layer).unwrap().children().unwrap().iter().filter(|c| c.is_ruler_guide()).map(|c| c.id).collect() + }; + let before = guides(&s); + assert_eq!(before.len(), 1); + s.execute("effect.apply", &json!({"ids":[layer.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + run(&mut s, json!({"ids":[layer.0]})).unwrap(); + assert_eq!(guides(&s), before); + let st = s.doc().unwrap(); + let children = st.doc.node(layer).unwrap().children().unwrap(); + assert!(children.len() > 1 && children.last().is_some_and(|c| c.is_ruler_guide())); + assert!(!st.selection.objects.is_empty() && st.selection.objects.iter().all(|id| !before.contains(id))); +} + /// The number of segments in the wireframe group `id`. fn segments(s: &Session, id: NodeId) -> usize { let st = s.doc().unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index 10e3905982..7457e6faa3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3237,7 +3237,7 @@ on the object or on a fill or stroke, reshape its edges as they reshape the draw black stroke with round joins and no fill (VectorCraft's own choice). The group keeps the object's place, id, name, visibility, lock, opacity, blending mode, knockout, opacity mask, attributes, slice and text wrap, and the groups are selected. -A layer in `ids` stays a layer: its members are replaced by the edges, which are selected. Objects without a visible 3D effect do not change. It is one undo step and +A layer in `ids` stays a layer: its members are replaced by the edges, which are selected, and its ruler guides stay. Objects without a visible 3D effect do not change. It is one undo step and returns `{ids}`, the groups. It is an error, and nothing changes, when nothing is selected or given, when no object has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges. From 55a80ca480692aa397e087bc7cb9c9d6b38ef1a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 07:31:39 -0800 Subject: [PATCH 5/9] 3D and Materials: Expand as Wireframes looks up swatches for plug-ins after a fill's 3D effect A live-effect plug-in after a fill's or stroke's 3D effect (Extrude & Bevel, Inflate, Rotate or Revolve) that links its pieces to a swatch or pattern was run without the document, so its result was refused and the edges kept the shape before it. The document now goes to those effects, as it does for the effects after an object's 3D effect. --- crates/effects/src/revolve.rs | 7 +++--- crates/effects/src/solids/wireframe.rs | 13 ++++++----- crates/engine/src/cmd/wireframe/tests.rs | 28 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 8d05ab38ed..aae131dfdc 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -511,8 +511,9 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul /// instances, blends and clipping groups already resolved), in document coordinates: with the /// effect on the object, the surface in its top visible fill (else its stroke); with it on fills and strokes, one surface for each of /// them, reshaped by the geometry effects after each one's Revolve; on a group or layer, one for -/// each member, about one shared axis. -pub(crate) fn revolve_face_paths(n: &Node) -> Result, String> { +/// each member, about one shared axis. The swatches and patterns named by a live-effect plug-in +/// after a fill's or stroke's Revolve are looked up in `doc`. +pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { let faces = |path: &PathData, b: Rect, options: Revolve| -> Result, String> { Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(vectorcraft_three_d::Face::path).collect()) }; @@ -554,7 +555,7 @@ pub(crate) fn revolve_face_paths(n: &Node) -> Result, String> { let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); let b = p.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let surface = faces(&p, b, fx_options(fx))?; - out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &ctx)); + out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx })); } } Ok(out) diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index 5f2275d8eb..33f7f7f018 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -44,14 +44,14 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result, later: &[Effect], ctx: &GeomContext /// The outline of every face [`super::solid_art`] draws for `n` (already resolved): with the effect /// on the object, its solids; with it on fills and strokes, the solid of each; on a group or layer, -/// the solids of its members, about one shared axis. -fn solid_face_paths(n: &Node) -> Result, String> { +/// the solids of its members, about one shared axis. The swatches and patterns named by a live-effect +/// plug-in after a fill's or stroke's 3D effect are looked up in `doc`. +fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { if is_container(n) { let (index, fx) = solid(&n.appearance.effects).ok_or("a 3D effect on a group's fill or stroke draws nothing")?; let mut shapes = members(n, label_of(&fx.id))?; @@ -110,7 +111,7 @@ fn solid_face_paths(n: &Node) -> Result, String> { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); let faces = facet_paths(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; - out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &ctx)); + out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx })); } } Ok(out) diff --git a/crates/engine/src/cmd/wireframe/tests.rs b/crates/engine/src/cmd/wireframe/tests.rs index ab51c81282..268fbcd6db 100644 --- a/crates/engine/src/cmd/wireframe/tests.rs +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -161,6 +161,34 @@ fn objects_without_a_3d_effect_are_left_and_none_at_all_is_an_error() { assert_eq!(s.doc().unwrap().doc.node(plain).unwrap(), &kept); } +/// A live-effect plug-in after a fill's 3D effect that links its pieces to one of the document's +/// swatches reshapes the edges, as it reshapes the drawn surface. +#[test] +fn a_painted_effect_after_a_fill_s_3d_effect_finds_the_document_s_swatches() { + use vectorcraft_geom::{PathData, Point, SubPath}; + let mut leaf = serde_json::to_value(vectorcraft_color::Paint::solid(vectorcraft_color::Color::from_hex("#00ff00").unwrap())).unwrap(); + leaf["swatch"] = json!("Leaf"); + let corners = [Point::new(300.0, 300.0), Point::new(320.0, 300.0), Point::new(320.0, 320.0), Point::new(300.0, 320.0)]; + let output = json!({"objects": [{"path": PathData::single(SubPath::polyline(&corners, true)), "fills": [leaf]}]}).to_string(); + let manifest = r#"{"id":"test.engine.wireleaf","name":"Test wireleaf","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{}}"#; + let wasm = wat::parse_str(vectorcraft_plugins::wat::returning(manifest, &output)).unwrap(); + for effect in ["threeD.extrude", "threeD.revolve"] { + let (mut s, id) = setup(None); + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "replace": true})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("swatch.new", &json!({"name": "Leaf", "color": "#00ff00", "global": true})).unwrap(); + let items = &s.doc().unwrap().doc.node(id).unwrap().appearance.items; + let fill = items.iter().position(|i| matches!(i, AppearanceItem::Fill(_))).unwrap(); + for e in [effect, "plugin.test.engine.wireleaf"] { + s.execute("effect.apply", &json!({"ids": [id.0], "effect": e, "item": fill, "params": {}})).unwrap(); + } + run(&mut s, json!({"ids": [id.0]})).unwrap_or_else(|e| panic!("{effect}: {e}")); + let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (300.0, 300.0, 320.0, 320.0), "{effect}: the plug-in's square"); + assert_eq!(segments(&s, id), 4, "{effect}"); + } +} + #[test] fn bad_ids_are_errors() { let (mut s, _) = setup(Some("threeD.extrude")); From 5a8cb302595c1340099ebf97328fd927715800f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 07:44:35 -0800 Subject: [PATCH 6/9] 3D and Materials: Expand as Wireframes reshapes edges by the effects after the 3D effect drawn With two 3D effects on an object, the geometry effects after the first one reshaped the edges, even when the second one (Revolve, which is drawn first) built the faces. A geometry effect between the two then moved the profile and the edges both. The edges now take only the effects after the 3D effect that built them. --- crates/effects/src/solids/wireframe.rs | 11 +++++++---- crates/effects/src/solids/wireframe/tests.rs | 12 ++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index 33f7f7f018..4f93b2f41a 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -52,7 +52,7 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result, n: &Node) -> Result, n: &Node, faces: Vec) -> Vec { +/// `faces` reshaped by the visible geometry effects after the 3D effect of `n`'s own that drew them +/// ([`reshaped`]): its first visible Revolve when `revolve` is set, else its first visible Extrude & +/// Bevel, Inflate or Rotate. +fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec, revolve: bool) -> Vec { let fx = &n.appearance.effects; - let Some(index) = fx.iter().position(|e| e.visible && is_three_d(&e.id)) else { return faces }; + let drawn = if revolve { fx.iter().position(|e| e.visible && crate::revolve::is_revolve(&e.id)) } else { solid(fx).map(|(i, _)| i) }; + let Some(index) = drawn else { return faces }; reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..GeomContext::of(n) }) } diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 158e42d98f..4af9d7b526 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -192,6 +192,18 @@ fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() { } } +/// With two 3D effects, a geometry effect between them reshapes the faces of the one drawn only +/// once: Revolve is drawn, so a Transform before it moves its profile and not its faces again. +#[test] +fn a_geometry_effect_before_the_drawn_3d_effect_is_not_applied_again() { + let mut n = solid_square(crate::REVOLVE, json!({})); + n.appearance.effects.insert(0, crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap()); + let alone = bounds(&three_d_wireframe_in(None, &n).unwrap()); + n.appearance.effects.insert(0, crate::new_effect(super::super::EXTRUDE, &json!({})).unwrap()); + let both = bounds(&three_d_wireframe_in(None, &n).unwrap()); + assert!((both.x0 - alone.x0).abs() < 1e-6 && (both.x1 - alone.x1).abs() < 1e-6, "{alone:?} {both:?}"); +} + #[test] fn an_object_without_a_visible_3d_effect_is_an_error() { let mut n = solid_square(super::super::EXTRUDE, json!({})); From 714d72971cdb3508ab40beba55952fc630cac06c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 09:16:50 -0800 Subject: [PATCH 7/9] 3D and Materials: the 3D area row lists Expand as Wireframes and its tests --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d4c411e30a..18c72f7d60 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -230,7 +230,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces; materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step` | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces, the Quick Actions › Expand as Wireframes command (#6354); materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step`; Expand as Wireframes is covered by `crates/effects/src/solids/wireframe/tests.rs` and `crates/engine/src/cmd/wireframe/tests.rs` (score, gaps and hours unchanged) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | From 556123e691bdf02748f270902c616c57481f05b0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 09:47:53 -0800 Subject: [PATCH 8/9] 3D and Materials: Expand as Wireframes keeps a layer's path guides and cuts Revolve's art for Revolve A layer expanded to its edges keeps the paths made into guides inside it, as it keeps its ruler guides. A clipping group with Revolve and Rotate is cut for Revolve, the effect the canvas draws, so its fills go in one piece and art with more fills than a 3D effect takes still expands. --- crates/effects/src/solids/wireframe.rs | 14 +++++++----- crates/effects/src/solids/wireframe/tests.rs | 21 +++++++++++++++++ crates/engine/src/cmd/wireframe.rs | 24 ++++++++++---------- crates/engine/src/cmd/wireframe/tests.rs | 23 +++++++++++++++++++ docs/mcp.md | 2 +- 5 files changed, 65 insertions(+), 19 deletions(-) diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index 4f93b2f41a..f1dc324679 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -10,7 +10,7 @@ use vectorcraft_three_d::Facet; use super::{ INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_plane, is_three_d, label_of, members, object_profile, - region, reshape_members, resolve, solid, solid_paints, solid_parts, union_bounds, view_options, + region, reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds, view_options, }; use crate::{GeomContext, is_geometry}; @@ -41,16 +41,18 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result Vec { "Expand as Wireframes", [], None, - "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected, and its ruler guides); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", + "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected, and its ruler guides and paths made into guides); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", has_doc, expand_as_wireframes )] @@ -38,12 +38,12 @@ fn wire_appearance() -> Appearance { Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Default::default() } } -/// The ruler guides of layer `n` and then those of the sublayers inside it, in order, added to -/// `out`. A guide from a hidden or locked sublayer, or from one inside a hidden or locked group or -/// sublayer, is hidden or locked itself. -fn ruler_guides(n: &Node, hidden: bool, locked: bool, out: &mut Vec>) { +/// The guides (ruler guides and paths made into guides) of layer `n` and then those of the groups +/// and sublayers inside it, in order, added to `out`. A guide from a hidden or locked sublayer, or +/// from one inside a hidden or locked group or sublayer, is hidden or locked itself. +fn guides(n: &Node, hidden: bool, locked: bool, out: &mut Vec>) { let Some(children) = n.children() else { return }; - for c in children.iter().filter(|c| c.is_ruler_guide()) { + for c in children.iter().filter(|c| c.is_guide()) { let mut g = c.clone(); if hidden || locked { let g = Arc::make_mut(&mut g); @@ -52,8 +52,8 @@ fn ruler_guides(n: &Node, hidden: bool, locked: bool, out: &mut Vec>) } out.push(g); } - for c in children.iter().filter(|c| !c.is_ruler_guide()) { - ruler_guides(c, hidden || !c.visible, locked || c.locked, out); + for c in children.iter().filter(|c| !c.is_guide()) { + guides(c, hidden || !c.visible, locked || c.locked, out); } } @@ -89,13 +89,13 @@ fn expand_as_wireframes(s: &mut Session, p: &Value) -> Result { let Some(source) = d.node(id) else { continue }; if matches!(source.kind, NodeKind::Layer { .. }) { // A layer stays a layer: its members become the edges, and its 3D effect is removed. - // Its ruler guides and those of its sublayers are not art: they stay, after the edges. + // Its guides and those of its sublayers are not art: they stay, after the edges. let mut layer = source.clone(); - let mut guides = vec![]; - ruler_guides(source, false, false, &mut guides); + let mut kept = vec![]; + guides(source, false, false, &mut kept); let mut children: Vec> = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect(); selected.extend(children.iter().map(|c| c.id)); - children.extend(guides); + children.extend(kept); if let Some(members) = layer.children_mut() { *members = children; } diff --git a/crates/engine/src/cmd/wireframe/tests.rs b/crates/engine/src/cmd/wireframe/tests.rs index 268fbcd6db..33a30975d9 100644 --- a/crates/engine/src/cmd/wireframe/tests.rs +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -112,6 +112,29 @@ fn a_layer_keeps_its_ruler_guides() { assert!(!st.selection.objects.is_empty() && st.selection.objects.iter().all(|id| !before.contains(id))); } +/// A path made into a guide is not art either: it stays on the layer, after the edges, and is not +/// selected. +#[test] +fn a_layer_keeps_its_path_guides() { + let (mut s, id) = setup(None); + let layer = s.doc().unwrap().doc.layers[0].id; + s.execute("path.create", &json!({"d":"M0 300L300 300"})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + let guides = |s: &Session| -> Vec { + s.doc().unwrap().doc.node(layer).unwrap().children().unwrap().iter().filter(|c| c.is_guide()).map(|c| c.id).collect() + }; + let before = guides(&s); + assert_eq!(before.len(), 1); + assert_ne!(before[0], id); + s.execute("effect.apply", &json!({"ids":[layer.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + run(&mut s, json!({"ids":[layer.0]})).unwrap(); + assert_eq!(guides(&s), before); + let st = s.doc().unwrap(); + let children = st.doc.node(layer).unwrap().children().unwrap(); + assert!(children.len() > 1 && children.last().is_some_and(|c| c.is_guide())); + assert!(!st.selection.objects.is_empty() && st.selection.objects.iter().all(|i| !before.contains(i))); +} + /// The number of segments in the wireframe group `id`. fn segments(s: &Session, id: NodeId) -> usize { let st = s.doc().unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index 7457e6faa3..7d344068bb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3237,7 +3237,7 @@ on the object or on a fill or stroke, reshape its edges as they reshape the draw black stroke with round joins and no fill (VectorCraft's own choice). The group keeps the object's place, id, name, visibility, lock, opacity, blending mode, knockout, opacity mask, attributes, slice and text wrap, and the groups are selected. -A layer in `ids` stays a layer: its members are replaced by the edges, which are selected, and its ruler guides stay. Objects without a visible 3D effect do not change. It is one undo step and +A layer in `ids` stays a layer: its members are replaced by the edges, which are selected, and its ruler guides and paths made into guides stay. Objects without a visible 3D effect do not change. It is one undo step and returns `{ids}`, the groups. It is an error, and nothing changes, when nothing is selected or given, when no object has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges. From eb80d45ec5ad9817be0f6105ad85bfb057ddffd9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 08:31:45 -0800 Subject: [PATCH 9/9] 3D and Materials: Expand as Wireframes takes a compound path member as its compound path A path inside a compound path given in ids was replaced by a group inside the compound path, which reads its members as paths, so the edges did not draw. The compound path is now expanded in its place. The panel's Quick Actions show on the Object tab of Revolve (Classic), not on its Rotation tab, as for the other effects with an Object tab. --- crates/engine/src/cmd/wireframe.rs | 6 ++++-- crates/engine/src/cmd/wireframe/tests.rs | 17 +++++++++++++++++ crates/ui-egui/src/panels/tests_three_d.rs | 16 ++++++++++++++++ crates/ui-egui/src/panels/three_d.rs | 4 ++-- docs/mcp.md | 2 +- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/wireframe.rs b/crates/engine/src/cmd/wireframe.rs index 595b837aa5..eeee004b07 100644 --- a/crates/engine/src/cmd/wireframe.rs +++ b/crates/engine/src/cmd/wireframe.rs @@ -25,7 +25,7 @@ pub fn specs() -> Vec { "Expand as Wireframes", [], None, - "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected, and its ruler guides and paths made into guides); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", + "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected, and its ruler guides and paths made into guides; a path inside a compound path in ids stands for the compound path); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", has_doc, expand_as_wireframes )] @@ -68,7 +68,9 @@ fn expand_as_wireframes(s: &mut Session, p: &Value) -> Result { let mut wires = vec![]; let mut seen = std::collections::HashSet::new(); for id in roots { - // An id given twice is expanded once. + // A path inside a compound path stands for the compound path, which draws it. An id given + // twice is expanded once. + let id = super::path::compound_or_self(d, id); if !seen.insert(id) { continue; } diff --git a/crates/engine/src/cmd/wireframe/tests.rs b/crates/engine/src/cmd/wireframe/tests.rs index 33a30975d9..7d9265bdbc 100644 --- a/crates/engine/src/cmd/wireframe/tests.rs +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -212,6 +212,23 @@ fn a_painted_effect_after_a_fill_s_3d_effect_finds_the_document_s_swatches() { } } +/// A path inside a compound path in `ids` stands for the compound path: the compound path becomes +/// the group, and no member is replaced by a group inside it. +#[test] +fn a_compound_path_member_in_ids_expands_the_compound_path() { + let (mut s, a) = setup(None); + let b = NodeId(s.execute("path.create", &json!({"d":"M250 100L350 100L350 200L250 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids":[a.0, b.0]})).unwrap(); + let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"ids":[c.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + let member = s.doc().unwrap().doc.node(c).unwrap().children().unwrap()[0].id; + let out = run(&mut s, json!({"ids":[member.0]})).unwrap(); + assert_eq!(out["ids"], json!([c.0])); + let st = s.doc().unwrap(); + let NodeKind::Group { children, clip: false } = &st.doc.node(c).unwrap().kind else { panic!("the compound path is a group") }; + assert!(!children.is_empty() && children.iter().all(|ch| ch.path_data().is_some())); +} + #[test] fn bad_ids_are_errors() { let (mut s, _) = setup(Some("threeD.extrude")); diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index e8bfede3fd..29751dd5d7 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -427,6 +427,22 @@ fn expand_as_wireframes_replaces_the_object_with_its_edges() { assert_eq!(undo_len(&app), history); } +/// The Quick Actions show on the Object tab of the effects that have one (Revolve (Classic) +/// included) and not on their Rotation tab; Revolve and Rotate show them on their first tab. +#[test] +fn quick_actions_show_on_the_object_tab_only() { + use crate::state::Dialog; + use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, EXTRUDE, INFLATE, REVOLVE}; + for effect in [EXTRUDE, INFLATE, CLASSIC_EXTRUDE, CLASSIC_REVOLVE] { + for (tab, shown) in [("object", true), ("rotation", false)] { + let d = Dialog::new("x", json!({"__effect": effect, "__tab": tab, "__gizmo": "rotation"})); + assert_eq!(super::quick_actions_shown(&d), shown, "{effect} {tab}"); + } + } + let d = Dialog::new("x", json!({"__effect": REVOLVE, "__tab": "rotation", "__gizmo": "rotation"})); + assert!(super::quick_actions_shown(&d)); +} + #[test] fn changed_settings_lists_only_new_values() { let before = json!({"depth": 50.0, "cap": true, "bevel": "none"}); diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs index 5d4763090e..c9f2f2205e 100644 --- a/crates/ui-egui/src/panels/three_d.rs +++ b/crates/ui-egui/src/panels/three_d.rs @@ -7,7 +7,7 @@ use egui::Ui; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Effect, Node}; -use vectorcraft_effects::{CLASSIC_EXTRUDE, EXTRUDE, INFLATE, THREE_D_TYPES, is_three_d}; +use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, EXTRUDE, INFLATE, THREE_D_TYPES, is_three_d}; use super::{live_run, pstate, set_pstate}; use crate::VectorcraftApp; @@ -261,7 +261,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { /// the first tab of the others (Revolve, Rotate), never on Lighting. fn quick_actions_shown(d: &Dialog) -> bool { let effect = d.str("__effect"); - let object_tab = [EXTRUDE, INFLATE, CLASSIC_EXTRUDE].contains(&effect.as_str()); + let object_tab = [EXTRUDE, INFLATE, CLASSIC_EXTRUDE, CLASSIC_REVOLVE].contains(&effect.as_str()); !crate::dialogs::revolve_gizmo::lighting(d) && (!object_tab || d.str("__tab") != "rotation") } diff --git a/docs/mcp.md b/docs/mcp.md index 7d344068bb..95de49525c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3237,7 +3237,7 @@ on the object or on a fill or stroke, reshape its edges as they reshape the draw black stroke with round joins and no fill (VectorCraft's own choice). The group keeps the object's place, id, name, visibility, lock, opacity, blending mode, knockout, opacity mask, attributes, slice and text wrap, and the groups are selected. -A layer in `ids` stays a layer: its members are replaced by the edges, which are selected, and its ruler guides and paths made into guides stay. Objects without a visible 3D effect do not change. It is one undo step and +A layer in `ids` stays a layer: its members are replaced by the edges, which are selected, and its ruler guides and paths made into guides stay. A path inside a compound path in `ids` stands for the compound path. Objects without a visible 3D effect do not change. It is one undo step and returns `{ids}`, the groups. It is an error, and nothing changes, when nothing is selected or given, when no object has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges.