diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 80cfef7b96..080098ad24 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -89,8 +89,8 @@ pub use revolve::{ pub use solids::{ CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, ROTATE, ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_classic_three_d, 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, three_d_meshes_in, 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 20a66e9a83..d82e4e8818 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -509,6 +509,60 @@ 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`'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. 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()) + }; + if is_container(n) { + 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, 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)?); + } + 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 && 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, 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 && 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, fx_options(fx))?; + out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..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 a49829f50c..9e7578d6d7 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..f1dc324679 --- /dev/null +++ b/crates/effects/src/solids/wireframe.rs @@ -0,0 +1,276 @@ +//! 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::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; + +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, resolve_for, solid, solid_paints, solid_parts, union_bounds, view_options, +}; +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; + +/// 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, 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 +/// 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()) { + // The builders' own error says why, when there is one. Revolve's art is cut for Revolve, + // as the canvas cuts it. + if crate::has_revolve(n) { + crate::revolve::revolve_face_paths(doc, &*resolve_for(doc, n, label, false)?)?; + } else { + solid_face_paths(doc, &*resolve(doc, n, label)?)?; + } + return Err(format!("{label} draws the source art for this object instead of its 3D surface")); + } + // Revolve's art is cut for Revolve whatever other 3D effect the object carries, as the canvas + // cuts it ([`crate::revolve_art_in`]). + let n = &*if revolve { resolve_for(doc, n, label, false)? } else { resolve(doc, n, label)? }; + let faces = if revolve { crate::revolve::revolve_face_paths(doc, n)? } else { solid_face_paths(doc, n)? }; + let faces = after_geometry(doc, n, faces, revolve); + 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 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 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) }) +} + +/// `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<&Effect> = later.iter().filter(|e| e.visible && is_geometry(&e.id)).collect(); + if after.is_empty() { + return faces; + } + 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 +/// 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. 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))?; + 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(), &GeomContext { doc, ..ctx })); + } + } + Ok(out) +} + +/// 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> { + 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 is_plane(&fx.id) { + 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..5ea4423ab3 --- /dev/null +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -0,0 +1,235 @@ +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); +} + +/// 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); +} + +/// 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() { + 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:?}"); + } +} + +/// 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:?}"); +} + +/// A clipping group with Revolve and Rotate draws Revolve, which cuts the art's fills as one piece +/// in the top fill's place. Its wireframe is cut the same way, so art with more fills than a 3D +/// effect takes members still expands, to the edges of Revolve alone. +#[test] +fn a_clipping_group_with_revolve_and_rotate_expands_the_art_revolve_cuts() { + let mut art = Node::path(NodeId(3), square(100.0, 100.0, 100.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let fill = art.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Fill(_))).cloned().unwrap(); + art.appearance.items = vec![fill; super::super::MAX_MEMBERS + 4]; + let mut clip = Node::path(NodeId(2), square(100.0, 100.0, 50.0), Appearance::default()); + if let vectorcraft_doc::NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let mut n = Node::new(NodeId(1), vectorcraft_doc::NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); + n.appearance.effects.push(crate::new_effect(crate::REVOLVE, &json!({})).unwrap()); + let alone = segments(&three_d_wireframe_in(None, &n).unwrap()); + n.appearance.effects.push(crate::new_effect(ROTATE, &json!({})).unwrap()); + assert!(crate::revolve_art_in(None, &n).is_some(), "the canvas draws Revolve"); + let both = segments(&three_d_wireframe_in(None, &n).unwrap()); + assert_eq!(both, alone); +} + +#[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 1aadd228a1..509279a3ae 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -106,6 +106,7 @@ mod variablecmds; pub(crate) mod views; pub mod wand; pub mod webexport; +mod wireframe; mod xform; use serde::Serialize; @@ -310,6 +311,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..eeee004b07 --- /dev/null +++ b/crates/engine/src/cmd/wireframe.rs @@ -0,0 +1,148 @@ +//! 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, 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 + )] +} + +/// 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() } +} + +/// 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_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_guide()) { + 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() { + 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![]; + let mut seen = std::collections::HashSet::new(); + for id in roots { + // 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; + } + 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. + // Its guides and those of its sublayers are not art: they stay, after the edges. + let mut layer = source.clone(); + 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(kept); + 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..7d9265bdbc --- /dev/null +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -0,0 +1,240 @@ +#![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.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(); + 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); + // 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(); + 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); +} + +/// 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))); +} + +/// 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(); + 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); +} + +/// 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}"); + } +} + +/// 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")); + 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..29751dd5d7 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -401,6 +401,48 @@ 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); +} + +/// 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 25dfaa6842..c9f2f2205e 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, CLASSIC_REVOLVE, 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, CLASSIC_REVOLVE].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 6b14fa0907..e219ce5e1b 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) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); 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 ab5733bf50..5a974d1085 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3420,6 +3420,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, 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, +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, 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. + +```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 2fe67a2d56..85c845ed56 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -234,7 +234,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 | @@ -568,6 +568,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 a79e6a4900..820bc1e805 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, 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 + 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.