From d56e241a43e76d501575ebce626b691a426fd3a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:30:45 -0800 Subject: [PATCH 01/23] 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 02/23] 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 e6f330afd23abc78514265cd078ab1cd384bb803 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 23:59:18 -0800 Subject: [PATCH 03/23] Automation: ui.dialog.set cannot change the command a dialog runs ui.dialog.set took every field whose name starts with `__`, so a control request (or MCP's answer_dialog, which goes through it) could rewrite the `__command` and `__params` of a confirmation or the generic parameter dialog, a plug-in command dialog's `__command`, or the paste a swatch conflict waits to finish, after a person opened the dialog. The person's OK then ran something else. A `__` field is now settable only when it is a control of that kind of dialog: a section or tab shown, a link between two fields, a name or search being typed (for example Save PDF's `__section`, Transform Each's `__constrain`, the Preferences `__search`). Every other `__` field, held or not, is refused with "the `` dialog cannot set protected field ``". The request sets no field, and with `action: "confirm"` OK does not run. Flags a button sets together with other fields are refused too: the style dialogs' `__reset*`, Artboard Options' `__fit`, `__sizeChoice`, `__refx` and `__refy`, and Scribble's `__scribbleSetting`. Recolor's Merge takes a row listed twice in the selection once, and Randomize keeps a seed at u64::MAX instead of overflowing it. Docs: docs/control-protocol.md, docs/mcp.md, docs/target-app-parity.md. --- crates/ui-egui/src/control.rs | 5 + crates/ui-egui/src/control/tests.rs | 8 +- crates/ui-egui/src/dialogs.rs | 69 +++++- .../src/dialogs/brush_options/tests.rs | 10 +- crates/ui-egui/src/dialogs/recolor.rs | 27 ++- crates/ui-egui/src/placing/tests.rs | 3 +- crates/ui-egui/src/tests/automation.rs | 204 +++++++++++++++++- docs/control-protocol.md | 2 +- docs/mcp.md | 2 +- docs/target-app-parity.md | 1 + 10 files changed, 312 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 11edb6de6e..1c09f918f9 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -18,6 +18,8 @@ //! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}` //! - `ui.dialog.set {field, value}` or `{fields: [{field, value}…]}` (all or none; setting a field //! the dialog does not read is an error that lists its fields: [`crate::dialogs::accepts_field`]; +//! a `__` field other than the controls of the dialog's kind is an error too, so a request cannot +//! change the command, targets or file the dialog's OK acts on: [`crate::dialogs::protected_field`]; //! `action: confirm | cancel` then confirms or cancels that dialog in the same request and replies //! `{dialog, result}`) / `ui.dialog.confirm` / `ui.dialog.cancel` //! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG @@ -603,6 +605,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques }; // Every field is checked before any is set, so a request sets all of them or none. if let Some((f, _)) = fields.iter().find(|(f, _)| !crate::dialogs::accepts_field(d, f)) { + if crate::dialogs::protected_field(d, f) { + return err(format!("the `{}` dialog cannot set protected field `{f}`", d.kind)); + } let names = crate::dialogs::field_names(d).join(", "); return err(format!("the `{}` dialog has no field `{f}`; its fields: {names}", d.kind)); } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 1763fd679a..d441946622 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -767,7 +767,8 @@ fn save_and_close_apply_a_merge_on_another_thread_first() { /// `ui.dialog.set` of a field the open dialog does not have is an error that lists its fields, /// and the dialog stays as it was. Fields a dialog may not hold yet are still set: `discard` -/// with a discard button, `preview` with a live preview, `__` fields and the spec's `inputs`. +/// with a discard button, `preview` with a live preview and the spec's `inputs`. A `__` field that +/// is not one of the controls of the dialog's kind is refused. #[test] fn dialog_set_refuses_a_field_the_dialog_does_not_have() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); @@ -776,8 +777,11 @@ fn dialog_set_refuses_a_field_the_dialog_does_not_have() { let r = input_request(&mut app, "ui.dialog.set", json!({"field": "discrad", "value": true})); assert_eq!(r["ok"], false, "{r}"); assert_eq!(r["error"], "the `saveChanges` dialog has no field `discrad`; its fields: discard, index, name, then, uid"); + assert_eq!(app.ui.dialog.as_ref().map(|d| Value::Object(d.fields.clone())), Some(fields.clone())); + let r = input_request(&mut app, "ui.dialog.set", json!({"field": "__note", "value": true})); + assert_eq!(r["error"], "the `saveChanges` dialog cannot set protected field `__note`", "{r}"); assert_eq!(app.ui.dialog.as_ref().map(|d| Value::Object(d.fields.clone())), Some(fields)); - for field in ["name", "discard", "__note"] { + for field in ["name", "discard"] { let r = input_request(&mut app, "ui.dialog.set", json!({"field": field, "value": true})); assert_eq!(r["ok"], true, "{field}: {r}"); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f9788d59a8..9274bbd40c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -481,13 +481,69 @@ fn spec(kind: &str) -> &'static DialogSpec { DialogKind::of(kind).map_or(&FALLBACK, DialogKind::spec) } -/// Whether `ui.dialog.set` can set `field` on `d`: a field it holds, a `__` field (the dialog's -/// own state), `discard` on a dialog with a discard button, `preview` on one with a live preview, -/// one of its spec's `inputs` for its kind, or one it `passes` on. +/// The `__` fields that `ui.dialog.set` can set on `d`: controls of its kind that the dialog holds +/// under a `__` name, each set as the control sets it. Every other `__` field of a dialog holds +/// what its OK acts on (the command and its fixed params, the targets, a file, the preset being +/// edited) or state the dialog keeps for itself, and `ui.dialog.set` refuses it +/// ([`protected_field`]), so a request cannot change what OK does after a person opened the dialog. +/// Flags that a button sets together with other fields are refused too: the style dialogs' +/// `__reset*` (Reset Panel also clears the section's fields, so scripts run +/// `ui.charStyleOptions.resetPanel` or `ui.paraStyleOptions.resetPanel`) and Artboard Options' +/// `__fit`, `__sizeChoice`, `__refx` and `__refy` (set with the size and position they record), and +/// Scribble's `__scribbleSetting` (the menu sets the preset's parameters with it). +fn hidden_controls(d: &Dialog) -> &'static [&'static str] { + match DialogKind::of(&d.kind) { + // Duotone's tone row, and the 3D effects' tab, gizmo, light and Show axis. + Some(DialogKind::Effect) => &["__gizmo", "__light", "__showAxis", "__tab", "__tone"], + // The preset name being typed and the format whose settings the gear shows. + Some(DialogKind::ExportForScreens) => &["__savePreset", "__settings"], + // Keywords typed but not added yet. + Some(DialogKind::FileInfo) => &["__keyword"], + // The preset name being typed. + Some(DialogKind::FlattenTransparency) => &["__presetName"], + // The Resolution menu's Other. + Some(DialogKind::RasterOptions | DialogKind::RasterFormatOptions | DialogKind::PsdOptions) => &["__otherPpi"], + // The category shown, the search text and the three settings kept outside the preferences. + Some(DialogKind::Preferences) => &["__category", "__search", "__smartGuides", "__snapToGrid", "__taskBar"], + // The section shown, the sheet the preview shows and the preset name being typed. + Some(DialogKind::Print | DialogKind::PrintPreset) => &["__savePresetAs", "__section", "__sheet"], + // The section shown, All or a range of artboards, the two password checkboxes and the + // preset name being typed. + Some(DialogKind::SavePdf | DialogKind::PdfPreset) => { + &["__allArtboards", "__requireOpenPassword", "__restrictPermissions", "__savePresetAs", "__section"] + } + // The selected rows and Randomize's seed. + Some(DialogKind::Recolor) => &["__seed", "__sel"], + // The view, zoom, download speed, selected color table entry and the preset name being typed. + Some(DialogKind::SaveForWeb) => &["__color", "__kbps", "__presetName", "__view", "__zoom"], + // The link that keeps width and height in proportion. + Some(DialogKind::Shape | DialogKind::TransformEach) => &["__constrain"], + // The attention filter, the Keys section, the search text and the key being recorded. + Some(DialogKind::ShortcutImport) => &["__attention", "__keysOpen", "__query", "__recording"], + // Set Size of Each's Width and Height checkboxes. + Some(DialogKind::ArrangeTools) if d.kind == crate::addons::arrange::SIZE => &["__height", "__width"], + // The search field. + Some(DialogKind::SelectByArtboards | DialogKind::SelectByLinkStatus) => &["__search"], + _ => &[], + } +} + +/// Whether `field` is a `__` field of `d` that `ui.dialog.set` refuses: any `__` field, held or +/// not, except the controls of `d`'s kind ([`hidden_controls`]). +pub fn protected_field(d: &Dialog, field: &str) -> bool { + field.starts_with("__") && !hidden_controls(d).contains(&field) +} + +/// Whether `ui.dialog.set` can set `field` on `d`: a `__` field only when it is one of the controls +/// of `d`'s kind ([`protected_field`]); otherwise a field it holds, `discard` on a dialog with a +/// discard button, `preview` on one with a live preview, one of its spec's `inputs` for its kind, +/// or one it `passes` on. pub fn accepts_field(d: &Dialog, field: &str) -> bool { + if field.starts_with("__") { + return !protected_field(d, field); + } let spec = spec(&d.kind); d.fields.contains_key(field) - || field.starts_with("__") || (field == "discard" && spec.discard.is_some()) || (field == "preview" && spec.preview) || (spec.inputs)(&d.kind).contains(&field) @@ -520,11 +576,12 @@ pub fn set_field(d: &mut Dialog, field: String, value: Value) { } } -/// The fields `ui.dialog.set` can set on `d`, sorted, without the `__` ones: those it holds, -/// `discard`, `preview` and the spec's `inputs` for its kind. +/// The fields `ui.dialog.set` can set on `d`, sorted: those it holds other than its `__` ones, the +/// `__` controls of its kind, `discard`, `preview` and the spec's `inputs` for its kind. pub fn field_names(d: &Dialog) -> Vec { let spec = spec(&d.kind); let mut names: Vec = d.fields.keys().filter(|k| !k.starts_with("__")).cloned().collect(); + names.extend(hidden_controls(d).iter().map(|f| f.to_string())); names.extend(spec.discard.map(|_| "discard".to_string())); names.extend(spec.preview.then(|| "preview".to_string())); names.extend((spec.inputs)(&d.kind).iter().map(|i| i.to_string())); diff --git a/crates/ui-egui/src/dialogs/brush_options/tests.rs b/crates/ui-egui/src/dialogs/brush_options/tests.rs index 3ae028bb05..f2fcc88981 100644 --- a/crates/ui-egui/src/dialogs/brush_options/tests.rs +++ b/crates/ui-egui/src/dialogs/brush_options/tests.rs @@ -436,13 +436,17 @@ fn each_tips_sample_paints_the_brush_with_its_method() { assert!(tints != hue && tints != shades && none != shades, "four different samples"); } -/// `ui.dialog.set` writes any field, `__def` included. With a brush definition that is not -/// an object, Brush Options and Colorization Tips still draw. +/// With a brush definition that is not an object, Brush Options and Colorization Tips still draw. +/// `ui.dialog.set` refuses `__def`, which holds the brush OK saves. #[test] fn a_brush_definition_that_is_not_an_object_still_draws() { let (mut app, ctx) = (app(), ctx()); app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); - crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "__def", "value": 5})); + let (req, _) = crate::control::ControlRequest::new("ui.dialog.set", json!({"field": "__def", "value": 5})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") }; + assert_eq!(r["error"], "the `brushOptions` dialog cannot set protected field `__def`", "{r}"); + assert_ne!(app.ui.dialog.as_ref().unwrap().fields.get("__def"), Some(&json!(5)), "the request is refused"); + set(&mut app, json!({"__def": 5})); crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "tips", "value": true})); let text = shown(&mut app); assert!(text.contains("Scatter Brush Options") && text.contains("Colorization Tips"), "{text}"); diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 6f696f4302..784a19007b 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -262,6 +262,11 @@ fn excluded(row: &Value) -> bool { row["exclude"].as_bool().unwrap_or(false) } +/// The seed for the next Randomize: one past the last, held at `u64::MAX`. +fn next_seed(d: &Dialog) -> u64 { + (d.f64("__seed", 0.0) as u64).saturating_add(1) +} + fn selection(d: &Dialog) -> Vec { d.fields.get("__sel").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default() } @@ -546,7 +551,7 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [("arrow-left-right", tl!("Randomly change color order"), false), ("sparkles", tl!("Randomly change saturation and brightness"), true)] { if widgets::icon_button_enabled(ui, icon, tip, false, rows.len() > 1 || sb, 24.0).clicked() { - let seed = d.f64("__seed", 0.0) as u64 + 1; + let seed = next_seed(d); d.fields.insert("__seed".into(), json!(seed)); let p = json!({"map": rows, "order": !sb, "saturationBrightness": sb, "seed": seed}); match app.session.execute("recolor.randomize", &p) { @@ -897,6 +902,7 @@ fn sliders(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value] fn merge(rows: &mut Vec, sel: &[usize]) { let mut sel: Vec = sel.iter().copied().filter(|i| *i < rows.len()).collect(); sel.sort_unstable(); + sel.dedup(); let Some((&first, rest)) = sel.split_first() else { return }; let mut moved = vec![]; for &i in rest.iter().rev() { @@ -1208,6 +1214,25 @@ mod tests { assert_eq!(r[2]["to"], "rgb 0 0 128"); } + /// A selection that lists a row twice merges it once, as `ui.dialog.set` can store one. + #[test] + fn merge_takes_a_repeated_row_once() { + let mut r = rows(); + merge(&mut r, &[0, 2, 2]); + assert_eq!(r.len(), 2); + assert_eq!(r[0]["from"], json!(["rgb 255 0 0", "rgb 0 255 0"])); + merge(&mut r, &[1, 1]); + assert_eq!(r.len(), 2); + } + + /// Randomize after a seed past `u64::MAX` keeps the largest seed. + #[test] + fn next_seed_holds_at_the_largest() { + assert_eq!(next_seed(&Dialog::new("recolor", json!({}))), 1); + assert_eq!(next_seed(&Dialog::new("recolor", json!({"__seed": 4}))), 5); + assert_eq!(next_seed(&Dialog::new("recolor", json!({"__seed": 1e30}))), u64::MAX); + } + #[test] fn linked_handles_move_together() { let mut r = rows(); diff --git a/crates/ui-egui/src/placing/tests.rs b/crates/ui-egui/src/placing/tests.rs index 96492a6512..9afe94b9ee 100644 --- a/crates/ui-egui/src/placing/tests.rs +++ b/crates/ui-egui/src/placing/tests.rs @@ -587,7 +587,8 @@ fn a_deferred_swatch_paste_rejects_a_changed_token_or_closed_document() { &crate::control::ControlRequest::new("ui.dialog.set", json!({"field": "__deferredPaste", "value": 999999})).0, ); let crate::control::Outcome::Done(result) = result else { panic!("expected dialog result") }; - assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["error"], "the `swatchConflict` dialog cannot set protected field `__deferredPaste`", "{result}"); + app.ui.dialog.as_mut().unwrap().fields.insert("__deferredPaste".into(), json!(999999)); } let error = crate::dialogs::confirm(&mut app).unwrap_err(); assert!( diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index df2fe29912..d2d0a8c4b6 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -10,6 +10,7 @@ use vectorcraft_engine::file_access::AutomationRoots; use crate::background::SharedWriteFn; use crate::control::ControlRequest; +use crate::state::Dialog; use crate::{Services, VectorcraftApp}; /// A folder holding `in` (the read root, with `a.svg`), `out` (the write root) and `outside` @@ -658,12 +659,12 @@ fn automation_cannot_approve_a_plugin_install() { assert!(app.ui.dialog.is_some(), "the refused answer leaves it open"); assert!(vectorcraft_plugins::registry::get(id).is_none()); - // What the dialog installs isn't in its fields. + // What the dialog installs isn't in its fields, and its `__` fields can't be set. let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__params", "value": {"dataBase64": "AAAA"}})); - assert!(r.get("error").is_none(), "{r}"); + refused(&r, "cannot set protected field `__params`"); // What MCP's `answer_dialog` sends: `ui.dialog.set {fields}`, then `ui.dialog.confirm`. let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": "AAAA"}}]})); - assert!(r.get("error").is_none(), "{r}"); + refused(&r, "cannot set protected field `__params`"); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); refused(&r, ASKED); assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none()); @@ -691,6 +692,200 @@ fn automation_cannot_approve_a_plugin_install() { app.run("plugin.remove", json!({"id": id})).unwrap(); } +/// An app with a control channel and a new document. +fn controlled() -> (VectorcraftApp, std::sync::mpsc::Sender) { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + (app, tx) +} + +/// The fields of the open dialog. +fn dialog_fields(app: &VectorcraftApp) -> Option> { + app.ui.dialog.as_ref().map(|d| d.fields.clone()) +} + +/// The objects on the document's first layer. +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len()) +} + +/// The left edge and width of the first object on the document's first layer. +fn first_object(app: &VectorcraftApp) -> (f64, f64) { + let b = app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + (b.x0, b.width()) +} + +/// Each of `sets` sent as `ui.dialog.set` is refused with the protected-field error of the +/// `kind` dialog, sets no field, and runs nothing. +fn refused_sets(app: &mut VectorcraftApp, tx: &std::sync::mpsc::Sender, kind: &str, sets: &[Value]) { + for set in sets { + let before = dialog_fields(app); + let r = request(app, tx, "ui.dialog.set", set.clone()); + let says = format!("the `{kind}` dialog cannot set protected field `__"); + assert!(r["error"].as_str().is_some_and(|e| e.starts_with(&says)), "{set}: {r}"); + assert_eq!(dialog_fields(app), before, "{set}: no field changes and the dialog stays open"); + assert_eq!(objects(app), 0, "{set}: nothing ran"); + } +} + +/// A confirmation a person opened runs the command it was opened with: `ui.dialog.set` can't +/// change its `__command` or `__params`, alone or in a list with other fields and `action: +/// "confirm"`, and OK then runs the command it asked about. +#[test] +fn dialog_set_cannot_redirect_person_opened_confirm() { + let (mut app, tx) = controlled(); + let rect = json!({"x": 10, "y": 10, "width": 20, "height": 20}); + crate::dialogs::confirm::ask(&mut app, "Add a rectangle?", "", "shape.rectangle", rect); + let other = json!({"x": 0, "y": 0, "width": 90, "height": 90}); + refused_sets( + &mut app, + &tx, + crate::dialogs::confirm::KIND, + &[ + json!({"field": "__command", "value": "file.close"}), + json!({"field": "__params", "value": other}), + json!({"fields": [{"field": "detail", "value": "Sure?"}, {"field": "__command", "value": "file.close"}], "action": "confirm"}), + json!({"fields": [{"field": "__params", "value": other}, {"field": "detail", "value": "Sure?"}], "action": "confirm"}), + ], + ); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "detail", "value": "Sure?"}], "action": "confirm"})); + assert_eq!(r["ok"], true, "{r}"); + assert!(app.ui.dialog.is_none() && objects(&app) == 1, "OK ran the command it asked about"); + assert_eq!(first_object(&app), (10.0, 20.0)); +} + +/// The generic parameter dialog runs its command with its fixed parameters: `ui.dialog.set` can +/// change the parameters it shows, not `__command`, `__params` or `__label`. +#[test] +fn dialog_set_cannot_redirect_parameter_dialog() { + let (mut app, tx) = controlled(); + let fields = json!({"__command": "shape.rectangle", "__label": "Rectangle", "__params": {"x": 10, "y": 10}, "width": 20, "height": 20}); + app.ui.dialog = Some(Dialog::new("command", fields)); + refused_sets( + &mut app, + &tx, + "command", + &[ + json!({"field": "__command", "value": "file.close"}), + json!({"field": "__params", "value": {"x": 500, "y": 500}}), + json!({"field": "__label", "value": "Close"}), + json!({"fields": [{"field": "width", "value": 30}, {"field": "__command", "value": "file.close"}], "action": "confirm"}), + ], + ); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "width", "value": 30, "action": "confirm"})); + assert_eq!(r["ok"], true, "{r}"); + assert!(app.ui.dialog.is_none() && objects(&app) == 1, "OK ran the dialog's command"); + assert_eq!(first_object(&app), (10.0, 30.0), "with its fixed x and the width set"); +} + +/// A plug-in command's dialog runs that command of that plug-in: `ui.dialog.set` can't change its +/// `__command` or `__plugin`. +#[test] +fn dialog_set_cannot_redirect_plugin_command() { + let (mut app, tx) = controlled(); + let fields = json!({"__plugin": "test.ui.redirect", "__command": "plugin.test.ui.redirect.a", "__label": "A", "amount": 1}); + app.ui.dialog = Some(Dialog::new(crate::dialogs::plugin::KIND, fields)); + refused_sets( + &mut app, + &tx, + crate::dialogs::plugin::KIND, + &[ + json!({"field": "__command", "value": "plugin.test.ui.redirect.b"}), + json!({"field": "__plugin", "value": "test.ui.other"}), + json!({"fields": [{"field": "amount", "value": 2}, {"field": "__command", "value": ""}], "action": "confirm"}), + ], + ); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "amount", "value": 2})); + assert_eq!(r["ok"], true, "{r}"); +} + +/// A `__` field the dialog does not hold is refused as a protected field too, and so is a control +/// of another kind of dialog; the error for an unknown field lists the dialog's own controls only. +#[test] +fn dialog_set_rejects_unknown_hidden_field() { + let (mut app, tx) = controlled(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + for field in ["__anything", "__section", "__ratio", "__constrainApplied"] { + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": field, "value": true})); + assert_eq!(r["error"], format!("the `transformEach` dialog cannot set protected field `{field}`"), "{r}"); + assert!(!dialog_fields(&app).unwrap().contains_key("__anything")); + } + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "nosuch", "value": true})); + let error = r["error"].as_str().unwrap_or_default(); + assert!(error.contains("has no field `nosuch`") && error.contains("__constrain"), "{r}"); + assert!(!error.contains("__ratio") && !error.contains("__section"), "{r}"); + + // Artboard Options' Fit, size preset and reference point flags are set by their buttons + // together with the size and position they record. + app.ui.dialog = Some(Dialog::new("artboardOptions", json!({"width": 60, "height": 20}))); + for (field, value) in [("__fit", json!(true)), ("__sizeChoice", json!({"landscape": true})), ("__refx", json!(2)), ("__refy", json!(2))] { + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": field, "value": value})); + assert_eq!(r["error"], format!("the `artboardOptions` dialog cannot set protected field `{field}`"), "{r}"); + assert!(!dialog_fields(&app).unwrap().contains_key(field)); + } + // Scribble's Settings menu sets the preset's parameters along with its name. + app.ui.dialog = Some(Dialog::new("effect", json!({"__effect": "scribble", "angle": 30}))); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__scribbleSetting", "value": "sharp"})); + assert_eq!(r["error"], "the `effect` dialog cannot set protected field `__scribbleSetting`", "{r}"); +} + +/// The dialog controls held under a `__` name are still set: Transform Each's link, the Save PDF +/// section and its All artboards checkbox, and the Preferences search. +#[test] +fn dialog_set_allows_hidden_controls() { + let (mut app, tx) = controlled(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__constrain", "value": true})); + assert_eq!(r["ok"], true, "{r}"); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.bool("__constrain"))); + app.ui.dialog = None; + + app.run("ui.savePdfDialog", json!({})).unwrap(); + let set = json!({"fields": [{"field": "__section", "value": "Security"}, {"field": "__allArtboards", "value": false}]}); + let r = request(&mut app, &tx, "ui.dialog.set", set); + assert_eq!(r["ok"], true, "{r}"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("__section"), d.bool("__allArtboards")), ("Security".to_string(), false)); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__save", "value": "file.saveCopy"})); + assert_eq!(r["error"], "the `savePdf` dialog cannot set protected field `__save`", "{r}"); + app.ui.dialog = None; + + // The raster options dialogs share the Resolution menu's Other, PSD Options included. + for kind in ["pngOptions", "tiffOptions", "psdOptions"] { + app.ui.dialog = Some(Dialog::new(kind, json!({"ppi": 72}))); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__otherPpi", "value": true})); + assert_eq!(r["ok"], true, "{kind}: {r}"); + } + app.ui.dialog = None; + + // A style section's reset flag alone would keep the section's values: scripts run the Reset + // Panel commands, which clear them. Which of the two styles a new style is stays fixed. + for (kind, para, flag) in [ + ("charStyleOptions", false, "__resetColor"), + ("paraStyleOptions", true, "__resetHyphenation"), + ("newTextStyle", false, "__resetColor"), + ("newTextStyle", true, "__resetHyphenation"), + ] { + app.ui.dialog = Some(Dialog::new(kind, json!({"name": "A", "__para": para}))); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": flag, "value": true})); + assert_eq!(r["error"], format!("the `{kind}` dialog cannot set protected field `{flag}`"), "{r}"); + assert!(!app.ui.dialog.as_ref().is_some_and(|d| d.bool(flag)), "{kind}"); + } + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__para", "value": false})); + assert_eq!(r["error"], "the `newTextStyle` dialog cannot set protected field `__para`", "{r}"); + app.ui.dialog = None; + + crate::prefs_dialog::open(&mut app, None); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__search", "value": "grid"})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("__search")), Some("grid".to_string())); +} + /// The refusal of an OK on an install question automation asked. const ASKED: &str = "Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself."; @@ -934,7 +1129,8 @@ fn automation_cannot_approve_replacing_a_plugin() { assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND})); let evil = vectorcraft_format::base64_encode(&plain(r#"{"objects":[{"name":"x"}]}"#)); let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": evil, "replace": true}}]})); - assert!(r.get("error").is_none(), "{r}"); + let protected = format!("the `{}` dialog cannot set protected field `__params`", crate::dialogs::plugin::INSTALL_KIND); + assert_eq!(r["error"], protected, "{r}"); super::plugins::show_approval(&mut app); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); assert!(r["error"].as_str().is_some_and(|e| e.contains("Only the person at the keyboard")), "{r}"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 640f1e805a..0fdfbc8231 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -102,7 +102,7 @@ the two can still win that race. | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | | `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls, Cmd-wheel (Ctrl on Windows and Linux) scrolls sideways and Alt-wheel (Option on the Mac) zooms about the pointer (with the `ctrlWheelScrollsSideways` preference off, Cmd-wheel zooms too); with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it. Over the canvas, the wheel preferences in `docs/mcp.md` change what these keys do | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | -| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | +| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`) other than its `__` fields, the `__` fields that are controls of the dialog (such as `__section` and `__allArtboards` in Save PDF, `__constrain` in Transform Each and the shape dialogs, and `__search` and `__category` in Preferences), `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was. Any other `__` field, held or not, holds what the dialog's OK acts on (such as the `__command` and `__params` of a `confirm` or `command` dialog) or the dialog's own state: setting it is the error "the `` dialog cannot set protected field ``", and the dialog stays as it was. `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | diff --git a/docs/mcp.md b/docs/mcp.md index aeed51157a..24d264cd38 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -509,7 +509,7 @@ applied colors still join the list while it is hidden. | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. | | `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee; `c` and `f` while dragging out an arc; `f`, `v`, `x` and `c` while dragging out a rectangular or polar grid). | | `type_text` | `{text}` | Remote only. | -| `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields, action?}` (a missing `value` is `null`). The app stores all of them or none, then answers that same dialog with `action` in the same request: `confirm` (OK) or `cancel`. Another connection cannot open a dialog between the two steps. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. `action` alone is sent as `ui.dialog.confirm` or `ui.dialog.cancel`. Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. | +| `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields, action?}` (a missing `value` is `null`). The app stores all of them or none, then answers that same dialog with `action` in the same request: `confirm` (OK) or `cancel`. Another connection cannot open a dialog between the two steps. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. `set` cannot change a `__` field other than the dialog's controls held under a `__` name (such as Save PDF's `__section` or the Preferences `__search`): the `__command` and `__params` of a `confirm` or `command` dialog, and every other `__` field that holds what OK acts on, are refused with "the `` dialog cannot set protected field ``", so OK runs what the dialog was opened for. `action` alone is sent as `ui.dialog.confirm` or `ui.dialog.cancel`. Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. | | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. UI commands such as `view.*` and `window.*` are in `command_list` with `ui: true`; running them needs the desktop app (`--connect`). | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5e0cf03a3a..660051393d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1703,6 +1703,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. The objects come in paint order from the bottom, and an image that reaches the decoder counts toward the 16 MiB of pixels even when decoding fails. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged. +- **A dialog's OK runs what it was opened for:** `ui.dialog.set`, and MCP's `answer_dialog` through it, can set a `__` field only when it is a control of that kind of dialog (a section or tab shown, a link between two fields, a name or search being typed). Every other `__` field, such as the `__command` and `__params` of a confirmation or the generic parameter dialog, a plug-in command dialog's `__command` and `__plugin`, or the paste a swatch conflict waits to finish, is refused with "the `` dialog cannot set protected field ``"; the request sets no field, and with `action: "confirm"` OK does not run. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/control/tests.rs`. Scores and hours unchanged. - **Only an approval records a plug-in grant (G17):** `vectorcraft_plugins::registry::grant` takes a `GrantToken`, which the engine makes after an approval and when it loads `grants.json`, and `registry::install`, `install_bytes` and folder loads refuse a module that lists permissions no recorded grant gives this very module (`Error::NeedsApproval`), leaving the plug-in under its id and the revision as they were. A module without `permissions`, or with an empty list, installs as before. `docs/plugins.md` says what the approval does not protect against (native code linked into the app, another program running as the same user, the unsigned `grants.json`). Regressions: `crates/plugins/src/registry.rs`, `crates/plugins/tests/grant_sites.rs`, `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval waits before OK (G17):** the approval dialog's OK stays disabled until its question has been on screen in the focused window for half a second, VectorCraft's own value; a click, Enter, Space, or a press begun before then installs nothing, and the dialog stays open. The wait starts over for every new question, even one that reads the same, and when the window gets the focus back, so a question swapped in just before a click is not approved by it. Cancel works at once. Regressions: `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **An install automation asked for needs the person to start it (G17):** an approval question that a control request, MCP, injected input or a file dialog one of them opened leads to keeps OK disabled and refuses every confirm, a person's click included; it says to choose Object › Plug-ins › Install Plug-in…, and `ui.installPlugin` answers `approval: "personMustStartInstall"`. The person's own Install Plug-in…, File › Open or drop inspects the module again and asks a new question. Automation's `replace: true` no longer skips the question about taking another module's place, and that question now opens in the approval dialog for everyone. VectorCraft's own rule. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. From 18fe6ad6fd33eb55f024a30f076ab24339873864 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:37:35 -0800 Subject: [PATCH 04/23] Automation: ui.dialog.set cannot open Save Preset in Save PDF or Print `__savePresetAs` makes OK save a preset instead of writing the PDF or printing. ui.dialog.set now accepts it only while Save Preset... has added the field, so a request cannot add it and confirm in one step. --- crates/ui-egui/src/dialogs.rs | 17 +++++++++++--- crates/ui-egui/src/tests/automation.rs | 31 ++++++++++++++++++++++++++ docs/control-protocol.md | 2 +- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9274bbd40c..838fc8161d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -528,10 +528,21 @@ fn hidden_controls(d: &Dialog) -> &'static [&'static str] { } } +/// The controls of [`hidden_controls`] that a button adds to the dialog, which `ui.dialog.set` can +/// set only while the dialog holds them: Save PDF's and Print's `__savePresetAs`, whose presence +/// makes OK save a preset instead of writing the PDF or printing. +const ADDED_CONTROLS: &[&str] = &["__savePresetAs"]; + +/// Whether `field` is a control of `d` that `ui.dialog.set` can set: one of the controls of `d`'s +/// kind ([`hidden_controls`]), and for one that a button adds ([`ADDED_CONTROLS`]), one `d` holds. +fn settable_control(d: &Dialog, field: &str) -> bool { + hidden_controls(d).contains(&field) && (!ADDED_CONTROLS.contains(&field) || d.fields.contains_key(field)) +} + /// Whether `field` is a `__` field of `d` that `ui.dialog.set` refuses: any `__` field, held or -/// not, except the controls of `d`'s kind ([`hidden_controls`]). +/// not, except the controls of `d`'s kind ([`settable_control`]). pub fn protected_field(d: &Dialog, field: &str) -> bool { - field.starts_with("__") && !hidden_controls(d).contains(&field) + field.starts_with("__") && !settable_control(d, field) } /// Whether `ui.dialog.set` can set `field` on `d`: a `__` field only when it is one of the controls @@ -581,7 +592,7 @@ pub fn set_field(d: &mut Dialog, field: String, value: Value) { pub fn field_names(d: &Dialog) -> Vec { let spec = spec(&d.kind); let mut names: Vec = d.fields.keys().filter(|k| !k.starts_with("__")).cloned().collect(); - names.extend(hidden_controls(d).iter().map(|f| f.to_string())); + names.extend(hidden_controls(d).iter().filter(|f| settable_control(d, f)).map(|f| f.to_string())); names.extend(spec.discard.map(|_| "discard".to_string())); names.extend(spec.preview.then(|| "preview".to_string())); names.extend((spec.inputs)(&d.kind).iter().map(|i| i.to_string())); diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index d2d0a8c4b6..36908ec3e6 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -886,6 +886,37 @@ fn dialog_set_allows_hidden_controls() { assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("__search")), Some("grid".to_string())); } +/// Save PDF's and Print's OK save a preset while Save Preset… asks for its name: `ui.dialog.set` +/// can't add `__savePresetAs` to turn OK into saving a preset, alone or with `action: "confirm"`, +/// and can type the name once Save Preset… has added the field. +#[test] +fn dialog_set_cannot_open_save_preset() { + let (mut app, tx) = controlled(); + let saved = |app: &VectorcraftApp| (app.session.prefs.pdf_presets.len(), app.session.prefs.print_presets.len()); + for (open, kind) in [("ui.savePdfDialog", "savePdf"), ("file.print", "print")] { + app.run(open, json!({})).unwrap(); + let presets = saved(&app); + for set in [ + json!({"field": "__savePresetAs", "value": "Proofs"}), + json!({"fields": [{"field": "__savePresetAs", "value": "Proofs"}], "action": "confirm"}), + ] { + let before = dialog_fields(&app); + let r = request(&mut app, &tx, "ui.dialog.set", set.clone()); + assert_eq!(r["error"], format!("the `{kind}` dialog cannot set protected field `__savePresetAs`"), "{set}: {r}"); + assert_eq!(dialog_fields(&app), before, "{set}: no field changes and the dialog stays open"); + } + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "nosuch", "value": true})); + assert!(!r["error"].as_str().unwrap_or_default().contains("__savePresetAs"), "{kind}: {r}"); + assert_eq!(saved(&app), presets, "{kind}: no preset saved"); + // Save Preset… adds the field; the name can then be typed and OK saves the preset. + app.ui.dialog.as_mut().unwrap().fields.insert("__savePresetAs".into(), json!("")); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__savePresetAs", "value": "Proofs", "action": "confirm"})); + assert_eq!(r["result"]["result"]["name"], "Proofs", "{kind}: {r}"); + app.ui.dialog = None; + } + assert_eq!(saved(&app), (1, 1)); +} + /// The refusal of an OK on an install question automation asked. const ASKED: &str = "Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself."; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0fdfbc8231..53ce5d35f9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -102,7 +102,7 @@ the two can still win that race. | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | | `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls, Cmd-wheel (Ctrl on Windows and Linux) scrolls sideways and Alt-wheel (Option on the Mac) zooms about the pointer (with the `ctrlWheelScrollsSideways` preference off, Cmd-wheel zooms too); with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it. Over the canvas, the wheel preferences in `docs/mcp.md` change what these keys do | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | -| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`) other than its `__` fields, the `__` fields that are controls of the dialog (such as `__section` and `__allArtboards` in Save PDF, `__constrain` in Transform Each and the shape dialogs, and `__search` and `__category` in Preferences), `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was. Any other `__` field, held or not, holds what the dialog's OK acts on (such as the `__command` and `__params` of a `confirm` or `command` dialog) or the dialog's own state: setting it is the error "the `` dialog cannot set protected field ``", and the dialog stays as it was. `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | +| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`) other than its `__` fields, the `__` fields that are controls of the dialog (such as `__section` and `__allArtboards` in Save PDF, `__constrain` in Transform Each and the shape dialogs, and `__search` and `__category` in Preferences; Save PDF's and Print's `__savePresetAs`, the preset name, only while Save Preset… asks for it, since OK then saves a preset), `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was. Any other `__` field, held or not, holds what the dialog's OK acts on (such as the `__command` and `__params` of a `confirm` or `command` dialog) or the dialog's own state: setting it is the error "the `` dialog cannot set protected field ``", and the dialog stays as it was. `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | From 6ccb7c907756400aa6c360edaa350834716ecac7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:36:03 -0800 Subject: [PATCH 05/23] 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 06/23] 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 07/23] 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 08/23] 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 45dd24d905710ecc7c8664c158f8c78a764ac1e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 08:10:15 -0800 Subject: [PATCH 09/23] .ai save: a stroke the object's art style keeps no longer warns A path with an effect, or a path linked to a graphic style, can have a solid stroke a plain line can't draw, such as one with a width profile. When the reader reads the path back from an art style, the path is written with that style, and the style holds the stroke. The path's drawn look still writes the stroke as a filled outline, and the save warned that such strokes are written as filled outlines. The warning is now left out while a drawn look is written, as the effects warning already is. A styled group's style holds no fills or strokes, and its members are written again after the look, so a member's stroke still warns. Strokes that no art style holds (gradient or pattern strokes, for example) still warn. --- crates/eps/src/ai_editing.rs | 2 +- crates/eps/src/ai_editing/tests.rs | 64 +++++++++++++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 10 ++++- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 3ca8a66b5f..5ab99d2378 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -162,7 +162,7 @@ //! symbols past 4,096 as their art (symbol art past [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects //! in all left out), instances of symbols the document doesn't have as empty groups, symbols' 9-slice //! guides, export types and dynamic type left out, live objects (placed documents included) as their art, -//! strokes a plain line can't draw +//! strokes a plain line can't draw and the object's art style doesn't hold //! as their filled outlines, several fills or strokes as a group of a path for each, freeform //! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as //! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 1ab878fc62..e9cc0ba669 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -6629,6 +6629,70 @@ fn every_effect_setting_written_on_an_object_is_read_back() { assert!(matches!(&spot.appearance.fill().unwrap().paint, Paint::Solid { swatch: Some(s), .. } if s == "Ink"), "{spot:?}"); } +/// A drawn look's strokes warn only where its art style leaves them out: a path with a drop shadow and +/// a stroke with a width profile is written with a style that holds the profile, and doesn't warn; +/// a group with a drop shadow whose member has a stroke inside the path still warns. +#[test] +fn a_drawn_looks_strokes_warn_only_where_its_style_leaves_them_out() { + let profile = vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] }; + let (n, warnings, p) = read_one(|doc| { + let mut stroke = StrokeLayer::new(black_fill(), 4.0); + stroke.profile = Some(profile.clone()); + let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], effects: vec![drop_shadow()], contents_index: None }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }); + assert!(p.contains("%AI9_BeginArtStyles"), "{p}"); + assert!(!warnings.iter().any(|w| w == writer::STROKES), "{warnings:?}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.profile.clone()), Some(profile)); + let (_, warnings, p) = read_one(|doc| { + let mut n = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), Appearance::basic(Paint::None, black_fill(), 2.0)); + n.appearance.stroke_mut().unwrap().align = vectorcraft_doc::StrokeAlign::Inside; + let mut g = group(doc, vec![n], false); + g.appearance.effects = vec![drop_shadow()]; + g + }); + assert!(p.contains("%AI9_BeginArtStyles"), "{p}"); + assert!(warnings.iter().any(|w| w == writer::STROKES), "{warnings:?}"); +} + +/// A path linked to a graphic style with a drop shadow and a stroke with a width profile is written +/// with that style, which holds the profile, and doesn't warn; it reads back linked, with its +/// profile. A linked path whose fill is half opaque in Screen mode isn't written with its style, which +/// the reader reads on no object, and warns that the fill's transparency is left out. +#[test] +fn a_linked_paths_drawn_look_warns_only_for_what_its_style_leaves_out() { + let profile = vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] }; + let linked = |doc: &mut Document, fill: FillLayer| { + let mut stroke = StrokeLayer::new(black_fill(), 4.0); + stroke.profile = Some(profile.clone()); + let ap = Appearance { + items: vec![AppearanceItem::Fill(fill), AppearanceItem::Stroke(stroke)], + effects: vec![drop_shadow()], + contents_index: None, + }; + let mut g = vectorcraft_doc::GraphicStyle::new("Linked", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + n.extras_mut().graphic_style = Some(7); + n + }; + let (n, warnings, p) = read_one(|doc| linked(doc, FillLayer::new(solid(Color::rgb(0.2, 0.2, 0.9))))); + assert!(p.contains("1 (Linked) XW"), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w == writer::STROKES), "{warnings:?}"); + assert!(n.extras().graphic_style.is_some(), "{n:?}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.profile.clone()), Some(profile.clone())); + let (_, warnings, p) = read_one(|doc| { + let mut fill = FillLayer::new(solid(Color::rgb(0.2, 0.2, 0.9))); + fill.opacity = 0.5; + fill.blend = BlendMode::Screen; + linked(doc, fill) + }); + assert!(!p.contains("1 (Linked) XW"), "{p}"); + assert!(warnings.iter().any(|w| w == writer::ITEM_TRANSPARENCY), "{warnings:?}"); +} + /// Each effect a graphic style can hold, in a style of its own, reads back with its settings, whatever /// the writer's conversions to the file's units. #[test] diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index dea4ad0c24..2f010070d0 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -51,7 +51,7 @@ pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths tha pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps"; pub(super) const LIVE: &str = "blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; -const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; +pub(super) const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; pub(super) const ITEM_TRANSPARENCY: &str = "the opacity and blending modes of single fills and strokes are left out of the editing data for other apps"; pub(super) const LAYER_MASKS: &str = "opacity masks on layers are left out of the editing data for other apps"; @@ -1028,7 +1028,13 @@ impl<'a> Writer<'a> { AppearanceItem::Fill(f) => self.fill_object(path, rule, f, xy), AppearanceItem::Stroke(s) if self.direct_stroke(s) => self.path_object(path, rule, None, Some(s), xy), AppearanceItem::Stroke(s) => { - self.warn(STROKES); + // In a styled path's or compound path's drawn look the stroke is kept by the object's + // art style, which is written only when the reader reads the object's appearance back + // from it. A styled group's style holds no fills or strokes: its members are written + // again after the look, and warn there. + if !self.in_look { + self.warn(STROKES); + } self.wrote_fixed = true; if self.brush_art(path, s, xy) { return; From 766e291e3cf8495fb7708e0645077fe4ce57eba1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 07:09:32 -0800 Subject: [PATCH 10/23] Save and Export: notes of what the file loses come before notes on options Each writer marks its notes as a loss (something of the document that the file leaves out or that reaches it changed) or as a note on the options (an option it didn't apply or applied another way while the art reaches the file as it looks, or something an option turns off). The PDF, SVG, EPS, legacy .ai, DXF and HP-GL writers return their notes on the options in a list of their own (`setting_notes`) beside `warnings`, and the engine's `Encoded` does the same. The replies of the save and export commands, and of `clipboard.exportPdf` and `clipboard.exportPng`, list the losses first, each kind in the order the writers gave them, across every file that Export for Screens writes. The status bar names the first note, so an export to lossy WebP of type in a font that isn't installed now names the fallback-font note instead of the lossy WebP note. A print to a file keeps the order its notes are given in. --- crates/cad/src/hpgl/write.rs | 18 +-- crates/cad/src/hpgl/write/tests.rs | 13 +- crates/cad/src/lib.rs | 3 + crates/cad/src/scene.rs | 18 ++- crates/cad/src/tests.rs | 21 ++-- crates/engine/src/cmd/css.rs | 16 +-- crates/engine/src/cmd/fileio.rs | 2 +- crates/engine/src/cmd/fileio/dxf.rs | 1 + crates/engine/src/cmd/fileio/encode.rs | 111 ++++++++++++++++-- crates/engine/src/cmd/fileio/eps.rs | 1 + crates/engine/src/cmd/fileio/export.rs | 4 +- crates/engine/src/cmd/fileio/hpgl.rs | 1 + crates/engine/src/cmd/fileio/legacy_ai.rs | 5 +- crates/engine/src/cmd/fileio/pdf.rs | 49 ++++---- crates/engine/src/cmd/fileio/save.rs | 21 ++-- crates/engine/src/cmd/fileio/screens.rs | 23 ++-- crates/engine/src/cmd/fileio/svg.rs | 1 + crates/engine/src/cmd/fileio/tests_dxf.rs | 15 +++ .../engine/src/cmd/fileio/tests_exportas.rs | 32 +++++ .../src/cmd/fileio/tests_pdfsecurity.rs | 18 +++ crates/engine/src/cmd/fileio/tests_screens.rs | 16 +++ crates/engine/src/cmd/rasterfx.rs | 4 +- crates/eps/src/legacy_ai.rs | 4 +- crates/eps/src/legacy_ai/tests.rs | 4 +- crates/eps/src/legacy_ai/writer.rs | 16 ++- crates/eps/src/lib.rs | 10 +- crates/eps/src/print.rs | 2 +- crates/eps/src/tests.rs | 2 +- crates/pdf/src/export/entry.rs | 22 ++-- crates/pdf/src/export/writer.rs | 14 ++- crates/pdf/src/lib.rs | 3 + crates/pdf/src/output.rs | 2 +- crates/pdf/src/post.rs | 8 +- crates/pdf/src/print.rs | 4 +- crates/pdf/src/tests/editing.rs | 3 +- crates/pdf/src/tests/layers.rs | 6 +- crates/pdf/src/tests/marks.rs | 11 +- crates/pdf/src/tests/output.rs | 2 +- crates/pdf/src/tests/pdf13.rs | 2 +- crates/pdf/src/tests/print.rs | 2 +- crates/pdf/src/tests/realtext.rs | 2 +- crates/pdf/src/tests/settings.rs | 7 +- crates/pdf/src/tests/webview.rs | 2 +- crates/svg/src/export.rs | 7 +- crates/svg/src/export/output.rs | 10 ++ crates/svg/src/export/text.rs | 2 +- crates/svg/src/lib.rs | 8 +- crates/svg/tests/it/data_attributes.rs | 2 +- crates/svg/tests/it/encoding_profile_fonts.rs | 2 +- crates/ui-egui/src/io.rs | 17 +++ crates/ui-egui/src/io/export.rs | 2 +- docs/mcp.md | 11 +- docs/target-app-parity.md | 1 + 53 files changed, 433 insertions(+), 150 deletions(-) diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 3e20c69a98..6ab80c8ba0 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -187,6 +187,9 @@ pub struct HpglOutput { pub pens: Vec<[u8; 3]>, /// What was approximated or left out. pub warnings: Vec, + /// Notes on the options: sorting the paths or drawing the inner paths first was applied only + /// in part. + pub setting_notes: Vec, } /// Write the visible art of `doc` over `opts.region` as an HP-GL plot. Live geometry effects are @@ -280,10 +283,11 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R if !by_pen.iter().all(Vec::is_empty) { pen_warnings.iter().for_each(|w| warn(&mut warnings, w)); } + let mut setting_notes = vec![]; if opts.sort || opts.inside_first { let (mut at, mut work) = ((0, 0), MAX_SORT_WORK); for paths in &mut by_pen { - at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut warnings); + at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut setting_notes); } } if opts.version == Version::Hpgl && by_pen.iter().flatten().flatten().any(|(x, y)| !CLASSIC_RANGE.contains(x) || !CLASSIC_RANGE.contains(y)) { @@ -293,7 +297,7 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R Version::Hpgl => plain(&by_pen), Version::Hpgl2 => encoded(&by_pen, &pens), }; - out.push(HpglOutput { bytes, pens: pens.clone(), warnings }); + out.push(HpglOutput { bytes, pens: pens.clone(), warnings, setting_notes }); } Ok(out) } @@ -366,20 +370,20 @@ fn box_dist2(p: (i64, i64), b: [i64; 4]) -> i128 { /// for a closed path any of its points (where it then starts); without, the first in paint order. /// With `inside_first`, a path is drawn only once every path lying inside it ([`nest::outer`]) is. /// Once `work` is spent, the paths left keep paint order, each after the paths inside it with -/// `inside_first`, with a warning. Returns where the pen stops. +/// `inside_first`, with a note in `notes`. Returns where the pen stops. fn reorder( paths: &mut Vec>, mut at: (i64, i64), work: &mut u64, nearest: bool, inside_first: bool, - warnings: &mut Vec, + notes: &mut Vec, ) -> (i64, i64) { let boxes: Vec<[i64; 4]> = paths.iter().map(|p| nest::bounds(p)).collect(); // The paths each path lies inside, and how many paths inside each are still to draw. let outer = inside_first.then(|| nest::outer(paths, &boxes, work)).flatten(); if inside_first && outer.is_none() { - warn(warnings, UNNESTED); + warn(notes, UNNESTED); } let mut waiting = vec![0usize; paths.len()]; for o in outer.iter().flatten().flatten() { @@ -430,11 +434,11 @@ fn reorder( if *work == 0 || best.0 >= left.len() { match &outer { Some(outer) => { - warn(warnings, UNSORTED_INSIDE); + warn(notes, UNSORTED_INSIDE); inner_first(left.into_iter().map(|(j, p, _)| (j, p)).collect(), outer, &mut waiting, paths); } None => { - warn(warnings, UNSORTED); + warn(notes, UNSORTED); paths.extend(left.into_iter().map(|(_, p, _)| p)); } } diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index c29ef67532..9cb4f7d727 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -852,7 +852,8 @@ fn sorting_measures_a_closed_path_by_its_nearest_point() { fn sorting_stops_at_its_budget_and_keeps_every_path() { let d = doc_with(|d| (0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK)).collect()); let out = plot(&d, &HpglOptions { sort: true, ..opts(&d) }); - assert!(out.warnings.iter().any(|w| w == UNSORTED), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [UNSORTED]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); assert_eq!(text(&out).matches("PD").count(), 600); } @@ -944,18 +945,18 @@ fn inside_first_orders_each_pens_paths() { } /// Finding which of 100 nested rings lie inside which is past the budget: they are drawn in -/// paint order, with a warning. +/// paint order, with a note on the option. #[test] fn inside_first_past_its_budget_draws_in_paint_order() { let d = doc_with(|d| (0..100).map(|i| stroked(d, rect(1.0 + 0.4 * f64::from(i), 10.0, 199.0, 90.0), Color::BLACK)).collect()); let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNNESTED]); + assert_eq!(out.setting_notes, [UNNESTED]); let l = lefts(&out); assert_eq!(l.len(), 100); assert!(l.windows(2).all(|w| w[0] <= w[1]), "{l:?}"); - // With `sort` too, the budget is spent before sorting starts: paint order, with both warnings. + // With `sort` too, the budget is spent before sorting starts: paint order, with both notes. let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNNESTED, UNSORTED]); + assert_eq!(out.setting_notes, [UNNESTED, UNSORTED]); assert_eq!(lefts(&out), l); } @@ -969,7 +970,7 @@ fn inside_first_past_its_sorting_budget_keeps_the_rings_last() { v }); let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNSORTED_INSIDE]); + assert_eq!(out.setting_notes, [UNSORTED_INSIDE]); let l = lefts(&out); assert_eq!(l.len(), 602); assert_eq!(l.get(600..), Some(&[plu(0.7), plu(0.5)][..])); diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 0ef28a4aaa..1443829806 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -410,6 +410,9 @@ pub struct DxfOutput { pub images: Vec, /// What was approximated or left out. pub warnings: Vec, + /// What was left out because an option turns it off (Include Placed Images or Include Fill + /// Hatches off). + pub setting_notes: Vec, } /// The smallest and largest `scale` (drawing units per unit). diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 7c5d8b6755..0379fc4ea5 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -520,6 +520,8 @@ pub(crate) struct Scene<'a> { /// Image keys → index into `images` (`None`: left out). by_key: HashMap>, warnings: Vec, + /// What was left out because an option asked for it ([`DxfOutput::setting_notes`]). + setting_notes: Vec, brushes: Option>, extents: Option<[f64; 4]>, /// The opacity of the containers around the current object. @@ -554,6 +556,7 @@ impl<'a> Scene<'a> { files: vec![], by_key: HashMap::new(), warnings: vec![], + setting_notes: vec![], brushes: None, extents: None, opacity: 1.0, @@ -588,15 +591,24 @@ impl<'a> Scene<'a> { metric: matches!(self.o.unit, Unit::Millimeters | Unit::Centimeters | Unit::Meters), next: self.next, }; - DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings } + DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings, setting_notes: self.setting_notes } } + /// Note `w`, once, about something of the document that the drawing leaves out or that reaches + /// it changed. A note about an option goes to [`Self::note_setting`]. fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); } } + /// Note `w`, once, about something left out because an option turns it off. + fn note_setting(&mut self, w: &str) { + if !self.setting_notes.iter().any(|x| x == w) { + self.setting_notes.push(w.to_string()); + } + } + fn handle(&mut self) -> Handle { let h = Handle(self.next); self.next = self.next.saturating_add(1); @@ -930,7 +942,7 @@ impl<'a> Scene<'a> { // The outlines close as the fill does, so an open path's fill keeps its closing edge. self.lines(&closed_fill(outline), style, 0.0); if !self.o.include_hatches { - self.warn(HATCHES_OFF); + self.note_setting(HATCHES_OFF); return; } let h = self.handle(); @@ -1321,7 +1333,7 @@ impl<'a> Scene<'a> { return; } if !self.o.include_images { - self.warn(IMAGES_OFF); + self.note_setting(IMAGES_OFF); return; } let Some(i) = self.image_def(im) else { return }; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 9d2a519e1d..96c037a4a7 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -881,12 +881,13 @@ fn fills_without_hatches_keep_their_outlines() { let d = shapes_doc(); let out = export(&d, &DxfOptions { include_hatches: false, ..opts(&d) }).unwrap(); assert_eq!(kinds(&out), ["LWPOLYLINE", "LWPOLYLINE", "CIRCLE"], "the fill's outline, the stroke, the circle"); - assert_eq!(out.warnings, [HATCHES_OFF]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [HATCHES_OFF]); let e = entities(&out); assert_eq!((get(&e[0], 70), get(&e[0], 370)), (Some("1"), None), "the closed outline, without a lineweight"); // Hatches stay by default, with no note. let on = export(&d, &opts(&d)).unwrap(); - assert!(kinds(&on).iter().any(|k| k == "HATCH") && !on.warnings.iter().any(|w| w == HATCHES_OFF)); + assert!(kinds(&on).iter().any(|k| k == "HATCH") && on.setting_notes.is_empty()); // The outlines are the ones written beside hatches, also for overlapping subpaths, which are // merged first; a fill with no area (a flat rectangle, a diagonal line) writes nothing either way. let compound = doc_with(|d| { @@ -916,13 +917,14 @@ fn fills_without_hatches_keep_their_outlines() { assert!(!kinds(&altered).iter().any(|k| k == "HATCH") && !kinds(&altered).is_empty(), "{:?}", kinds(&altered)); // Nothing filled: no note. let lines = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); - assert!(export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap().warnings.is_empty()); + let none = export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap(); + assert!(none.warnings.is_empty() && none.setting_notes.is_empty()); // R12 has no hatches anyway: the same outlines, and its own note alone. let r12 = |include_hatches| export(&d, &DxfOptions { version: DxfVersion::R12, include_hatches, ..opts(&d) }).unwrap(); let (off, on) = (r12(false), r12(true)); assert_eq!(off.bytes, on.bytes); assert_eq!(off.warnings, on.warnings); - assert!(!off.warnings.iter().any(|w| w == HATCHES_OFF)); + assert!(off.setting_notes.is_empty()); } #[test] @@ -957,15 +959,18 @@ fn images_can_be_left_out_with_their_files() { let p = pairs(&out.bytes); assert!(!section(&p, "OBJECTS").iter().any(|o| matches!(kind(o), "IMAGEDEF" | "IMAGEDEF_REACTOR" | "RASTERVARIABLES"))); assert!(!section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE"))); - assert_eq!(out.warnings, [IMAGES_OFF]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [IMAGES_OFF]); // An image that can't be decoded is not read when images are left out. let mut broken = d.clone(); broken.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(b"not a png".to_vec()), proxy: None }); - assert_eq!(export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap().warnings, [IMAGES_OFF]); + let left_out = export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap(); + assert!(left_out.warnings.is_empty(), "{:?}", left_out.warnings); + assert_eq!(left_out.setting_notes, [IMAGES_OFF]); assert!(export(&broken, &opts(&broken)).unwrap().warnings.iter().any(|w| w.contains("couldn't be decoded"))); // R12 has no images anyway: its own note alone. let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, include_images: false, ..opts(&d) }).unwrap(); - assert!(r12.warnings.iter().any(|w| w.contains("no images")) && !r12.warnings.iter().any(|w| w == IMAGES_OFF)); + assert!(r12.warnings.iter().any(|w| w.contains("no images")) && r12.setting_notes.is_empty()); } #[test] @@ -983,7 +988,7 @@ fn images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_b let cnc = run(DxfPreset::Cnc.options()); assert_eq!(kinds(&cnc), ["LWPOLYLINE"]); assert!(cnc.images.is_empty()); - assert!(cnc.warnings.iter().any(|w| w == IMAGES_OFF) && cnc.warnings.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.warnings); + assert!(cnc.setting_notes.iter().any(|w| w == IMAGES_OFF) && cnc.setting_notes.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.setting_notes); let custom = DxfPreset::Custom.options(); assert!(custom.include_images && custom.include_hatches, "Custom keeps both"); } diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs index ed83a01410..9e18df76bf 100644 --- a/crates/engine/src/cmd/css.rs +++ b/crates/engine/src/cmd/css.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeId; use vectorcraft_svg::{CssOptions, CssSheet, css_rules}; -use super::fileio::{default_name, encode_with_warnings, isolated, write_file}; +use super::fileio::{Note, default_name, encode_with_notes, isolated, note_texts, write_file}; use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets}; pub fn specs() -> Vec { @@ -100,20 +100,22 @@ fn export(s: &mut Session, p: &Value) -> Result { } let css = format!("{}\n", sheet.text()); let doc = &s.doc()?.doc; - // Encode the pictures of rasterized art, and add each of their warnings once. + // Encode the pictures of rasterized art, and add each of their warnings once. What CSS can't + // describe is a loss. let mut images = Vec::new(); - let mut warnings = sheet.warnings.clone(); + let mut notes: Vec = sheet.warnings.iter().cloned().map(Note::loss).collect(); for r in &sheet.rules { let Some(name) = &r.image else { continue }; let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?; - let (png, notes) = encode_with_warnings(&d, "png", &json!({}))?; + let (png, given) = encode_with_notes(&d, "png", &json!({}))?; images.push((name, png)); - for w in notes { - if !warnings.contains(&w) { - warnings.push(w); + for n in given { + if !notes.iter().any(|x| x.text == n.text) { + notes.push(n); } } } + let warnings = note_texts(¬es); let n = sheet.rules.len(); match str_param(p, "path") { Some(path) => { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 329988cb34..b2644c1f29 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -66,7 +66,7 @@ use serde_json::{Value, json}; pub use action_batch::batch_overwrites_one_file; pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; -pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; +pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, note_texts}; pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub(crate) use export::with_artboard_fills; pub(crate) use export::{export_crop, isolated}; diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index 16cf3b2631..0fcaa4bfc0 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -241,6 +241,7 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option) -> enc.warnings.push(w); } } + enc.add_setting_notes(out.setting_notes); for img in out.images { if !enc.linked.iter().any(|l| l.name == img.name) { enc.linked.push(vectorcraft_svg::LinkedImage { name: img.name, bytes: Arc::new(img.bytes) }); diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 0f91007515..1be80f2c56 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -74,18 +74,104 @@ pub struct Encoded { pub linked: Vec, /// Image maps, each written beside its image under the image's name (raster `imageMap`). pub maps: Vec, - /// The encoder's warnings (features approximated or left out, options not applied yet). + /// The encoder's warnings: what the file leaves out of the document or writes differently + /// ([`NoteKind::Loss`]). pub warnings: Vec, + /// The encoder's notes on the options: options not applied yet or applied another way, and + /// what an option turns off ([`NoteKind::Setting`]). A reply gives them after the warnings + /// ([`Self::notes`]). + pub setting_notes: Vec, /// What joins the file's stem and the artboard name when there is a file per artboard /// (default `-`; EPS uses `_`, as the reference app names them). pub joiner: Option<&'static str>, } +/// What a note an export gives with its file is about. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoteKind { + /// Something of the document (its art, type, layers or artboards) that the file leaves out or + /// that reaches it changed: the format, its version or a limit can't hold it, or the export + /// couldn't write it. + Loss, + /// An option the export didn't apply or applied another way while the art reaches the file as + /// it looks, or something left out because an option turns it off. + Setting, +} + +/// A note an export gives with its file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Note { + /// What the note is about. + pub kind: NoteKind, + /// The note, in English. + pub text: String, +} + +impl Note { + /// A note about what the file loses ([`NoteKind::Loss`]). + pub fn loss(text: impl Into) -> Self { + Self { kind: NoteKind::Loss, text: text.into() } + } + + /// A note on the options ([`NoteKind::Setting`]). + pub fn setting(text: impl Into) -> Self { + Self { kind: NoteKind::Setting, text: text.into() } + } +} + +/// The texts of `notes` in the order a reply gives them: the losses first, then the notes on the +/// options, each kind in the order given (the status bar names the first). +pub fn note_texts(notes: &[Note]) -> Vec { + let of = |kind| notes.iter().filter(move |n| n.kind == kind).map(|n| n.text.clone()); + of(NoteKind::Loss).chain(of(NoteKind::Setting)).collect() +} + +/// Add `note` to `notes` unless a note with its text is there already. +pub(super) fn add_note(notes: &mut Vec, note: Note) { + if !notes.iter().any(|n| n.text == note.text) { + notes.push(note); + } +} + impl Encoded { pub(super) fn one(bytes: Vec) -> Self { Self { files: vec![(None, bytes)], ..Self::default() } } + /// The file `bytes` with `notes`, each in the list of its kind, in the order given. + pub(super) fn one_with_notes(bytes: Vec, notes: Vec) -> Self { + let mut enc = Self::one(bytes); + for n in notes { + match n.kind { + NoteKind::Loss => enc.warnings.push(n.text), + NoteKind::Setting => enc.setting_notes.push(n.text), + } + } + enc + } + + /// Add each of `texts` to [`Self::setting_notes`] unless a note with its text is there already. + pub(super) fn add_setting_notes(&mut self, texts: impl IntoIterator) { + for t in texts { + if !self.warnings.contains(&t) && !self.setting_notes.contains(&t) { + self.setting_notes.push(t); + } + } + } + + /// Every note with its kind: the warnings, then the notes on the options that aren't also + /// warnings. + pub fn kinded_notes(&self) -> Vec { + let settings = self.setting_notes.iter().filter(|t| !self.warnings.contains(t)).map(Note::setting); + self.warnings.iter().map(Note::loss).chain(settings).collect() + } + + /// The notes' texts in the order a reply gives them ([`note_texts`]): the warnings, then the + /// notes on the options, each once. + pub fn notes(&self) -> Vec { + note_texts(&self.kinded_notes()) + } + /// Every file to write for the destination `path` (a path or a file name): the file itself /// when there is one, else `{stem}-{artboard name}.{ext}` beside it (see `joiner`; a file of no artboard /// among several, as Save's master file, is the file itself), then the linked images beside it @@ -128,13 +214,14 @@ impl Encoded { out } - /// The one file and the warnings, when the export wrote nothing else. - fn single(self, f: &Format) -> Result<(Vec, Vec)> { + /// The one file and the notes, when the export wrote nothing else. + fn single(self, f: &Format) -> Result<(Vec, Vec)> { if !self.linked.is_empty() || !self.maps.is_empty() { return Err(bad(C, format!("{} with linked images or an image map writes several files: use document.export", f.label))); } + let notes = self.kinded_notes(); match <[_; 1]>::try_from(self.files) { - Ok([(_, bytes)]) => Ok((bytes, self.warnings)), + Ok([(_, bytes)]) => Ok((bytes, notes)), Err(_) => Err(bad(C, format!("{} writes one file per artboard here: use document.export, or name one artboard", f.label))), } } @@ -419,8 +506,14 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result> { /// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet, /// features approximated or left out; every format that draws type: fonts written in the fallback -/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame). +/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame), what +/// the file loses first ([`note_texts`]). pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec, Vec)> { + encode_with_notes(doc, format, p).map(|(bytes, notes)| (bytes, note_texts(¬es))) +} + +/// Like [`encode_with_warnings`], with each note's kind ([`Encoded::kinded_notes`]). +pub fn encode_with_notes(doc: &Document, format: &str, p: &Value) -> Result<(Vec, Vec)> { let f = super::writable(C, Some(format), None)?; let enc = encode_all(doc, f.id, p)?; // The error for a version 7 .ai of several artboards names the legacy .ai. @@ -476,7 +569,7 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result { /// ([`vectorcraft_plugins::export`]). The plug-in is given the native file's JSON /// ([`vectorcraft_format::json_part`]) and `p`'s `params`, for the file `p`'s `path` names (else /// one named after the document) with that path's extension when the plug-in lists it, else its -/// first. Its warnings come back with the file. +/// first. Its warnings come back with the file, as losses (the plug-in gives them no kind). fn plugin_file(doc: &Document, plugin: &vectorcraft_plugins::Plugin, f: &Format, p: &Value) -> Result { use vectorcraft_plugins::{Error, export}; let doc = &*doc.without_edit_modes(); @@ -539,8 +632,8 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { "hpgl" => return super::hpgl::encode(doc, p, use_artboards), "ai" => return super::save::encode_ai(C, f, doc, p), "pdf" => { - let (bytes, warnings) = super::pdf::encode(C, doc, p)?; - return Ok(Encoded { warnings, ..Encoded::one(bytes) }); + let (bytes, notes) = super::pdf::encode(C, doc, p)?; + return Ok(Encoded::one_with_notes(bytes, notes)); } "png" | "jpg" | "webp" | "gif" | "png8" | "tiff" | "bmp" | "tga" | "psd" => { let o: RasterOptions = options(f, p)?; @@ -607,7 +700,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { let regions: Vec = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect(); enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions))); if format == RasterFormat::WebP && o.lossless == Some(false) { - enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into()); + enc.setting_notes.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into()); } return Ok(enc); } diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index fe962a36ad..3acd23e854 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -382,6 +382,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { enc.warnings.push(w); } } + enc.add_setting_notes(out.setting_notes); } enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions))); Ok(enc) diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 62e5b78e6b..19e496306f 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -42,7 +42,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { } _ => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes) }), }; - let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.warnings })); + let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.notes() })); if main.len() > 1 { out["files"] = main.iter().map(|(name, bytes)| merge(json!({ "name": name }), data(bytes))).collect(); } @@ -69,7 +69,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result { o.insert("_displayDocTitle".into(), json!(display_doc_title)); } let enc = encode_all(doc, f.id, &q)?; - write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings })) + write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.notes() })) } /// The document an export of `st` in format `f` writes ([`export_source`]): a `.ai` or `.ait` diff --git a/crates/engine/src/cmd/fileio/hpgl.rs b/crates/engine/src/cmd/fileio/hpgl.rs index ff6e63fbd7..1baac844e6 100644 --- a/crates/engine/src/cmd/fileio/hpgl.rs +++ b/crates/engine/src/cmd/fileio/hpgl.rs @@ -116,6 +116,7 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option) -> enc.warnings.push(w); } } + enc.add_setting_notes(out.setting_notes); } if enc.files.is_empty() { return Err(bad(C, "the document has no artboard")); diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 1fd3e10a9f..6ee826d5ea 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -136,10 +136,12 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result } let images = see_through_images(&d, mode); if !images.is_empty() { - note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED }); if mode == SeeThrough::Flatten { + note(&mut enc.warnings, FLATTENED); flatten_images(&mut d, &images)?; } else { + // A note on the options: `transparentImages: omit` leaves them out. + enc.add_setting_notes([OMITTED.to_string()]); for (_, id) in images { let _ = d.remove(id); // Inside an image left out before: gone already. } @@ -150,6 +152,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result for w in placed.iter().chain(&out.warnings) { note(&mut enc.warnings, w); } + enc.add_setting_notes(out.setting_notes); enc.files.push((Some(b), out.bytes)); } // Expanding raster effects and flattening images draw the art, and an image the renderer diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 1f166797f8..b11251ba34 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -12,7 +12,7 @@ use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, S use super::super::flatten::{FlattenOptions, flatten_document, see_through_images}; use super::super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param}; -use super::{ArtboardPick, FormatOption, write_or_return}; +use super::{ArtboardPick, FormatOption, Note, note_texts, write_or_return}; use crate::EngineError; const C: &str = "document.exportPdf"; @@ -284,21 +284,22 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str = /// ([`vectorcraft_pdf::PdfSettings::leaves_out_bleed`]). pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings"; -/// Encode `doc` as PDF with the options in `p` → (bytes, warnings). Raster effects are rendered +/// Encode `doc` as PDF with the options in `p` → (bytes, notes). Raster effects are rendered /// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself /// when the PDF has every artboard (a PDF of some reopens as just those). -pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { +pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { encode_at(cmd, doc, p, None) } /// [`encode`] modified at `modified` (Unix seconds) when given, instead of the time of writing. -pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option) -> Result<(Vec, Vec)> { +pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option) -> Result<(Vec, Vec)> { // Keep the editable object tree independent of preview fallbacks used by the drawn pages. - let (full, mut warnings) = crate::cmd::place::document::full_documents(doc); - let (bytes, drawn_warnings) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; - warnings.extend(drawn_warnings); - warnings.extend(overflow_warning(cmd, &full, p)?); - Ok((bytes, warnings)) + let (full, placed) = crate::cmd::place::document::full_documents(doc); + let mut notes: Vec = placed.into_iter().map(Note::loss).collect(); + let (bytes, drawn) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; + notes.extend(drawn); + notes.extend(overflow_warning(cmd, &full, p)?.map(Note::loss)); + Ok((bytes, notes)) } /// The warning about the type that the pages of a PDF of `doc` with the options in `p` draw and @@ -321,7 +322,7 @@ pub(super) fn encode_carrying( p: &Value, native: impl FnOnce() -> Result>, private: impl FnOnce() -> Result)>>, -) -> Result<(Vec, Vec)> { +) -> Result<(Vec, Vec)> { encode_carrying_at(cmd, doc, p, None, native, private) } @@ -334,7 +335,7 @@ fn encode_carrying_at( modified: Option, native: impl FnOnce() -> Result>, private: impl FnOnce() -> Result)>>, -) -> Result<(Vec, Vec)> { +) -> Result<(Vec, Vec)> { let mut opts = options_at(cmd, doc, p, modified.or_else(vectorcraft_doc::metadata::output_time))?; // The pages' background colors, behind the art (the editing data keeps them as colors). let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect()); @@ -345,21 +346,22 @@ fn encode_carrying_at( if opts.settings.thumbnails { opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?; } - let mut warnings = vec![]; - // The status bar shows the first warning, so this one goes ahead of the flattening note. + let mut notes = vec![]; + // A loss, ahead of the flattening note: the PDF/X pages leave out the bleed that Document + // Setup gives. if opts.settings.leaves_out_bleed(doc) { - warnings.push(BLEED_LEFT_OUT.to_string()); + notes.push(Note::loss(BLEED_LEFT_OUT)); } if opts.settings.preserve_editing { if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) { opts.native = Some(native()?); if let Some((data, lost)) = private()? { opts.private_data = Some(data); - warnings.extend(lost); + notes.extend(lost.into_iter().map(Note::loss)); } } else { opts.settings.preserve_editing = false; - warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string()); + notes.push(Note::setting(EDITING_NEEDS_EVERY_ARTBOARD)); } } let set = &opts.settings; @@ -370,12 +372,13 @@ fn encode_carrying_at( vectorcraft_doc::cancel::check()?; if flattened { let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() }; - warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset")); + notes.push(Note::loss(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset"))); } let r = super::super::rasterfx::export_pdf_with_report(flat.as_ref().unwrap_or(doc), &opts).map_err(|e| pdf_error(cmd, e))?; - warnings.extend(r.warnings); - warnings.extend(crate::cmd::fonts::substitution_warning(doc)); - Ok((r.bytes, warnings)) + notes.extend(r.warnings.into_iter().map(Note::loss)); + notes.extend(r.setting_notes.into_iter().map(Note::setting)); + notes.extend(crate::cmd::fonts::substitution_warning(doc).map(Note::loss)); + Ok((r.bytes, notes)) } /// The flattener preset of PDF 1.3 files whose `flattenerPreset` is empty. @@ -479,8 +482,8 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { } else { q = json!({ "_displayDocTitle": display_doc_title }); } - let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?; - write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings })) + let (bytes, notes) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?; + write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": note_texts(¬es) })) } /// `(path, value)` for every leaf of `v` that differs from `default` (`compression.color.ppi`). @@ -512,7 +515,7 @@ fn pdf_settings(s: &mut Session, p: &Value) -> Result { let set = settings_with(Q, p, saved)?; let diff = changes(&set, &preset_settings(Q, p, saved)?); let warnings = match s.active() { - Some(st) if bool_or(p, "includeDocument", false) => encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1, + Some(st) if bool_or(p, "includeDocument", false) => note_texts(&encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1), _ => set.warnings(), }; let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 5d7783665b..c21b53da4f 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -740,12 +740,14 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res carries_editing.set(true); Ok(Some((data, resolved_warnings))) }; - let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?; - warnings.extend(placed_warnings); + let (bytes, notes) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?; + let mut enc = Encoded::one_with_notes(bytes, notes); + enc.warnings.extend(placed_warnings); + // A note on the options: `pdfCompatible: false` writes blank pages. if !pdf_compatible { - warnings.push(if carries_editing.get() { NOT_PDF_COMPATIBLE_EDITING } else { NOT_PDF_COMPATIBLE }.to_string()); + enc.setting_notes.push(if carries_editing.get() { NOT_PDF_COMPATIBLE_EDITING } else { NOT_PDF_COMPATIBLE }.to_string()); } - Ok(Encoded { warnings, ..Encoded::one(bytes) }) + Ok(enc) } /// Snapshot the active document for `plan` (stamping File Info's dates on the snapshot when the @@ -833,8 +835,8 @@ impl SaveJob { } /// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with - /// the warnings (what the format loses first, then the encoder's own notes: PDF options not - /// applied yet…, then the plan's notes on its params). + /// the notes (what the format loses first, then the encoder's own notes: PDF options not + /// applied yet…, then the plan's notes on its params, which are notes on the options). pub fn encode(&self) -> Result { let mut enc = self.encode_one(&self.doc)?; for &b in &self.boards { @@ -842,7 +844,7 @@ impl SaveJob { enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes))); } enc.warnings.splice(0..0, self.format_losses()); - enc.warnings.extend(self.plan.notes.iter().cloned()); + enc.setting_notes.extend(self.plan.notes.iter().cloned()); Ok(enc) } @@ -893,9 +895,8 @@ impl SaveJob { /// order: when `stop` returns a value, nothing is written and that value is returned. pub fn write_unless(&self, stop: impl FnOnce(&[&str]) -> Option, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let (plan, doc) = (&self.plan, &*self.doc); - let mut enc = self.encode()?; - let warnings = std::mem::take(&mut enc.warnings); - let extra = json!({ "format": plan.format.id, "warnings": warnings }); + let enc = self.encode()?; + let extra = json!({ "format": plan.format.id, "warnings": enc.notes() }); let Some(path) = &plan.path else { let mut out = write_encoded(None, &plan.name, doc, &enc, extra)?; out["name"] = json!(plan.name); diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index b48aef68c3..62096a6f5e 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -17,12 +17,12 @@ use vectorcraft_doc::Document; use vectorcraft_geom::Rect; use super::super::{EngineError, NodeId, Result, Session, bad, bool_or, str_param}; -use super::encode::single_artboard; +use super::encode::{add_note, single_artboard}; use super::export::{export_crop, isolated, selected_artwork, without_artboards}; use super::naming::{self, NameParts}; use super::{ - ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_warnings, merge, - unique_file_names_keeping_dots, with_black_output, writable, write_file, + ARTBOARD_PARAMS, ArtboardPick, Format, Note, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_notes, merge, + note_texts, unique_file_names_keeping_dots, with_black_output, writable, write_file, }; const C: &str = "document.exportForScreens"; @@ -1046,7 +1046,8 @@ pub(crate) fn with_export_preset(s: &Session, cmd: &str, settings: &mut Map Result { check_passwords(p)?; let o = ScreenParams::of(p)?; @@ -1138,18 +1139,16 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> create_dir(dir)?; } let mut out = Output::default(); - // The encoders' warnings for every file, each once. - let mut warnings: Vec = Vec::new(); + // The encoders' notes for every file, each once. + let mut notes: Vec = Vec::new(); // Include Bleed: a grown artboard already holds the bleed. Assets, layers and a selection are // written on a page of their own bounds, which it doesn't grow. let bleed_on_page = o.include_bleed && o.assets.is_none() && o.layers.is_none() && o.selection.is_none(); let mut write = |doc: &Document, format: &str, options: &Value| -> Result> { // The steps are the files: an encoder's own steps (artboards, pages) are not reported. - let (bytes, notes) = vectorcraft_doc::cancel::quiet(|| encode_with_warnings(doc, format, options))?; - for w in notes { - if !(bleed_on_page && w == super::pdf::BLEED_LEFT_OUT) && !warnings.contains(&w) { - warnings.push(w); - } + let (bytes, given) = vectorcraft_doc::cancel::quiet(|| encode_with_notes(doc, format, options))?; + for n in given.into_iter().filter(|n| !(bleed_on_page && n.text == super::pdf::BLEED_LEFT_OUT)) { + add_note(&mut notes, n); } Ok(bytes) }; @@ -1224,6 +1223,8 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> if o.zip || folder.is_none() { vectorcraft_doc::cancel::check()?; } + // What the files lose first, across every file. + let warnings = note_texts(¬es); let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect(); if o.zip { let zip = super::zip::deflate(&out.kept, super::zip::FIXED_TIME, vectorcraft_doc::cancel::is_cancelled); diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 3123fdcfb1..d479be0f7c 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -198,6 +198,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + let Some((option, losses)) = warnings.split_last() else { panic!("no notes") }; + assert!(option.starts_with("fill hatches are left out"), "{warnings:?}"); + assert!(losses.iter().any(|w| w.starts_with("transparency needs DXF 2004")), "{warnings:?}"); + assert!(!losses.iter().any(|w| w.starts_with("fill hatches")), "each note once: {warnings:?}"); +} + /// Two placed images whose pixel data can't be decoded are left out of the drawing and get no /// linked file, and `warnings` holds the note raster exports give, once, without naming either /// image's key. diff --git a/crates/engine/src/cmd/fileio/tests_exportas.rs b/crates/engine/src/cmd/fileio/tests_exportas.rs index 09bbcf28f3..f3566e82ba 100644 --- a/crates/engine/src/cmd/fileio/tests_exportas.rs +++ b/crates/engine/src/cmd/fileio/tests_exportas.rs @@ -117,3 +117,35 @@ fn formats_list_use_artboards() { } assert!(opts("vectorcraft").get("useArtboards").is_none()); } + +/// An export's reply gives what the file loses before its notes on the options, whatever order +/// the encoder gave them: the WebP encoder notes that lossy WebP isn't applied before the export +/// notes the type it drew in the fallback font. With only notes on the options, they keep their +/// order. +#[test] +fn what_the_file_loses_comes_before_a_note_on_the_options() { + let lossy = "lossy WebP isn't available yet: the file is lossless (exact, but larger)"; + let warnings = |s: &mut Session, p: Value| -> Vec { + let r = s.execute("document.export", &p).unwrap(); + r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() + }; + let mut s = session(60.0, 40.0, 1); + assert_eq!(warnings(&mut s, json!({"format": "webp", "lossless": false})), [lossy]); + s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let w = warnings(&mut s, json!({"format": "webp", "lossless": false})); + assert_eq!(w.len(), 2, "{w:?}"); + assert!(w[0].contains("fallback font") && w[0].contains("Nonexistent Sans Probe"), "{w:?}"); + assert_eq!(w[1], lossy); + // document.serialize gives them in the same order. + let r = s.execute("document.serialize", &json!({"format": "webp", "lossless": false})).unwrap(); + assert_eq!(r["warnings"], json!(w)); +} + +#[test] +fn note_texts_give_the_losses_first_each_kind_in_order() { + use super::encode::{Note, note_texts}; + let notes = [Note::setting("s1"), Note::loss("l1"), Note::setting("s2"), Note::loss("l2")]; + assert_eq!(note_texts(¬es), ["l1", "l2", "s1", "s2"]); + assert_eq!(note_texts(&[Note::setting("s1"), Note::setting("s2")]), ["s1", "s2"]); + assert!(note_texts(&[]).is_empty()); +} diff --git a/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs b/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs index d8c36a9196..09505391af 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs @@ -56,3 +56,21 @@ fn permissions_without_a_password_warn_and_a_password_applies_them() { let bytes = vectorcraft_format::base64_decode(locked["dataBase64"].as_str().unwrap()).unwrap(); assert!(String::from_utf8_lossy(&bytes).contains("/Encrypt")); } + +/// The writer notes the permissions it didn't apply before the export notes the type it drew in +/// the fallback font: the reply gives what the file loses first, through both commands. +#[test] +fn the_fallback_font_note_comes_before_the_permissions_note() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); + s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + for r in [ + s.execute("document.exportPdf", &json!({"security": {"printing": "none"}})).unwrap(), + s.execute("document.export", &json!({"format": "pdf", "security": {"printing": "none"}})).unwrap(), + ] { + let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + let at = |s: &str| warnings.iter().position(|w| w.contains(s)); + let (font, permissions) = (at("Nonexistent Sans Probe"), at("permissions need a password")); + assert!(font.is_some() && permissions.is_some() && font < permissions, "{warnings:?}"); + } +} diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 86ee164998..0378478522 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -507,6 +507,22 @@ fn type_in_a_missing_family_is_named_once_for_every_format() { assert!(fallback[0].contains("Nonexistent Sans Probe"), "{fallback:?}"); } +/// What a later file loses comes before a note on the options that an earlier file gave: the +/// first artboard's WebP gives only the lossy WebP note, the second's also says its type doesn't +/// fit. +#[test] +fn what_any_file_loses_comes_before_the_notes_on_the_options() { + let mut s = session(2); + let second = s.doc().unwrap().doc.artboards[1].rect; + let area = json!({"width": 30, "height": 8}); + s.execute("text.create", &json!({"x": second.x0 + 2.0, "y": second.y0 + 2.0, "size": 12, "area": area, "text": "Ab\nCd\nEf\nGh"})).unwrap(); + let r = export(&mut s, json!({"formats": [{"format": "webp", "lossless": false}]})); + let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert_eq!(warnings.len(), 2, "{warnings:?}"); + assert!(warnings[0].contains("doesn't fit its frame"), "{warnings:?}"); + assert!(warnings[1].starts_with("lossy WebP"), "{warnings:?}"); +} + #[test] fn full_document_gives_one_file_per_format() { let mut s = session(3); diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 566a16dbfd..8f5f532c51 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -645,8 +645,8 @@ pub fn export_pdf(doc: &Document, opts: &vectorcraft_pdf::PdfOptions) -> std::re export_pdf_with_report(doc, opts).map(|r| r.bytes) } -/// Like [`export_pdf`], also returning the writer's warnings (options not applied yet, features -/// approximated or left out). +/// Like [`export_pdf`], also returning the writer's warnings (features approximated or left out) +/// and its notes on the options (options not applied yet). pub fn export_pdf_with_report( doc: &Document, opts: &vectorcraft_pdf::PdfOptions, diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 2a09cf003c..993fcbd9e3 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -156,6 +156,8 @@ pub enum HiddenObjects { pub struct LegacyAiOutput { pub bytes: Vec, pub warnings: Vec, + /// Notes on what the options asked for: hidden objects left out or written visible. + pub setting_notes: Vec, } /// Whether image node `n` of `doc` is written as a 1-bit image mask in its colorize color @@ -267,7 +269,7 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result { /// The name each spot color (by its swatch's name) is written with ([`Self::spot`]). spot_names: Vec<(String, String)>, pub(super) warnings: Vec, + /// Notes on what the options asked for ([`crate::LegacyAiOutput::setting_notes`]). + pub(super) setting_notes: Vec, /// The lock state the last `A` set. locked: bool, /// Symbols and live objects being written inside one another. @@ -56,6 +58,7 @@ impl<'a> Writer<'a> { spots: vec![], spot_names: vec![], warnings: vec![], + setting_notes: vec![], locked: false, nest: 0, hidden: o.hidden, @@ -68,12 +71,21 @@ impl<'a> Writer<'a> { } } + /// Note `w`, once, about something of the document that the file leaves out or that reaches + /// it changed. A note about an option goes to [`Self::note_setting`]. fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); } } + /// Note `w`, once, about hidden objects left out or written visible as an option asks. + fn note_setting(&mut self, w: &str) { + if !self.setting_notes.iter().any(|x| x == w) { + self.setting_notes.push(w.to_string()); + } + } + fn line(&mut self, s: &str) { self.body.push_str(s); self.body.push('\n'); @@ -225,7 +237,7 @@ impl<'a> Writer<'a> { if !n.visible && !self.in_hidden { match self.hidden { HiddenObjects::Omit => { - self.warn(HIDDEN); + self.note_setting(HIDDEN); return; } HiddenObjects::Layer => { @@ -233,7 +245,7 @@ impl<'a> Writer<'a> { self.hidden_art.push((n.clone(), locked || n.locked)); return; } - HiddenObjects::Visible => self.warn(HIDDEN_SHOWN), + HiddenObjects::Visible => self.note_setting(HIDDEN_SHOWN), } } if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 586967234b..f6b7fba609 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -195,6 +195,8 @@ pub struct EpsOutput { pub bytes: Vec, /// What was approximated or left out. pub warnings: Vec, + /// Notes on the options: the preview or thumbnail asked for that was left out. + pub setting_notes: Vec, } /// The `%%BoundingBox` of `region` with PostScript's origin at document point `origin`: whole @@ -229,11 +231,11 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb let baked = vectorcraft_effects::bake_document(doc); let doc = baked.as_ref().unwrap_or(doc); let page = scene::Scene::new(doc, opts).run(); - let mut warnings = page.warnings; + let mut setting_notes = vec![]; let thumb = thumbnail.and_then(|t| match tiff::png(t) { Ok(png) => Some(png), Err(e) => { - warnings.push(format!("the thumbnail was left out: {e}")); + setting_notes.push(format!("the thumbnail was left out: {e}")); None } }); @@ -241,7 +243,7 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb let bytes = match (opts.preview, preview) { (Preview::None, _) => ps.into_bytes(), (_, None) => { - warnings.push("the preview was left out: nothing was rendered for it".into()); + setting_notes.push("the preview was left out: nothing was rendered for it".into()); ps.into_bytes() } (kind, Some(img)) => { @@ -249,7 +251,7 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb dos_eps(ps.as_bytes(), &tiff)? } }; - Ok(EpsOutput { bytes, warnings }) + Ok(EpsOutput { bytes, warnings: page.warnings, setting_notes }) } /// Comment lines that start a data section the file carries (`%VectorCraft_BeginData: `) diff --git a/crates/eps/src/print.rs b/crates/eps/src/print.rs index 99dffd8225..0ccd7a6331 100644 --- a/crates/eps/src/print.rs +++ b/crates/eps/src/print.rs @@ -181,7 +181,7 @@ pub fn print(pages: &[PrintPage], order: &[usize], job: &PrintJob) -> Result Result, PdfError> { export_with_report(doc, opts).map(|r| r.bytes) } -/// Like [`export`], also returning warnings: options accepted but not applied yet (see -/// [`crate::PdfSettings::warnings`]) and features approximated or dropped. +/// Like [`export`], also returning warnings, features approximated or dropped, and notes on the +/// options: options accepted but not applied yet (see [`crate::PdfSettings::warnings`]) or applied +/// another way. pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); @@ -63,9 +64,10 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let display_doc_title = opts.display_doc_title || opts.title.as_deref().is_some_and(|title| !title.trim().is_empty()); let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?; // Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it). - let mut warnings = set.warnings(); + let mut warnings = vec![]; + let mut settings = set.warnings(); if raised.is_some() { - warnings.push(if opts.settings.compatibility == Compatibility::Pdf14 { + settings.push(if opts.settings.compatibility == Compatibility::Pdf14 { "PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6".into() } else { "PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6".into() @@ -75,7 +77,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha if let Some(native) = native { w.pdf.document_mut().embed_file(crate::editing::embedded_file(native, set.compression.compress_text, now.map(date_time))); } else if set.preserve_editing { - warnings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into()); + settings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into()); } let mut ex = Exporter::new(doc, set); @@ -85,7 +87,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha // A knockout page group draws the layers' objects one by one, not layer by layer. ex.layers = set.writes_layers() && !doc.page_knockout; if set.writes_layers() && doc.page_knockout { - warnings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into()); + settings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into()); } ex.overprint = set.advanced.overprint == crate::Overprint::Preserve; let bleed = set.bleed_of(doc); @@ -142,7 +144,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; let (layers, overprinted, invisible) = (ex.layers, ex.overprinted, ex.invisible); w.absorb(ex); - let (bytes, more) = match w.finish(left_out, display_doc_title) { + let (bytes, more, catalog) = match w.finish(left_out, display_doc_title) { Ok(done) => done, Err(e) => return Err(writer_error(doc, opts, left_out, &long, e)), }; @@ -155,11 +157,13 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha // The writer's metadata has no copyright: it is added to the XMP packet. let bytes = crate::post::rights(bytes, &doc.metadata)?; warnings.extend(more); + settings.extend(catalog); let bytes = crate::post::user_units(bytes, &units)?; // A PDF 1.5 file written as PDF 1.6 keeps the encryption PDF 1.5 has (128-bit RC4). - let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?; + let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut settings)?; warnings.dedup(); - Ok(ExportReport { bytes, warnings }) + settings.dedup(); + Ok(ExportReport { bytes, warnings, setting_notes: settings }) } /// The artboards `opts` exports, in page order, with the boxes of their pages: Marks and Bleeds diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index 027025ad2c..9f6d9d6906 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -26,6 +26,10 @@ pub(crate) struct Writer { images: Vec<(String, Image)>, } +/// What [`Writer::finish`] gives: the file, the warnings of its drawing, and the notes on the +/// output intent asked for ([`crate::ExportReport::setting_notes`]). +pub(crate) type Finished = (Vec, Vec, Vec); + /// One page as [`Writer::page`] draws it: the art placed in a drawing space ([`Self::place`]), /// which [`Self::view`] maps onto the page. pub(crate) struct Sheet<'a> { @@ -252,10 +256,11 @@ impl Writer { } /// The finished file (with the output intent and Trapped entries, and checked against its - /// standard) and the warnings of its drawing. PDF/X-1a spot colours keep their CMYK + /// standard), the warnings of its drawing, and the notes on the output intent asked for + /// ([`crate::ExportReport::setting_notes`]). PDF/X-1a spot colours keep their CMYK /// alternates. When the writer couldn't decode one of the images, its key is added to /// `left_out` ([`leaving_out_undecodable`]) and the writer's error is returned. - pub(crate) fn finish(mut self, left_out: &mut HashSet, display_doc_title: bool) -> Result<(Vec, Vec), PdfError> { + pub(crate) fn finish(mut self, left_out: &mut HashSet, display_doc_title: bool) -> Result { let images = std::mem::take(&mut self.images); let mut bytes = self.pdf.finish().map_err(|e| { if let write::WriteError::Serialization(k) = &e @@ -270,10 +275,9 @@ impl Writer { } let lab = !self.lab_spots.is_empty() && !self.out.standard.cmyk_only(); let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes }; - let (bytes, more) = self.out.write_catalog(bytes, display_doc_title)?; - self.warnings.extend(more); + let (bytes, settings) = self.out.write_catalog(bytes, display_doc_title)?; self.warnings.extend(self.rgb_in_cmyk.warning()); - Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings)) + Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings, settings)) } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 234f4b8a8d..2ba025866d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -213,7 +213,10 @@ pub struct ImportReport { #[derive(Clone, Debug)] pub struct ExportReport { pub bytes: Vec, + /// What the file leaves out of the document or writes differently. pub warnings: Vec, + /// Notes on the options: one the export didn't apply or applied another way. + pub setting_notes: Vec, } #[derive(Debug, thiserror::Error, PartialEq)] diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index 55ab42ced3..77bc7d1d66 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -349,7 +349,7 @@ impl ColorOut { } /// `pdf` with the output intent and Trapped entries, and a PDF/X file's identification - /// ([`crate::pdfx`]) → (the file, warnings). + /// ([`crate::pdfx`]) → (the file, notes on the output intent asked for). pub(crate) fn write_catalog(&self, pdf: Vec, display_doc_title: bool) -> Result<(Vec, Vec), PdfError> { let cat = self.catalog.as_ref(); if cat.is_none() && !display_doc_title { diff --git a/crates/pdf/src/post.rs b/crates/pdf/src/post.rs index e735d079fe..c6fdd8048d 100644 --- a/crates/pdf/src/post.rs +++ b/crates/pdf/src/post.rs @@ -204,13 +204,13 @@ fn linearised(pdf: &[u8], set: &PdfSettings) -> Result, PdfError> { } /// The file the export wrote (`pdf`), finished as `opts` say; `editing`: it carries the editing -/// data, to seal once the pages are final. What can't be done comes back in `warnings`. -pub(crate) fn finish(pdf: Vec, opts: &PdfOptions, editing: bool, warnings: &mut Vec) -> Result, PdfError> { +/// data, to seal once the pages are final. The options it can't apply come back in `settings`. +pub(crate) fn finish(pdf: Vec, opts: &PdfOptions, editing: bool, settings: &mut Vec) -> Result, PdfError> { let set = &opts.settings; let pdf = match (set.thumbnails, opts.thumbnails.is_empty()) { (true, false) => thumbnails(pdf, &opts.thumbnails, set.standard.is_pdfx())?, (true, true) => { - warnings.push("page thumbnails need the pages drawn, which weren't given: none are embedded".into()); + settings.push("page thumbnails need the pages drawn, which weren't given: none are embedded".into()); pdf } (false, _) => pdf, @@ -229,7 +229,7 @@ pub(crate) fn finish(pdf: Vec, opts: &PdfOptions, editing: bool, warnings: & match linearised(&pdf, set) { Ok(out) => Ok(out), Err(e) => { - warnings.push(format!("the file isn't optimized for fast web view: {e}")); + settings.push(format!("the file isn't optimized for fast web view: {e}")); crate::encrypt::protect(pdf, set) } } diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs index b11a764be2..0bad9d9f60 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -470,10 +470,10 @@ fn print_inner(doc: &Document, opts: &PrintOptions, left_out: &mut HashSet assert!(r.warnings.iter().any(|x| x.contains(w)), "{v}: {:?}", r.warnings), - None => assert!(r.warnings.is_empty(), "{v}: {:?}", r.warnings), + Some(w) => assert!(r.setting_notes.iter().any(|x| x.contains(w)), "{v}: {:?}", r.setting_notes), + None => assert!(r.warnings.is_empty() && r.setting_notes.is_empty(), "{v}: {:?} {:?}", r.warnings, r.setting_notes), } } // A knockout page group draws objects, not layers. d.page_knockout = true; let r = export(&d, json!({"createLayers": true})); oc_properties(&r.bytes, None, |props, _| assert!(props.is_none())); - assert!(r.warnings.iter().any(|w| w.contains("knockout page group")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("knockout page group")), "{:?}", r.setting_notes); } #[test] diff --git a/crates/pdf/src/tests/marks.rs b/crates/pdf/src/tests/marks.rs index 603138bd49..655ed43ea4 100644 --- a/crates/pdf/src/tests/marks.rs +++ b/crates/pdf/src/tests/marks.rs @@ -251,8 +251,9 @@ fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() { assert!(r.bytes.starts_with(format!("%PDF-{header}").as_bytes()), "{}", c.label()); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [3.938], "{}", c.label()); assert!((raw_meters(&r.bytes).0 - 20.0).abs() < 0.001, "{}", c.label()); - let raised = r.warnings.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit") && w.ends_with("the file is written as PDF 1.6")); - assert_eq!(raised, c == Compatibility::Pdf15, "{}: {:?}", c.label(), r.warnings); + let raised = + r.setting_notes.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit") && w.ends_with("the file is written as PDF 1.6")); + assert_eq!(raised, c == Compatibility::Pdf15, "{}: {:?}", c.label(), r.setting_notes); } } } @@ -288,11 +289,11 @@ fn pdfa2b_at_pdf_1_4_writes_a_long_page_as_pdf_1_6_within_14400_units() { assert!(media[2] - media[0] <= 14_400.0 && media[3] - media[1] <= 14_400.0, "{side} {in_points}: {media:?}"); assert!(((media[2] - media[0]) * unit[0] - side).abs() < side * 0.001, "{side} {in_points}: {media:?} {unit:?}"); let raised = "PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6"; - assert!(r.warnings.iter().any(|w| w == raised), "{side} {in_points}: {:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w == raised), "{side} {in_points}: {:?}", r.setting_notes); } let r = report(&sized(14_400.0, 300.0), set.clone()).unwrap(); assert!(r.bytes.starts_with(b"%PDF-1.4"), "{in_points}"); - assert!(!r.warnings.iter().any(|w| w.contains("has no UserUnit")), "{in_points}: {:?}", r.warnings); + assert!(!r.setting_notes.iter().any(|w| w.contains("has no UserUnit")), "{in_points}: {:?}", r.setting_notes); } } @@ -500,7 +501,7 @@ fn long_pages_in_points_keeps_every_version_and_changes_nothing_where_pages_are_ assert!(r.bytes.starts_with(format!("%PDF-{}", c.id()).as_bytes()), "{}", c.label()); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new(), "{}", c.label()); assert!((raw_meters(&r.bytes).0 - 10.0).abs() < 0.001, "{}", c.label()); - assert!(!r.warnings.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit")), "{}: {:?}", c.label(), r.warnings); + assert!(!r.setting_notes.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit")), "{}: {:?}", c.label(), r.setting_notes); assert_eq!(long_page_warnings(&r).len(), 1, "{}: {:?}", c.label(), r.warnings); if matches!(c, Compatibility::Pdf13 | Compatibility::Pdf14) { assert_eq!(r.bytes, report(&d, set).unwrap().bytes, "{}", c.label()); diff --git a/crates/pdf/src/tests/output.rs b/crates/pdf/src/tests/output.rs index f7732821c1..0ab4d58c18 100644 --- a/crates/pdf/src/tests/output.rs +++ b/crates/pdf/src/tests/output.rs @@ -463,7 +463,7 @@ fn pdf_a_keeps_its_own_output_intent() { let r = pdf(&d, json!({"standard": "pdfA2b", "output": {"outputIntent": GENERIC_CMYK, "trapped": true}})); let text = String::from_utf8_lossy(&r.bytes); assert!(text.contains("GTS_PDFA1") && !text.contains("GTS_PDFX") && !text.contains("/Trapped")); - assert!(r.warnings.iter().any(|w| w.contains("PDF/A")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("PDF/A")), "{:?}", r.setting_notes); } #[test] diff --git a/crates/pdf/src/tests/pdf13.rs b/crates/pdf/src/tests/pdf13.rs index 4df56f3ff0..af7bbefe5d 100644 --- a/crates/pdf/src/tests/pdf13.rs +++ b/crates/pdf/src/tests/pdf13.rs @@ -69,7 +69,7 @@ fn pdf_1_3_files_are_encrypted_with_40_bit_rc4() { let o = options(json!({"compatibility": "1.3", "security": {"openPassword": "open", "permissionsPassword": "owner", "plaintextMetadata": true}})); let r = export_with_report(&doc(1.0), &o).unwrap(); assert!(String::from_utf8_lossy(&r.bytes).contains("/Filter/Standard/V 1/R 2/Length 40/P ")); - assert!(r.warnings.iter().any(|w| w.contains("PDF 1.3 encryption covers the metadata too")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("PDF 1.3 encryption covers the metadata too")), "{:?}", r.setting_notes); assert!(matches!(import(&r.bytes), Err(PdfError::NeedsPassword))); for password in ["open", "owner"] { let opened = import_with_report(&r.bytes, &ImportOptions { password: Some(password.into()), ..Default::default() }).unwrap(); diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 2f59563e70..897e7241b4 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -349,7 +349,7 @@ fn every_copy_on_a_sheet_draws_the_same_symbol_instances() { w.page(&mut ex, &sheet).unwrap(); assert_eq!(ex.symbol_limit, 10, "the limit is the export's again after the sheet"); w.absorb(ex); - let (bytes, _) = w.finish(&mut std::collections::HashSet::new(), false).unwrap(); + let (bytes, ..) = w.finish(&mut std::collections::HashSet::new(), false).unwrap(); let p = pages(&bytes); let left = p[0].1.iter().filter(|(r, _)| r.x0 < 200.0).count(); assert_eq!((left, p[0].1.len() - left), (3, 3), "one instance in each copy: {:?}", p[0].1); diff --git a/crates/pdf/src/tests/realtext.rs b/crates/pdf/src/tests/realtext.rs index b3e105e4f6..3db63a4b2a 100644 --- a/crates/pdf/src/tests/realtext.rs +++ b/crates/pdf/src/tests/realtext.rs @@ -689,7 +689,7 @@ fn subset_thresholds_below_100_percent_are_accepted_with_a_warning() { let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Subset", style(24.0))]); let settings: PdfSettings = serde_json::from_value(json!({"advanced": {"outlineText": false, "fontSubsetPercent": 40}})).unwrap(); let r = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap(); - assert!(r.warnings.iter().any(|w| w.contains("subset")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("subset")), "{:?}", r.setting_notes); assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), ["Subset"]); } diff --git a/crates/pdf/src/tests/settings.rs b/crates/pdf/src/tests/settings.rs index 1750beebd2..e159b7b162 100644 --- a/crates/pdf/src/tests/settings.rs +++ b/crates/pdf/src/tests/settings.rs @@ -73,7 +73,7 @@ fn checks_refuse_what_the_writer_cant_honour() { } #[test] -fn options_not_applied_yet_come_back_as_warnings() { +fn options_not_applied_yet_come_back_as_setting_notes() { assert!(PdfSettings::default().warnings().is_empty()); for (v, word) in [ (json!({"createLayers": true, "compatibility": "1.4"}), "PDF 1.5"), @@ -110,7 +110,8 @@ fn options_not_applied_yet_come_back_as_warnings() { let r = export_with_report(&doc(), &PdfOptions { settings: settings(json!({"thumbnails": true, "marks": {"trim": true}})), ..Default::default() }) .unwrap(); - // The writer embeds the thumbnails it is given: without them, the one warning says so. - assert!(r.warnings.len() == 1 && r.warnings[0].contains("thumbnails need the pages drawn"), "{:?}", r.warnings); + // The writer embeds the thumbnails it is given: without them, the one note on the options says so. + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.setting_notes.len() == 1 && r.setting_notes[0].contains("thumbnails need the pages drawn"), "{:?}", r.setting_notes); assert!(r.bytes.starts_with(b"%PDF-1.7")); } diff --git a/crates/pdf/src/tests/webview.rs b/crates/pdf/src/tests/webview.rs index a40db425e7..6fc50d0d54 100644 --- a/crates/pdf/src/tests/webview.rs +++ b/crates/pdf/src/tests/webview.rs @@ -345,7 +345,7 @@ fn thumbnails_are_embedded_on_every_page() { assert_eq!(import(&r.bytes).unwrap().artboards.len(), 3); // Asked for without the pages drawn: none, with a warning. Thumbnails too large are refused. let r = export_with_report(&d, &options(json!({"thumbnails": true}))).unwrap(); - assert!(r.warnings.iter().any(|w| w.contains("thumbnails")) && crate::lab_spot::find(&r.bytes, b"/Thumb", 0).is_none()); + assert!(r.setting_notes.iter().any(|w| w.contains("thumbnails")) && crate::lab_spot::find(&r.bytes, b"/Thumb", 0).is_none()); o.thumbnails[1] = thumb(107, 20, 0); assert!(matches!(export(&d, &o), Err(PdfError::BadSetting(_)))); } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 8eb18626f3..667e32a4ca 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -102,6 +102,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: region_boxes: None, art_reach: HashMap::new(), warnings: vec![], + setting_notes: vec![], id_prefix, linked: Vec::new(), instance_xf: Affine::IDENTITY, @@ -137,7 +138,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: // XML names cannot use character references. Preserve names that Latin-1 cannot encode. let encoding = if opts.encoding == Encoding::Latin1 && w.non_latin1_names { - w.warn("Unicode data attribute names require UTF-8; SVG was written in UTF-8"); + w.note_setting("Unicode data attribute names require UTF-8; SVG was written in UTF-8"); Encoding::Utf8 } else { opts.encoding @@ -223,7 +224,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: out.push_str(&w.body); out.push_str(""); out.push_str(nl); - Output { svg: out, linked: w.linked, warnings: w.warnings, encoding } + Output { svg: out, linked: w.linked, warnings: w.warnings, setting_notes: w.setting_notes, encoding } } type ColorizedKey = (String, u32, u32, Option<[u8; 3]>); @@ -268,6 +269,8 @@ struct Writer<'a> { brushes: Option>, /// Features written approximately (once each). warnings: Vec, + /// Notes on how the options were applied (once each). + setting_notes: Vec, /// Prepended to every id and class name ([`ObjectIds::Unique`]). id_prefix: &'a str, /// Image files the SVG links to ([`ImageMode::Link`]). diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index 7c30f23f5a..150c3605d5 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -525,11 +525,21 @@ impl Writer<'_> { }) } + /// Note `w`, once, about something of the document that the file leaves out or that reaches + /// it changed ([`crate::Output::warnings`]). A note about an option goes to [`Self::note_setting`]. pub(super) fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); } } + + /// Note `w`, once, about an option that wasn't applied or was applied another way while the + /// art reaches the file as it looks ([`crate::Output::setting_notes`]). + pub(super) fn note_setting(&mut self, w: &str) { + if !self.setting_notes.iter().any(|x| x == w) { + self.setting_notes.push(w.to_string()); + } + } } /// An element that has both its own classes and a generated `cls-N` would write `class` twice: diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index e5a5248400..27893bbe74 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -521,7 +521,7 @@ impl Writer<'_> { continue; }; if !font.subset { - self.warn(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting")); + self.note_setting(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting")); } let shared = uses.iter().filter(|o| described(o) == described(u)).count() > 1; let range = if shared { format!(";unicode-range:{}", unicode_range(&u.chars)) } else { String::new() }; diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 8a447f6c38..ef6a312fe5 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -294,6 +294,9 @@ pub struct Output { pub linked: Vec, /// Features the export approximates. pub warnings: Vec, + /// Notes on the options: one the export didn't apply or applied another way (an encoding, a + /// font embedded whole). + pub setting_notes: Vec, /// The encoding [`Self::bytes`] writes. pub encoding: Encoding, } @@ -361,10 +364,11 @@ pub fn export(doc: &Document, opts: &ExportOptions) -> String { export_full(doc, opts, None).svg } -/// Export a document as an SVG string, also returning warnings about approximated features. +/// Export a document as an SVG string, also returning warnings about approximated features, +/// then the notes on the options ([`Output::setting_notes`]). pub fn export_with_report(doc: &Document, opts: &ExportOptions) -> (String, Vec) { let o = export_full(doc, opts, None); - (o.svg, o.warnings) + (o.svg, o.warnings.into_iter().chain(o.setting_notes).collect()) } /// Export a document as SVG plus the image files it links to. `native`: the document in the diff --git a/crates/svg/tests/it/data_attributes.rs b/crates/svg/tests/it/data_attributes.rs index d652b9665f..f8151ed7da 100644 --- a/crates/svg/tests/it/data_attributes.rs +++ b/crates/svg/tests/it/data_attributes.rs @@ -98,7 +98,7 @@ fn unicode_data_names_survive_each_encoding() { } if encoding == Encoding::Latin1 { assert_eq!(out.encoding, Encoding::Utf8, "XML names cannot use character references"); - assert!(!out.warnings.is_empty()); + assert!(!out.setting_notes.is_empty()); assert!(!out.svg.contains("ISO-8859-1")); } else { assert_eq!(out.encoding, encoding); diff --git a/crates/svg/tests/it/encoding_profile_fonts.rs b/crates/svg/tests/it/encoding_profile_fonts.rs index f0ea9804d0..5468953aa4 100644 --- a/crates/svg/tests/it/encoding_profile_fonts.rs +++ b/crates/svg/tests/it/encoding_profile_fonts.rs @@ -321,7 +321,7 @@ fn font_licences_are_respected() { let size = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf").len(); assert!(fonts[0].2.len() + 64 >= size, "embedded whole: {} of {size} bytes", fonts[0].2.len()); assert!(o.warnings.iter().any(|w| w.contains("Sealed Sans 3") && w.contains("doesn't allow embedding")), "{:?}", o.warnings); - assert!(o.warnings.iter().any(|w| w.contains("Wholed Sans 3") && w.contains("whole")), "{:?}", o.warnings); + assert!(o.setting_notes.iter().any(|w| w.contains("Wholed Sans 3") && w.contains("whole")), "{:?}", o.setting_notes); } proptest::proptest! { diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index dff4fa0dd2..b43193d148 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -155,6 +155,23 @@ mod tests { assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); } + /// An export whose encoder notes an option it didn't apply (lossy WebP) before what the file + /// loses (type in a font that isn't installed): the status bar names what the file loses, and + /// the hover lists it first. + #[test] + fn an_export_names_what_the_file_loses_before_a_note_on_the_options() { + let (mut app, _) = app(); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let r = export(&mut app, Some("webp"), Some("/out/d.webp".into()), &json!({"lossless": false})).unwrap(); + let notes = notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert!(notes[0].contains("Nonexistent Sans Probe"), "{r}"); + assert!(notes[1].starts_with("lossy WebP"), "{r}"); + assert_eq!(app.ui.status, exported_with_notes("/out/d.webp", 2, ¬es[0])); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); + } + /// #1685: a save or an export that can't write its file says so in a dialog that names the /// file and the reason and stays until it is closed; the document stays modified and keeps /// the file it had. An agent's request only gets the error, and a message that arrives while diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 03e0196267..10eee5800c 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -83,7 +83,7 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option 1 { out["files"] = json!(files); } diff --git a/docs/mcp.md b/docs/mcp.md index fa22c66414..5c93f5dc5e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -773,7 +773,11 @@ a path whose extension names another format has it replaced by the template's extension, and any other path that does not end in that extension has it added, so `card.png` is saved as `card.vctemplate` and `card` as `card.vctemplate`; the suggested name and the Templates folder come back without a path), `file.newFromTemplate {path}`, `file.revert` and `file.formatOptions {format?}` (a format's options -with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. A document saved as SVG or PDF +with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. +In the replies of the save and export commands, and of `clipboard.exportPdf` and `clipboard.exportPng`, `warnings` +lists what the file leaves out of the document or writes differently first. The notes on the options follow: an +option not applied or applied another way while the art reaches the file as it looks, or something an option turns +off, such as `includeImages: false` in DXF. Each kind keeps the order the writers give it. A document saved as SVG or PDF remembers its options, so the next save reuses them; a document not yet saved as a native file is offered the VectorCraft Options the last native Save or Save As used (not its artboards, nor Use Compression). A PDF opened with `pages`, `cropTo` or `password` is only part of the file, so Save asks for a new name instead of writing it back. @@ -885,7 +889,7 @@ Long-tail commands: PDF files take the Save PDF dialog's options: `preset`, `standard`, `compatibility`, the General toggles and the `compression`, `marks`, `bleed`, `output`, `advanced` and `security` sections (`list_commands` with filter `exportPdf` documents every field). `document.exportPdf` and `export` (format `pdf`, the options in `options`) return `warnings`: -options accepted but not applied yet, and features approximated or left out. An image whose pixel data can't be +features approximated or left out, then options accepted but not applied yet. An image whose pixel data can't be decoded (a PNG cut short) is left out of the pages, and `warnings` holds the note "an image couldn't be decoded and is missing from the picture" once, as raster exports give it; `.ai` saves and `file.print` do the same. A standard with a PDF version it doesn't allow is refused (PDF/A-2b at 1.3 or 2.0, PDF/X-4 above 1.6, PDF/X-1a and PDF/X-3 above 1.4); @@ -6290,7 +6294,8 @@ the later file is written as `-2`, `-3`… (`Artboard-1-2.png`) and listed under rows write one file, and row folders that differ only in case (`Print`, `print`) are one folder, spelled as the first row spells it. The document remembers the last params: `document.exportSettings`. As in `document.export`, the reply's `warnings` lists the notes of every file exported (an image that couldn't be decoded, type written in the -fallback font…), each note once; the export still writes or returns every file. +fallback font…), each note once, what any of the files loses before the notes on the options; the export still +writes or returns every file. Batch exports (#1935): - **What:** `layers: [id…]` (each layer's art alone) or `selection: "one" | "each"` instead of artboards diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d42a4d988b..87b21331f2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -580,6 +580,7 @@ since 2026-10-01. - **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. - **Notes of an open in the status bar:** After File › Open the status message names the first note that says reading the file left something out (the notes that make Save ask before writing over the file), else the first note, and counts them all. With several notes, resting the pointer on the message lists them in that order, as many as fit in the window and at most 30, then how many more. Screen readers receive the list after the message. Scores and estimates are unchanged. - **Notes of a save or an export in the status bar:** After Save, Save As, Save a Copy, Save as Template, Save a Timestamped Copy, Save as PDF, Export As, Export Selection, Export for Screens, Asset Export, Save for Web, Preview in Browser, Save for Office Documents, Text Export Options, a print to a file or the CSS Properties panel's export, the status message names the first note and counts them all. With several notes, resting the pointer on the message lists them in the order the reply gives them (a save's notes start with what the format loses), as it lists an open's notes. Scores and estimates are unchanged. +- **What a save or an export loses, named first:** Each writer marks its notes as a loss (something of the document the file leaves out or writes differently) or a note on the options (an option it didn't apply or applied another way while the art reaches the file as it looks, or something an option turns off). The replies of the save and export commands list the losses first, each kind in the order given, across every file an export writes. The status message after a save or an export then names a loss when there is one: an export to lossy WebP of type in a font that isn't installed names the fallback-font note instead of the lossy WebP note. A print to a file keeps the order its notes are given in. Scores and estimates are unchanged. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. From 714d72971cdb3508ab40beba55952fc630cac06c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 09:16:50 -0800 Subject: [PATCH 11/23] 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 12/23] 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 f207ac35183874b5a8d237fc5dff52ee20c1fddb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 10:39:17 -0800 Subject: [PATCH 13/23] Type: a Tabs indent drag over many paragraphs no longer takes time in the square of their number When a Tabs panel indent marker gave the paragraphs of a threaded story values of their own, the panel sent one text.setFormat for each paragraph, and the story flowed through its frames again after every one of them. text.setFormat now takes paragraphIndents, a list of {id, start, leftIndent?, firstLineIndent?} entries, one per paragraph. It checks every entry before it changes anything, rebuilds each type object's paragraph styles once and makes the whole change one edit, so the story flows again once. The markers send one such command inside the same labeled command.batch, and a drag that changes no paragraph runs nothing. - With the Selection tool, a marker drag on any frame of a threaded story now sets every paragraph of the story, in its locked and hidden frames too. - The single-value form of text.setFormat fails, naming the key, when a number such as leftIndent is given as a string, a switch or a list. - A null value of any text.setFormat attribute leaves it as it is, as an omitted one does. A call whose attributes are all null fails with "nothing to change" and records no edit and no undo step. - A text.setFormat call whose values are the ones the text already has returns its ids and records no edit, no undo step and no new revision. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/panelcmds.rs | 194 ++++++++++++++++++-- crates/engine/src/cmd/threads/tests.rs | 161 ++++++++++++++++ crates/engine/src/cmd/typecmd/area_tests.rs | 124 +++++++++++++ crates/engine/src/cmd/variablecmds.rs | 4 +- crates/ui-egui/src/panels/tabs.rs | 117 +++++++----- crates/ui-egui/src/panels/tabs/tests.rs | 125 ++++++++++--- docs/mcp.md | 22 +++ docs/target-app-parity.md | 2 +- 8 files changed, 656 insertions(+), 93 deletions(-) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 83a0655284..f94494625b 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", has_doc, set_format ), @@ -467,6 +467,9 @@ fn list_level_param(p: &Value, cmd: &str) -> Result> { fn set_format(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setFormat"; + if p.get("paragraphIndents").is_some() { + return set_paragraph_indents(s, p); + } let ids = super::typecmd::text_targets(s, p, C)?; check_switches( p, @@ -486,6 +489,27 @@ fn set_format(s: &mut Session, p: &Value) -> Result { ], C, )?; + // Each value that `num` reads below must be a number or null. + super::check_numbers( + p, + &[ + "baselineShift", + "hScale", + "vScale", + "rotation", + "leftIndent", + "rightIndent", + "firstLineIndent", + "spaceBefore", + "spaceAfter", + "wordsLongerThan", + "afterFirst", + "beforeLast", + "hyphenLimit", + "hyphenationZone", + ], + C, + )?; let num = |k: &str| p.get(k).and_then(Value::as_f64); let flag = |k: &str| p.get(k).and_then(Value::as_bool); let kerning = match p.get("kerning") { @@ -557,7 +581,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "kashida", "composer", ]; - if !keys.iter().any(|k| p.get(*k).is_some()) { + // A null value leaves its attribute as it is, as an omitted one does. + if !keys.iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { return Err(bad(C, "nothing to change")); } // The anti-aliasing method belongs to the whole type object; each frame keeps its own method. @@ -587,7 +612,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let tcy_up_down = super::textedit::tcy_offset_param(p, "tcyUpDown", C)?; let tcy_left_right = super::textedit::tcy_offset_param(p, "tcyLeftRight", C)?; let mojikumi = match p.get("mojikumi") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::Mojikumi::None, Some("lineEndHalf") => vectorcraft_doc::Mojikumi::LineEndHalf, @@ -595,7 +620,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let kinsoku = match p.get("kinsoku") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::Kinsoku::None, Some("hard") => vectorcraft_doc::Kinsoku::Hard, @@ -604,7 +629,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let kinsoku_type = match p.get("kinsokuType") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("pushInFirst") => vectorcraft_doc::KinsokuShoriType::PushInFirst, Some("pushOutFirst") => vectorcraft_doc::KinsokuShoriType::PushOutFirst, @@ -613,7 +638,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let direction = match p.get("direction") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("auto") => None, Some("leftToRight") => Some(vectorcraft_doc::ParaDirection::LeftToRight), @@ -622,7 +647,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let leading_model = match p.get("leadingModel") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("romanBaseline") => vectorcraft_doc::LeadingModel::RomanBaseline, Some("emBoxTop") => vectorcraft_doc::LeadingModel::EmBoxTop, @@ -630,7 +655,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let burasagari = match p.get("burasagari") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::Burasagari::None, Some("standard") => vectorcraft_doc::Burasagari::Standard, @@ -639,7 +664,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let kashida = match p.get("kashida") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::text::Kashida::None, Some("short") => vectorcraft_doc::text::Kashida::Short, @@ -670,7 +695,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let spacing = |k: &str| spacing_param(p, k, C); let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?); let single_word = match p.get("singleWordJustification") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("fullJustify") => SingleWordJustify::FullJustify, Some("alignLeft") => SingleWordJustify::AlignLeft, @@ -680,7 +705,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let auto_leading = match p.get("autoLeading") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(C, "`autoLeading` must be a number"))?), }; // A paragraph whose Minimum, Desired and Maximum would no longer be in order. @@ -787,12 +812,20 @@ fn set_format(s: &mut Session, p: &Value) -> Result { l.level = list_level.unwrap_or(l.level); } }; - s.edit("Character", |d, _| { + // The change is made on a copy first: a call whose values are the ones the text already has + // records no edit, undo step or revision. Only the type objects and graph label styles the call + // changes are compared, not the whole document. + super::variablecmds::settle_typing(s)?; + let mut doc = (*s.doc()?.doc).clone(); + let mut changed = false; + { + let d = &mut doc; // The paragraph change, for the graph labels to keep. let mut para_change = None; let out_of_order_error = |name: &str| bad(C, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum")); for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let before = t.clone(); let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?; let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?; let local = |v: Option| v.map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose(); @@ -896,16 +929,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result { return Err(out_of_order_error(name)); } super::typecmd::refresh_bounds(t); + changed |= *t != before; para_change = Some(paras); } // Graph labels keep a change made to all of their text through the graph being drawn again. if range.is_none() { let shift = num("baselineShift").map(|v| v.clamp(-1296.0, 1296.0)); let scale = num("hScale").map(|v| v.clamp(1.0, 10000.0)); - super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right))); + changed |= super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right))); if let Some(paras) = para_change { // The kept attributes are checked too: they may differ from the labels'. - super::graph::capture_label_paragraphs(d, &ids, &|para| { + changed |= super::graph::capture_label_paragraphs(d, &ids, &|para| { let mut out_of_order = None; paras(para, &mut out_of_order); out_of_order.map_or(Ok(()), |name| Err(out_of_order_error(name))) @@ -915,14 +949,20 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = anti_alias { for id in &ids { if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + changed |= t.anti_alias != v; t.anti_alias = v; } } // Graph labels keep the method through the graph being drawn again. - super::graph::capture_label_anti_alias(d, &ids, v); + changed |= super::graph::capture_label_anti_alias(d, &ids, v); } - Ok(()) - })?; + } + if changed { + s.edit("Character", |d, _| { + *d = doc; + Ok(()) + })?; + } let mut result = json!({ "ids": ids.iter().map(|i| i.0).collect::>() }); if let Some(kerning) = p.get("kerning") { result["kerning"] = kerning.clone(); @@ -930,6 +970,126 @@ fn set_format(s: &mut Session, p: &Value) -> Result { Ok(result) } +/// The most entries `paragraphIndents` takes in one `text.setFormat` call. +pub(crate) const MAX_PARAGRAPH_INDENTS: usize = 100_000; + +/// The indents one `paragraphIndents` entry gives its paragraph. +#[derive(Clone, Copy, Debug, PartialEq)] +struct ParagraphIndent { + left: Option, + first_line: Option, +} + +/// A type object that `paragraphIndents` names: the bytes where its paragraphs start and the +/// indents its entries give them, by paragraph index. +struct IndentedFrame { + id: NodeId, + starts: Vec, + changes: Vec<(usize, ParagraphIndent)>, +} + +/// `text.setFormat` with `paragraphIndents`: each entry names one paragraph of a type object, by +/// the object and the byte where the paragraph starts, and sets its left and first-line indents. +/// Every entry is checked against the document before anything changes. Each type object's +/// paragraph styles are rebuilt once and the whole change is one edit, so a threaded story flows +/// again once however many of its paragraphs change. When no entry changes its paragraph, nothing +/// is edited. +fn set_paragraph_indents(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.setFormat"; + const KEY: &str = "paragraphIndents"; + if let Some(other) = p.as_object().and_then(|o| o.keys().find(|k| *k != KEY)) { + return Err(bad(C, format!("`{KEY}` takes no `{other}` beside it"))); + } + let entries = p.get(KEY).and_then(Value::as_array).ok_or_else(|| bad(C, format!("`{KEY}` must be a list")))?; + if entries.is_empty() { + return Err(bad(C, format!("`{KEY}` is empty"))); + } + if entries.len() > MAX_PARAGRAPH_INDENTS { + return Err(bad(C, format!("`{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {})", entries.len()))); + } + let d = &s.doc()?.doc; + // The type objects in the order they first appear. + let mut frames: Vec = vec![]; + let mut frame_at: std::collections::HashMap = std::collections::HashMap::new(); + let mut given: std::collections::HashMap<(NodeId, usize), ParagraphIndent> = std::collections::HashMap::new(); + for (n, e) in entries.iter().enumerate() { + let e = e.as_object().ok_or_else(|| bad(C, format!("`{KEY}` entry {n} must be an object")))?; + if let Some(k) = e.keys().find(|k| !["id", "start", "leftIndent", "firstLineIndent"].contains(&k.as_str())) { + return Err(bad(C, format!("`{KEY}` entry {n}: unknown key `{k}`"))); + } + let indent = |k: &str| -> Result> { + match e.get(k) { + None => Ok(None), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && x.abs() <= MAX_PARA => Ok(Some(x)), + _ => Err(bad(C, format!("`{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt"))), + }, + } + }; + let change = ParagraphIndent { left: indent("leftIndent")?, first_line: indent("firstLineIndent")? }; + if change.left.is_none() && change.first_line.is_none() { + return Err(bad(C, format!("`{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent`"))); + } + let id = NodeId(e.get("id").and_then(Value::as_u64).ok_or_else(|| bad(C, format!("`{KEY}` entry {n}: `id` must be an object id")))?); + let start = index(e.get("start").and_then(Value::as_u64).ok_or_else(|| bad(C, format!("`{KEY}` entry {n}: `start` must be a byte offset")))?); + let k = match frame_at.get(&id) { + Some(k) => *k, + None => { + let Some(NodeKind::Text(t)) = d.node(id).map(|n| &n.kind) else { + return Err(bad(C, format!("`{KEY}` entry {n}: {} is not a type object", id.0))); + }; + let plain = t.plain_text(); + let mut starts = vec![0]; + starts.extend(plain.bytes().enumerate().filter_map(|(i, c)| (c == b'\n').then_some(i + 1))); + frames.push(IndentedFrame { id, starts, changes: vec![] }); + frame_at.insert(id, frames.len() - 1); + frames.len() - 1 + } + }; + let Some(IndentedFrame { starts, changes, .. }) = frames.get_mut(k) else { continue }; + let para = starts.binary_search(&start).map_err(|_| bad(C, format!("`{KEY}` entry {n}: no paragraph of {} starts at byte {start}", id.0)))?; + match given.insert((id, para), change) { + None => changes.push((para, change)), + Some(was) if was == change => {} + Some(_) => { + return Err(bad( + C, + format!("`{KEY}` entry {n} gives the paragraph at byte {start} of {} other indents than an entry before it", id.0), + )); + } + } + } + let ids = json!({ "ids": frames.iter().map(|f| f.id.0).collect::>() }); + // Only the type objects with a paragraph whose indents change are edited. + let differs = |t: &vectorcraft_doc::TextObject, i: usize, c: &ParagraphIndent| { + let para = t.para_at(i); + c.left.is_some_and(|v| v != para.left_indent) || c.first_line.is_some_and(|v| v != para.first_line_indent) + }; + frames.retain(|f| match d.node(f.id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => f.changes.iter().any(|(i, c)| differs(t, *i, c)), + _ => false, + }); + if frames.is_empty() { + return Ok(ids); + } + s.edit("Character", |d, _| { + for f in &frames { + let Some(NodeKind::Text(t)) = d.node_mut(f.id).map(|n| &mut n.kind) else { continue }; + let mut styles = t.paragraph_styles(); + for (i, c) in &f.changes { + if let Some(para) = styles.get_mut(*i) { + para.left_indent = c.left.unwrap_or(para.left_indent); + para.first_line_indent = c.first_line.unwrap_or(para.first_line_indent); + } + } + t.set_paragraph_styles(styles); + super::typecmd::refresh_bounds(t); + } + Ok(()) + })?; + Ok(ids) +} + /// The part of a story paragraph in one frame, as [`convert_list_to_text`] reads it: the frame, /// the frame's paragraph, its bytes there, its list, the marker the frame's layout draws for it /// and the numbered paragraphs the frame counts before it at each list level. diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index 48a1e46fbc..b6ea41df3e 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -1679,3 +1679,164 @@ fn the_frame_a_release_leaves_alone_has_the_bounds_of_the_whole_story() { let cached = t.cached_bounds.unwrap(); assert!((cached.y0 - bounds.y0).abs() < 1e-6 && (cached.y1 - bounds.y1).abs() < 1e-6, "{cached:?} {bounds:?}"); } + +/// Twelve paragraphs that wrap, threaded through three frames, the first two of which end inside +/// a paragraph; the last frame's paragraphs are indented 10 pt. → the session and the frames. +fn split_story() -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 900, "height": 1000})).unwrap(); + let words = "aaa bbb ccc ddd eee fff ggg hhh iii jjj ".repeat(3); + let story = (1..=12).map(|i| format!("p{i} {}", words.trim_end())).collect::>().join("\n"); + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 50}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("text.thread.add", &json!({"from": a, "rect": [300, 10, 150, 50]})).unwrap()["id"].as_u64().unwrap(); + let c = s.execute("text.thread.add", &json!({"from": b, "rect": [600, 10, 250, 950]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setFormat", &json!({"ids": [c], "leftIndent": 10})).unwrap(); + for id in [b, c] { + assert!(text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap().first_para_continues, "frame {id} starts inside a paragraph"); + } + (s, [a, b, c]) +} + +/// Each frame's text, paragraph styles and whether it continues a paragraph. +fn frames_of(s: &Session, frames: &[u64]) -> Vec<(String, Vec, bool)> { + frames + .iter() + .map(|id| { + let t = text_of(&s.doc().unwrap().doc, NodeId(*id)).unwrap(); + (t.plain_text(), t.paragraph_styles(), t.first_para_continues) + }) + .collect() +} + +/// One entry for each paragraph of the story: a paragraph split between frames by its piece in the +/// later frame. Each takes indents of its own, wide enough to move text between the frames. +fn indent_entries(s: &Session, frames: &[u64]) -> Vec { + let mut out = vec![]; + for (k, id) in frames.iter().enumerate() { + let t = text_of(&s.doc().unwrap().doc, NodeId(*id)).unwrap(); + let next_continues = frames.get(k + 1).is_some_and(|n| text_of(&s.doc().unwrap().doc, NodeId(*n)).unwrap().first_para_continues); + let plain = t.plain_text(); + let pieces: Vec<&str> = plain.split('\n').collect(); + let mut start = 0; + for (i, piece) in pieces.iter().enumerate() { + if i + 1 < pieces.len() || !next_continues { + let n = out.len() as f64; + out.push(json!({"id": id, "start": start, "leftIndent": 20.0 + n, "firstLineIndent": 15.0 - n})); + } + start += piece.len() + 1; + } + } + out +} + +/// `paragraphIndents` sets each paragraph of a threaded story as one `text.setFormat` command for +/// each paragraph would, the story's last paragraph first, with a paragraph split between frames +/// set once; the story flows again once, and Undo and Redo take the whole change as one step. +#[test] +fn paragraph_indents_flow_a_threaded_story_once_as_one_command_per_paragraph_would() { + let (mut one, frames) = split_story(); + let (mut each, _) = split_story(); + let entries = indent_entries(&one, &frames); + assert_eq!(entries.len(), 12, "one entry for each paragraph of the story: {entries:?}"); + let commands: Vec = entries + .iter() + .rev() + .map(|e| { + json!({"command": "text.setFormat", "params": { + "ids": [e["id"]], "start": e["start"], "end": e["start"], "leftIndent": e["leftIndent"], "firstLineIndent": e["firstLineIndent"] + }}) + }) + .collect(); + each.execute("command.batch", &json!({"label": "Left Indent", "commands": commands})).unwrap(); + let start = frames_of(&one, &frames); + let undo = one.doc().unwrap().history.undo.len(); + let copied = COPIED.with(std::cell::Cell::get); + let r = one.execute("text.setFormat", &json!({"paragraphIndents": entries})).unwrap(); + assert_eq!(COPIED.with(std::cell::Cell::get), copied + 1, "the story flows again once"); + // The first frame holds only the start of the paragraph that runs on into the second. + assert_eq!(r, json!({"ids": frames[1..]})); + assert_eq!(one.doc().unwrap().history.undo.len(), undo + 1); + let set = frames_of(&one, &frames); + assert_ne!(set, start); + assert_eq!(set, frames_of(&each, &frames)); + one.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames_of(&one, &frames), start); + one.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(frames_of(&one, &frames), set); +} + +/// Thousands of paragraphs of a threaded story are set by one edit, which flows the story again +/// once. +#[test] +fn paragraph_indents_over_thousands_of_paragraphs_flow_the_story_once() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 900, "height": 1000})).unwrap(); + let story = vec!["x"; 5000].join("\n"); + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 300}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("text.thread.add", &json!({"from": a, "rect": [300, 10, 150, 300]})).unwrap()["id"].as_u64().unwrap(); + let entries: Vec = [a, b] + .into_iter() + .flat_map(|id| { + let t = text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap(); + let count = t.paragraph_count(); + (0..count).map(move |i| json!({"id": id, "start": 2 * i, "leftIndent": (i % 7) as f64})) + }) + .collect(); + assert!(entries.len() >= 5000, "{}", entries.len()); + let (undo, copied) = (s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)); + s.execute("text.setFormat", &json!({"paragraphIndents": entries})).unwrap(); + assert_eq!((s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)), (undo + 1, copied + 1)); + let t = text_of(&s.doc().unwrap().doc, NodeId(a)).unwrap(); + assert_eq!((t.para_at(0).left_indent, t.para_at(1).left_indent, t.para_at(8).left_indent), (0.0, 1.0, 1.0)); +} + +/// `paragraphIndents` that would change no paragraph leaves the document and its history alone, and +/// a bad one is refused before anything changes. +#[test] +fn paragraph_indents_check_every_entry_and_skip_a_change_that_changes_nothing() { + let (mut s, [a, b, c]) = split_story(); + let start = frames_of(&s, &[a, b, c]); + let undo = s.doc().unwrap().history.undo.len(); + let copied = COPIED.with(std::cell::Cell::get); + let r = + s.execute("text.setFormat", &json!({"paragraphIndents": [{"id": a, "start": 0, "leftIndent": 0}, {"id": c, "start": 0, "leftIndent": 10}]})); + assert_eq!(r.unwrap(), json!({"ids": [a, c]})); + assert_eq!((s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)), (undo, copied), "nothing to change"); + let paragraph = text(&s, c).find('\n').unwrap() + 1; + let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 900, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let ok = json!({"id": c, "start": paragraph, "leftIndent": 30}); + for bad in [ + json!({"paragraphIndents": []}), + json!({"paragraphIndents": {"id": a, "start": 0, "leftIndent": 30}}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30}], "ids": [a]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30}], "leftIndent": 5}), + json!({"paragraphIndents": [ok, 3]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": "30"}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 1300}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "firstLineIndent": -1297}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30, "spaceBefore": 4}]}), + json!({"paragraphIndents": [ok, {"id": "a", "start": 0, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": rect, "start": 0, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": 999_999, "start": 0, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": a, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": -1, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 1, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": c, "start": paragraph, "leftIndent": 31}]}), + json!({"paragraphIndents": [ok, {"id": c, "start": paragraph, "leftIndent": 30, "firstLineIndent": 0}]}), + json!({"paragraphIndents": vec![ok.clone(); crate::cmd::panelcmds::MAX_PARAGRAPH_INDENTS + 1]}), + ] { + assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}"); + assert_eq!(frames_of(&s, &[a, b, c]), start, "{bad}"); + } + // The same entry twice is one change. + s.execute("text.setFormat", &json!({"paragraphIndents": [ok, ok]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(text_of(&s.doc().unwrap().doc, NodeId(c)).unwrap().para_at(1).left_indent, 30.0); +} diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 5545e1be94..d12bd8acae 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -2156,3 +2156,127 @@ fn set_path_handle_reshapes_type_on_a_path() { assert!(s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 9, "which": "out", "x": 0, "y": 0})).is_err(), "no such anchor"); assert!(s.execute("text.setPathHandle", &json!({"id": path["id"], "anchor": 0, "which": "out", "x": 0, "y": 0})).is_err(), "not type"); } + +/// `text.setFormat` refuses a number or switch given as another type, naming the key, and +/// changes nothing; null leaves the attribute as it is. +#[test] +fn set_format_refuses_a_param_of_the_wrong_type() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let state = |s: &Session| (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let before = state(&s); + for key in [ + "baselineShift", + "hScale", + "vScale", + "rotation", + "leftIndent", + "rightIndent", + "firstLineIndent", + "spaceBefore", + "spaceAfter", + "wordsLongerThan", + "afterFirst", + "beforeLast", + "hyphenLimit", + "hyphenationZone", + ] { + for wrong in [json!("30"), json!(true), json!([30])] { + let err = s.execute("text.setFormat", &json!({ key: wrong })).unwrap_err().to_string(); + assert!(err.contains(&format!("`{key}` must be a number, not {wrong}")), "{key}: {err}"); + } + } + for key in ["underline", "allCaps", "hyphenate", "noBreak"] { + let err = s.execute("text.setFormat", &json!({ key: "true" })).unwrap_err().to_string(); + assert!(err.contains(&format!("`{key}` must be true or false")), "{key}: {err}"); + } + let err = s.execute("text.setFormat", &json!({"leftIndent": 30, "firstLineIndent": "12"})).unwrap_err().to_string(); + assert!(err.contains("`firstLineIndent` must be a number"), "{err}"); + assert!(state(&s) == before, "a refused call changes nothing and records no undo step"); + s.execute("text.setFormat", &json!({"leftIndent": 30, "firstLineIndent": null})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!((t.para_at(0).left_indent, t.para_at(0).first_line_indent), (30.0, 0.0)); +} + +/// `text.setFormat` reads a null value as one left out: a call whose attributes are all null has +/// nothing to change, and records no edit, no undo step and no new revision. +#[test] +fn set_format_with_only_null_values_changes_nothing() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.doc.clone(), st.history.undo.len(), st.revision) + }; + let before = state(&s); + for params in [ + json!({"leftIndent": null}), + json!({"leftIndent": null, "firstLineIndent": null, "underline": null}), + json!({"kerning": null, "antiAlias": null, "list": null, "wordSpacing": null}), + json!({"mojikumi": null}), + json!({"kinsoku": null, "kinsokuType": null, "direction": null, "leadingModel": null}), + json!({"burasagari": null, "kashida": null, "singleWordJustification": null, "autoLeading": null}), + json!({"ids": [id], "start": 0, "end": 3, "spaceBefore": null}), + ] { + let err = s.execute("text.setFormat", ¶ms).unwrap_err().to_string(); + assert!(err.contains("nothing to change"), "{params}: {err}"); + assert!(state(&s) == before, "{params}: no edit, no undo step and no new revision"); + } + // A null beside a value leaves only its own attribute. + let para = |d: &vectorcraft_doc::Document| { + let NodeKind::Text(t) = &d.node(NodeId(id)).unwrap().kind else { panic!("text") }; + (t.para_at(0).mojikumi, t.para_at(0).kinsoku) + }; + let mojikumi = para(&before.0).0; + s.execute("text.setFormat", &json!({"mojikumi": null, "kinsoku": "soft"})).unwrap(); + assert_eq!(para(&s.doc().unwrap().doc), (mojikumi, vectorcraft_doc::Kinsoku::Soft)); + assert_eq!(s.doc().unwrap().history.undo.len(), before.1 + 1, "one undo step"); +} + +/// `text.setFormat` with the values the text already has succeeds and names its objects, but +/// records no edit, no undo step and no new revision. +#[test] +fn set_format_with_the_current_values_records_nothing() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.doc.clone(), st.history.undo.len(), st.revision) + }; + let before = state(&s); + let NodeKind::Text(t) = &before.0.node(NodeId(id)).unwrap().kind else { panic!("text") }; + let hyphenate = t.para_at(0).hyphenate; + for params in [ + json!({"leftIndent": 0, "firstLineIndent": 0}), + json!({"underline": false, "strikethrough": false, "hyphenate": hyphenate}), + json!({"ids": [id], "start": 0, "end": 3, "spaceBefore": 0, "spaceAfter": 0}), + json!({"ids": [id], "start": 4, "end": 7, "leftIndent": null, "rightIndent": 0}), + ] { + let out = s.execute("text.setFormat", ¶ms).unwrap(); + assert_eq!(out["ids"], json!([id]), "{params}"); + assert!(state(&s) == before, "{params}: no edit, no undo step and no new revision"); + } + // A call that changes a value is one undo step; the same call again records nothing. + s.execute("text.setFormat", &json!({"leftIndent": 24})).unwrap(); + let changed = state(&s); + assert_eq!((changed.1, changed.2), (before.1 + 1, before.2 + 1), "one undo step and one revision"); + s.execute("text.setFormat", &json!({"leftIndent": 24})).unwrap(); + assert!(state(&s) == changed, "the same value again records nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc == before.0, "one undo restores the text"); + // The anti-aliasing method is compared the same way. + s.execute("text.setFormat", &json!({"antiAlias": "none"})).unwrap(); + s.execute("text.setFormat", &json!({"antiAlias": "strong"})).unwrap(); + let strong = state(&s); + assert!(strong.1 > before.1, "a new method is an undo step"); + let NodeKind::Text(t) = &strong.0.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!(t.anti_alias, vectorcraft_doc::text::TypeAntiAlias::Strong); + s.execute("text.setFormat", &json!({"antiAlias": "strong"})).unwrap(); + assert!(state(&s) == strong, "the same method again records nothing"); +} diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index ad70f047bc..dc393003ac 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -46,9 +46,9 @@ fn selection_and_unlocked(s: &Session) -> std::result::Result<(), String> { variables_unlocked(s) } -/// Ends a typing session as [`Session::edit`] would, before a helper below compares the document, +/// Ends a typing session as [`Session::edit`] would, before a command compares the document, /// so the typing is its own undo step whether or not the command then changes anything. -fn settle_typing(s: &mut Session) -> Result<()> { +pub(super) fn settle_typing(s: &mut Session) -> Result<()> { if !s.previewing && super::typing_in_progress(s) { super::settle_interaction(s)?; } diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index c7b8e47828..77dd07e15f 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -84,11 +84,10 @@ fn append_indent_targets(node: &Node, range: std::ops::Range, targets: &m /// one frame ends and the next begins, the last paragraph of the one and the first of the next are /// pieces of one story paragraph: a paragraph that runs on into the next frame, or an empty piece /// after a frame's last paragraph break. Such a paragraph is targeted once, by its last piece with -/// text in a frame that `selected` takes, the piece whose style the story keeps when it flows again. -/// A paragraph whose text lies only in frames that `selected` leaves out is not targeted. An empty -/// paragraph takes its style from its last piece when the story flows again, so it is targeted by -/// that piece when `selected` takes its frame, and otherwise not. -fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(NodeId) -> bool, targets: &mut Vec) { +/// text, the piece whose style the story keeps when it flows again. An empty paragraph takes its +/// style from its last piece when the story flows again, so it is targeted by that piece. Locked and +/// hidden frames are targeted like the others. +fn append_story_targets(doc: &Document, story: &[NodeId], targets: &mut Vec) { /// The part of a story paragraph in one frame: paragraph `index` there, starting at byte `start`. struct Piece<'a> { id: NodeId, @@ -113,11 +112,7 @@ fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(Node } } for pieces in ¶graphs { - let pick = if pieces.iter().any(|p| p.has_text) { - pieces.iter().rev().find(|p| p.has_text && selected(p.id)) - } else { - pieces.last().filter(|p| selected(p.id)) - }; + let pick = pieces.iter().rev().find(|p| p.has_text).or_else(|| pieces.last()); if let Some(p) = pick { targets.push(indent_target(p.id, p.text, p.index, p.start)); } @@ -125,13 +120,14 @@ fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(Node } /// Paragraphs touched by the Type selection (every paragraph of the story after Select All in -/// threaded text), or all paragraphs of selected text objects. Each paragraph of a threaded story -/// is listed once, in story order ([`append_story_targets`]). +/// threaded text), or all paragraphs of selected text objects. A selected frame of a threaded story +/// selects the whole story, its locked and hidden frames included. Each paragraph of a threaded +/// story is listed once, in story order ([`append_story_targets`]). fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec { let mut targets = Vec::new(); if let Some((_, a, b)) = super::character::text_editing(app) { if let (Some(story), Some(st)) = (super::character::text_story(app), app.session.active()) { - append_story_targets(&st.doc, &story, |_| true, &mut targets); + append_story_targets(&st.doc, &story, &mut targets); } else if let NodeKind::Text(text) = &node.kind { append_indent_targets(node, text.paragraphs_in(a, b), &mut targets); } @@ -153,7 +149,7 @@ fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec { } let story = st.doc.story_of(id); done.extend(story.iter().copied()); - append_story_targets(&st.doc, &story, |f| taken.contains(&f), &mut targets); + append_story_targets(&st.doc, &story, &mut targets); } } targets @@ -243,9 +239,9 @@ fn paint_markers(ui: &mut Ui, painter: &egui::Painter, tokens: &Tokens, view: Ma } } -/// Apply an absolute ruler position through paragraph-format commands. Paragraphs that need values -/// of their own get one command each, the story's last paragraph first: threaded text re-flows -/// after each command, and a re-flow moves only text after the paragraph that changed. +/// Apply an absolute ruler position through paragraph-format commands. When the paragraphs need +/// values of their own, one command sets each paragraph's ([`set_each_indent`]), and threaded text +/// flows again once. fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: &ParaStyle, span: f64, targets: &[IndentTarget], both: bool) { if targets.is_empty() || !target.is_finite() { return; @@ -253,23 +249,10 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: let Some(target) = bounded_marker_position(marker, target, span, targets, both) else { return }; if marker == IndentMarker::Upper { if targets.iter().all(|p| p.left == para.left_indent) { - if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", json!({"firstLineIndent": target - para.left_indent})) { - app.status(e); - } + run_on_targets(app, targets, json!({"firstLineIndent": target - para.left_indent})); } else { - super::character::end_typing(app); - let commands: Vec = targets - .iter() - .rev() - .map(|p| { - json!({"command": "text.setFormat", "params": { - "ids": [p.id], "start": p.start, "end": p.start, "firstLineIndent": target - p.left - }}) - }) - .collect(); - if let Err(e) = app.run("command.batch", json!({"label": tl!("First-line Left Indent"), "commands": commands})) { - app.status(e); - } + let changes = targets.iter().map(|p| (p, None, Some(target - p.left))).collect(); + set_each_indent(app, tl!("First-line Left Indent"), changes); } } else { // The ruler shows each paragraph's left indent with its list level's added; the paragraph @@ -284,26 +267,58 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: _ => None, }; if let Some(params) = params { - if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) { - app.status(e); - } + run_on_targets(app, targets, params); return; } - super::character::end_typing(app); - let commands: Vec = targets - .iter() - .rev() - .map(|p| { - let mut params = json!({"ids": [p.id], "start": p.start, "end": p.start, "leftIndent": target - p.level}); - if !both { - params["firstLineIndent"] = json!(p.left + p.first_line - target); - } - json!({"command": "text.setFormat", "params": params}) - }) - .collect(); - if let Err(e) = app.run("command.batch", json!({"label": tl!("Left Indent"), "commands": commands})) { - app.status(e); - } + let changes = targets.iter().map(|p| (p, Some(target - p.level), (!both).then_some(p.left + p.first_line - target))).collect(); + set_each_indent(app, tl!("Left Indent"), changes); + } +} + +/// Run one `text.setFormat` with `params` on the paragraphs the Paragraph panel acts on while typing, +/// and with the Selection tool on every type object of `targets`, so a selected frame of a threaded +/// story sets the whole story ([`indent_targets`]). +fn run_on_targets(app: &mut VectorcraftApp, targets: &[IndentTarget], mut params: Value) { + if super::character::text_editing(app).is_none() { + let mut seen = HashSet::new(); + params["ids"] = json!(targets.iter().map(|p| p.id).filter(|id| seen.insert(*id)).collect::>()); + } + if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) { + app.status(e); + } +} + +/// Give each paragraph its own left and first-line indents (None: as it is) in one `text.setFormat` +/// with `paragraphIndents`, one undo step named `label`. Paragraphs that keep their indents are +/// left out, and when every paragraph keeps them nothing is run. +fn set_each_indent(app: &mut VectorcraftApp, label: &str, changes: Vec<(&IndentTarget, Option, Option)>) { + // As `text.setFormat` keeps a paragraph's indents, within 1296 pt either way. + let bound = |v: Option| v.map(|v| v.clamp(-1296.0, 1296.0)); + let entries: Vec = changes + .into_iter() + .filter_map(|(p, left, first_line)| { + let (left, first_line) = (bound(left), bound(first_line)); + let own_left = p.left - p.level; + if left.is_none_or(|v| v == own_left) && first_line.is_none_or(|v| v == p.first_line) { + return None; + } + let mut entry = json!({"id": p.id, "start": p.start}); + if let Some(v) = left { + entry["leftIndent"] = json!(v); + } + if let Some(v) = first_line { + entry["firstLineIndent"] = json!(v); + } + Some(entry) + }) + .collect(); + if entries.is_empty() { + return; + } + super::character::end_typing(app); + let command = json!({"command": "text.setFormat", "params": {"paragraphIndents": entries}}); + if let Err(e) = app.run("command.batch", json!({"label": label, "commands": [command]})) { + app.status(e); } } diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 97e8c8a00a..d6bcef15f1 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -617,6 +617,52 @@ fn indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_p } } +/// A marker drag over many paragraphs of a threaded story with mixed indents sends one +/// `text.setFormat` for all of them, in one undo step, and a drag that changes no paragraph runs +/// nothing. +#[test] +fn indent_markers_over_many_threaded_paragraphs_send_one_format_command() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 3000})).unwrap(); + let story = (1..=128).map(|i| format!("p{i}")).collect::>().join("\n"); + let a = app.run("text.create", json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 200, 2900]})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setFormat", json!({"ids": [b], "leftIndent": 10})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let before = story_paragraphs(&app, &[a, b]); + assert_eq!(before.len(), 128, "{before:?}"); + assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}"); + let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.len()); + let drag = |app: &mut VectorcraftApp, marker, to| { + let (node, para, span) = current_para(app).unwrap(); + let targets = indent_targets(app, &node); + assert_eq!(targets.len(), 128); + set_marker(app, marker, to, ¶, span, &targets, false); + }; + let (undo, journal) = steps(&app); + drag(&mut app, IndentMarker::Upper, 60.0); + assert_eq!(steps(&app), (undo + 1, journal + 1)); + let (name, params) = app.session.journal.last().cloned().unwrap(); + assert_eq!(name, "command.batch"); + let commands = params["commands"].as_array().unwrap(); + assert_eq!(commands.len(), 1, "one command for every paragraph"); + assert_eq!(commands[0]["command"], "text.setFormat"); + assert_eq!(commands[0]["params"]["paragraphIndents"].as_array().unwrap().len(), 128); + let after = story_paragraphs(&app, &[a, b]); + for (was, now) in before.iter().zip(&after) { + assert_eq!((&now.0, now.1, now.2), (&was.0, was.1, 60.0), "{after:?}"); + } + // The same position again changes no paragraph. + drag(&mut app, IndentMarker::Upper, 60.0); + assert_eq!(steps(&app), (undo + 1, journal + 1), "nothing ran"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(story_paragraphs(&app, &[a, b]), before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(story_paragraphs(&app, &[a, b]), after); +} + /// After Select All in threaded text, a paragraph split between two frames is one target. #[test] fn indent_markers_after_select_all_list_a_split_paragraph_once() { @@ -633,12 +679,12 @@ fn indent_markers_after_select_all_list_a_split_paragraph_once() { assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}"); } -/// With the Selection tool on some frames of a threaded story, only the paragraphs with text in -/// those frames are listed, each once: a selected frame that starts inside a paragraph lists that -/// paragraph, and so does a selected frame that ends inside one. Text in a selected group is -/// listed with the story's frames, and an empty text object keeps its one paragraph. +/// With the Selection tool on any frame of a threaded story, the indent markers list every +/// paragraph of the story, each once in story order: a selected story is one text. Text in a +/// selected group is listed with the story's frames, and an empty text object keeps its one +/// paragraph. #[test] -fn indent_markers_on_some_threaded_frames_list_their_paragraphs_once() { +fn indent_markers_on_some_threaded_frames_list_the_whole_story_once() { let (mut app, [a, b, c]) = split_story_app(); let count = |app: &VectorcraftApp, id: u64| text_of(app, id).paragraph_count(); let targets_of = |app: &mut VectorcraftApp, ids: Vec| { @@ -646,29 +692,64 @@ fn indent_markers_on_some_threaded_frames_list_their_paragraphs_once() { let (node, _, _) = current_para(app).unwrap(); story_positions(&indent_targets(app, &node), &[a, b, c]) }; - // The continuing frame alone: its first paragraph is the one the frame before ends inside. - let only_b = targets_of(&mut app, vec![b]); - assert_eq!(only_b.len(), count(&app, b), "{only_b:?}"); - assert_eq!(only_b.first(), Some(&(1, 0)), "{only_b:?}"); - let only_a = targets_of(&mut app, vec![a]); - assert_eq!(only_a.len(), count(&app, a), "{only_a:?}"); - // Two frames that share a paragraph list it once, by the later frame's piece. - let both = targets_of(&mut app, vec![c, a, b]); - assert_eq!(both.len(), count(&app, a) + count(&app, b) + count(&app, c) - 2, "{both:?}"); - assert!(both.windows(2).all(|w| w[0] < w[1]), "{both:?}"); - // The first and last frames of the story, and a grouped empty text object besides. + let story = count(&app, a) + count(&app, b) + count(&app, c) - 2; + let all = targets_of(&mut app, vec![c, a, b]); + assert_eq!(all.len(), story, "two frames that share a paragraph list it once: {all:?}"); + assert!(all.windows(2).all(|w| w[0] < w[1]), "{all:?}"); + for frame in [a, b, c] { + assert_eq!(targets_of(&mut app, vec![frame]), all, "frame {frame} alone lists the whole story"); + } + // A grouped empty text object besides the last frame of the story. let empty = app.run("text.create", json!({"x": 10, "y": 900, "text": "", "area": {"width": 100, "height": 40}})).unwrap()["id"].as_u64().unwrap(); app.run("select.set", json!({"ids": [empty, c]})).unwrap(); let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [group, a]})).unwrap(); - let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(a)).unwrap().clone(); + app.run("select.set", json!({"ids": [group]})).unwrap(); + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(c)).unwrap().clone(); let targets = indent_targets(&app, &node); let ids: Vec = targets.iter().map(|p| p.id).collect(); assert_eq!(ids.iter().filter(|id| **id == empty).count(), 1, "{ids:?}"); - let story: Vec = targets.into_iter().filter(|p| p.id != empty).collect(); - let positions = story_positions(&story, &[a, b, c]); - assert_eq!(positions.len(), count(&app, a) + count(&app, c), "{positions:?}"); - assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}"); + let in_story: Vec = targets.into_iter().filter(|p| p.id != empty).collect(); + assert_eq!(story_positions(&in_story, &[a, b, c]), all); +} + +/// A marker drag on one selected frame of a threaded story sets every paragraph of the story, in +/// its locked and hidden frames too, in one undo step, whether the paragraphs share their indents +/// or have their own. +#[test] +fn indent_markers_on_one_threaded_frame_set_its_locked_and_hidden_frames() { + let (mut app, [a, b, c]) = split_story_app(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + // Every paragraph has the same indents. + let (node, para, span) = current_para(&app).unwrap(); + let targets = indent_targets(&app, &node); + assert_eq!(targets.len(), 12); + let undo = app.session.active().unwrap().history.undo.len(); + set_marker(&mut app, IndentMarker::Upper, 30.0, ¶, span, &targets, false); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + let shared = story_paragraphs(&app, &[a, b, c]); + assert_eq!(shared.len(), 12, "{shared:?}"); + assert!(shared.iter().all(|p| (p.1, p.2) == (0.0, 30.0)), "{shared:?}"); + // The paragraphs of the hidden frame have a left indent of their own. + app.run("text.setFormat", json!({"ids": [c], "leftIndent": 10})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let before = story_paragraphs(&app, &[a, b, c]); + assert_eq!(before.len(), 12, "{before:?}"); + assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}"); + let (node, para, span) = current_para(&app).unwrap(); + let targets = indent_targets(&app, &node); + assert_eq!(targets.len(), 12); + let undo = app.session.active().unwrap().history.undo.len(); + set_marker(&mut app, IndentMarker::Upper, 60.0, ¶, span, &targets, false); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + let after = story_paragraphs(&app, &[a, b, c]); + assert_eq!(after.iter().map(|p| &p.0).collect::>(), before.iter().map(|p| &p.0).collect::>()); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.2), (was.1, 60.0), "{} keeps its left indent and starts at 60 pt: {after:?}", now.0); + } } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 62a4212472..572100189a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8236,6 +8236,28 @@ stops, the command changes nothing and records no undo step. `text.tabs.set` rej holds at least that many, so stops can be removed from a paragraph that holds more. It sorts the stops by position and keeps the first of stops at one position. +`text.setFormat {paragraphIndents: [{id, start, leftIndent?, firstLineIndent?}…]}` gives each paragraph indents of its +own in one call: each entry names one paragraph of a type object by the byte `start` where the paragraph begins, and +sets its `leftIndent`, its `firstLineIndent` or both, in points from -1296 to 1296. The call takes no other key, and +at most 100000 entries. It checks every entry before it changes anything, and fails on an empty list, an unknown key, an +entry with neither indent, a value that is not a number in range, an `id` that is not a type object, a `start` where no +paragraph of that object begins, and two entries for one paragraph with other values (the same entry twice counts once). +The whole change is one edit: each type object's paragraph styles are rebuilt once, and a threaded story flows through +its frames again once, however many of its paragraphs change, where one `text.setFormat {ids, start, end}` for each +paragraph flows it again after every paragraph. When no paragraph's indents change, the command records no edit and no +undo step. It returns `{ids}`, the type objects the entries name. The Tabs panel's indent markers use it when the +paragraphs they move have indents of their own, inside a `command.batch` that names the undo step after the marker. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"paragraphIndents":[{"id":12,"start":0,"firstLineIndent":36},{"id":12,"start":40,"leftIndent":24,"firstLineIndent":12}]}}} +``` + +The single-value form of `text.setFormat` fails, naming the key, when a number such as `leftIndent` or `baselineShift` is +given as a string, a switch or a list, or a switch such as `underline` as anything but true or false; it changes nothing +then. A null value of any attribute leaves that attribute as it is, as an omitted one does, so a call whose attributes +are all null fails with "nothing to change" and records no edit and no undo step. A call whose values are the ones the +text already has succeeds and returns its `ids`, but records no edit and no undo step. + `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle.list` counts runs; both count the type in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes. `charStyle.unused` and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5ec97e34c1..570a44e8c8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -657,7 +657,7 @@ since 2026-10-01. - **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), which lists Alt-click for opening panel options and names Actions, Brushes, Swatches and Symbols as exceptions. - **Moving all tab stops:** in the Tabs panel, a stop dragged on the ruler with Ctrl held (Command on macOS) moves every stop by the same distance, in one `text.tabs.set` and one undo step; the stops keep their order, alignment and leaders, the ruler shows them moving during the drag, and a drag that would take the leftmost stop below 0 leaves it at 0, or the rightmost past 100,000 pt leaves it at 100,000 pt, with the other stops at their spacing from it. A drag that leaves every stop where it was records no undo step. Ctrl must be held when the drag starts. Tests `panels::tabs::tests::command_drag_moves_all_the_stops_together`, `command_drag_stops_at_zero_and_keeps_the_spacing`, `command_drag_stops_at_the_largest_position_and_keeps_the_spacing` and `a_drag_that_moves_no_stop_records_no_undo_step`. Public reference: [Manage tab stops](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/manage-tab-stops.html), which says that a Ctrl-drag (Windows) or Command-drag (macOS) of a tab moves all the tab stops at the same time. -- **Tabs indent markers on threaded frames:** when a marker drag gives the paragraphs of a threaded story values of their own, the Tabs panel sends one `text.setFormat` for each paragraph of the story, from its last paragraph to its first, whatever order the frames were selected in with the Selection tool; a paragraph that runs on from one frame into the next gets one step, after Type tool Select All too. With the Selection tool only the paragraphs with text in a selected frame change, so a frame selected alone that starts or ends inside a paragraph sets that paragraph. Tests `panels::tabs::tests::indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once`, `indent_markers_after_select_all_list_a_split_paragraph_once` and `indent_markers_on_some_threaded_frames_list_their_paragraphs_once`. Public reference: [Indent text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/indent-text.html). +- **Tabs indent markers on threaded frames:** when a marker drag gives the paragraphs of a threaded story values of their own, the Tabs panel sends one `text.setFormat {paragraphIndents}` with an entry for each paragraph of the story, whatever order the frames were selected in with the Selection tool, so the story flows through its frames again once rather than once for each paragraph, and a drag that changes no paragraph runs nothing; a paragraph that runs on from one frame into the next gets one entry, after Type tool Select All too. A selected story is one text: with the Selection tool, a marker drag on any of its frames sets every paragraph of the story, in its locked and hidden frames too. Tests `panels::tabs::tests::indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once`, `indent_markers_over_many_threaded_paragraphs_send_one_format_command`, `indent_markers_after_select_all_list_a_split_paragraph_once`, `indent_markers_on_some_threaded_frames_list_the_whole_story_once`, `indent_markers_on_one_threaded_frame_set_its_locked_and_hidden_frames`, `cmd::threads::tests::paragraph_indents_flow_a_threaded_story_once_as_one_command_per_paragraph_would`, `paragraph_indents_over_thousands_of_paragraphs_flow_the_story_once` and `paragraph_indents_check_every_entry_and_skip_a_change_that_changes_nothing`, and `cmd::typecmd::area_tests::set_format_refuses_a_param_of_the_wrong_type`. Public reference: [Indent text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/indent-text.html). - **Width Point Edit:** Shift-double-click enables Adjust Adjoining Width Points; plain double-click and direct opening retain the default. Tool dispatch and headless dialog tests cover neighboring widths, Undo/Redo and Cancel. Public reference: [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a feature lands. From be50e5328b6a91ac004ede7ecc6c3ce84f03659e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 09:28:32 -0800 Subject: [PATCH 14/23] Transform: a bevel stops at the corners of the segments it joins with Use Preview Bounds A bevel join, and a miter join whose miter limit bevels a corner, end the stroke at the outer corners of the two segments they join. The visual box measured such a corner as if the stroke reached half its weight round it: a 40 x 100 pt triangle with a 20 pt stroke and a miter limit of 2 measured 10 pt above its tip, where the stroke paints about 1.96 pt above it. Use Preview Bounds, and the alignment and sizing that use it, took that box. - With bevel and miter joins, closed paths and open paths with round or projecting caps are now measured along their segments, as butt-capped open paths already were, with the miter tips the limit keeps and the caps added. Round joins still reach half the weight round each corner, the round caps of dashes, which can end at a corner, reach half the weight all round, and the projecting caps of dashes reach the diagonal of their square. The visual box of such a stroke can be smaller than before and is never larger. A path with no width, such as a rectangle sized to 0 pt wide, turns back at its ends, where the join bevels, so its box no longer reaches past them. - object.setBounds with Use Preview Bounds measures an object as drawn when a beveled or mitered corner, or a butt cap, is on a segment at a slant. Before, a closed path with miter spikes the limit keeps missed the size typed when the width and height were out of proportion. - A click past a bevel or a butt cap, by more than the click tolerance, no longer selects the stroke. Strokes with a width profile or with dashes that have round or projecting caps, and the ends of curves that bend more tightly than half the weight, keep the round reach. - Paths measured along their segments skip the segments that cannot reach past the box measured so far, which starts with the path's own bounds. --- crates/doc/src/reach.rs | 277 +++++++++++++++++------ crates/effects/src/stroke/tests_reach.rs | 29 +++ crates/engine/src/cmd/object/bounds.rs | 8 +- crates/engine/src/tests/previewbounds.rs | 30 ++- docs/target-app-parity.md | 1 + 5 files changed, 271 insertions(+), 74 deletions(-) diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 8279507e01..7f9b6a1766 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -1,6 +1,7 @@ //! Where a path's strokes paint, from the path's geometry without building their outlines: the //! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes -//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points. +//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points, +//! and a bevel ends at the corners of the two segments it joins. use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit}; use vectorcraft_geom::hit::fill_contains; @@ -39,10 +40,20 @@ fn joints(segs: &[PathSeg], closed: bool) -> impl Iterator Option<[Point; 3]> { - let cos = t_in.dot(t_out); // cos(half the turn) is sin(half the angle between the segments): the miter is side / it. - let half = ((1.0 + cos) / 2.0).max(0.0).sqrt(); - if cos >= CORNER_COS || half * limit < 1.0 { + let half = ((1.0 + t_in.dot(t_out)) / 2.0).max(0.0).sqrt(); + if half * limit < 1.0 { + return None; + } + let ([a, b], out) = bevel(at, t_in, t_out, side)?; + Some([a, at + out * (side / half), b]) +} + +/// The bevel at a corner with tangents `t_in`, `t_out` of a stroke reaching `side` from its path: +/// the outer corners of the two segments' strokes and the unit direction out of the turn, or +/// `None` when the path doesn't turn there. +fn bevel(at: Point, t_in: Vec2, t_out: Vec2, side: f64) -> Option<([Point; 2], Vec2)> { + if t_in.dot(t_out) >= CORNER_COS { return None; } let out = unit(t_in - t_out); @@ -51,7 +62,7 @@ fn miter(at: Point, t_in: Vec2, t_out: Vec2, side: f64, limit: f64) -> Option<[P let n = Vec2::new(-t.y, t.x); if n.dot(out) >= 0.0 { n } else { -n } }; - Some([at + outer(t_in) * side, at + out * (side / half), at + outer(t_out) * side]) + Some(([at + outer(t_in) * side, at + outer(t_out) * side], out)) } /// The heads' ends of `bp`: the start of its first subpath and the end of its last, when open (as @@ -187,26 +198,36 @@ fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool { } impl StrokeLayer { - /// Does bounding this stroke need its path's shape: miter spikes, projecting caps, arrowheads, - /// an alignment that depends on whether the path is closed, or butt caps, which stop at the - /// ends of an open path? Else it reaches evenly round it. + /// Does bounding this stroke need its path's shape: miter spikes, bevels, projecting caps, + /// arrowheads, an alignment that depends on whether the path is closed, or butt caps, which + /// stop at the ends of an open path? Else it reaches evenly round it. fn needs_shape(&self) -> bool { - self.join == LineJoin::Miter + self.join != LineJoin::Round || self.cap != LineCap::Round || self.start_arrow.is_some() || self.end_arrow.is_some() || self.align != StrokeAlign::Center } - /// The box this stroke paints along `segs`, the segments with length of an open subpath, when - /// its caps are butt caps: each segment's [`swept_box`] and, with round joins, the arc each - /// joint between segments paints ([`round_join_box`]), a shallow one included. - /// `None` when `segs` is empty. Butt dashes stop inside the same box. The heads and miter tips - /// are measured apart. A segment in the middle is measured only when its own box grown by - /// `side`, which holds everything it paints, is not inside the box so far. - fn butt_body(&self, segs: &[PathSeg], side: f64) -> Option { + /// Does this stroke stop at the corners of its segments along a subpath that is `closed` or + /// open, where its caps are butt caps on an open subpath or its joins are bevels or miters? + /// Dashes with round or projecting caps can end at a corner, so with them only butt caps stop. + fn stops_at_corners(&self, closed: bool) -> bool { + let capped_dashes = self.cap != LineCap::Butt && self.dash.as_ref().is_some_and(Dash::is_dashed); + (self.cap == LineCap::Butt && !closed) || (self.join != LineJoin::Round && !capped_dashes) + } + + /// The box this stroke's body paints along `segs`, the segments with length of a subpath, short + /// of its caps, joined with `within`: each segment's [`swept_box`] and, with round joins on an + /// open subpath, the arc each joint between segments paints ([`round_join_box`]), a shallow one + /// included. A bevel stays inside the boxes of the segments it joins. `None` when `segs` is + /// empty. Butt dashes stop inside the same box. The caps other than butt ones, the heads and + /// miter tips are measured apart. A segment in the middle is measured only when its own box + /// grown by `side`, which holds everything it paints, is not inside the box so far, which + /// starts as `within` and the first and last segments' boxes. + fn body_along(&self, segs: &[PathSeg], side: f64, within: Rect) -> Option { let (first, last) = (segs.first()?, segs.last()?); - let ends = swept_box(first, side, 3).union(swept_box(last, side, 3)); + let ends = swept_box(first, side, 3).union(swept_box(last, side, 3)).union(within); let body = segs.iter().skip(1).take(segs.len().saturating_sub(2)).fold(ends, |b, s| { if b.contains_rect(ParamCurveExtrema::bounding_box(s).inflate(side, side)) { b } else { b.union(swept_box(s, side, 3)) } }); @@ -217,24 +238,28 @@ impl StrokeLayer { } /// The box this stroke paints along `bp` (geometric bounds `bounds`): an open subpath with butt - /// caps by [`Self::butt_body`], every other subpath by the box of its segments grown by its - /// reach, with `tips` and, when `measure_tips`, the miter spikes the limit keeps, the corners of - /// projecting caps and the reach of the arrowheads round their end points. + /// caps, and every subpath when the joins are bevels or miters, by [`Self::body_along`] with its + /// caps, every other subpath by the box of its segments grown by its reach, with `tips` and, + /// when `measure_tips`, the miter spikes the limit keeps, the corners of projecting caps and the + /// reach of the arrowheads round their end points. fn reach_along(&self, bp: &BezPath, bounds: Rect, tips: Option, measure_tips: bool) -> Rect { let els = bp.elements(); let subs = subpaths(bp); let mut out = tips.map_or(bounds, |t| bounds.union(t)); let mut side_max: f64 = 0.0; - // Every subpath reaches evenly unless one is open and butt-capped; only then is each - // subpath's own box needed. - let even_only = self.cap != LineCap::Butt || subs.iter().all(|s| s.1); - let mut even = even_only.then_some(bounds); // Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere. let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed); + // A butt cap stops at an open subpath's end, and a bevel, or a miter the limit bevels, at + // the two segments' corners, so such a subpath is measured along its segments. Every + // subpath reaches evenly unless one is measured that way; only then is each subpath's own + // box needed. + let even_only = !subs.iter().any(|s| self.stops_at_corners(s.1)); + let mut even = even_only.then_some(bounds); for (r, closed) in subs { let side = self.side_reach(closed); let butt = self.cap == LineCap::Butt && !closed; - if !butt { + let along = self.stops_at_corners(closed); + if !along { side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); } if side <= 0.0 { @@ -242,10 +267,10 @@ impl StrokeLayer { } let square = self.cap == LineCap::Square && !closed; let miters = measure_tips && self.join == LineJoin::Miter; - if !(butt || square || miters || !even_only) { + if !(along || square || miters || !even_only) { continue; } - let segs = drawn(&els[r]); + let segs = drawn(&els[r.clone()]); if miters { for (at, t_in, t_out) in joints(&segs, closed) { if let Some([_, tip, _]) = miter(at, t_in, t_out, side, self.miter_limit) { @@ -253,13 +278,24 @@ impl StrokeLayer { } } } - if butt { - if let Some(body) = self.butt_body(&segs, side) { - out = out.union(body); + if along { + // The path's bounds are in the box already, so only the segments that can reach + // past them are measured. + match self.body_along(&segs, side, out) { + Some(body) => out = out.union(body), + // A subpath with no length paints at most its caps' dot. + None if !butt => { + let dot = segments(&els[r]).iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b)); + out = dot.map_or(out, |d| out.union(d.inflate(side, side))); + } + None => {} } - continue; - } - if !even_only { + if self.cap == LineCap::Round && !closed { + for (at, _) in ends(&segs).into_iter().flatten() { + out = out.union(Rect::from_center_size(at, (2.0 * side, 2.0 * side))); + } + } + } else if !even_only { even = vectorcraft_geom::union_opt(even, segs.iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b))); } if square { @@ -279,7 +315,8 @@ impl StrokeLayer { /// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown /// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of /// projecting caps and the reach of the arrowheads round their end points. An open subpath - /// with butt caps does not reach past its ends ([`Self::butt_body`]). + /// with butt caps does not reach past its ends, and a bevel does not reach round its corner + /// ([`Self::body_along`]). pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect { self.reach_along(bp, bounds, None, true) } @@ -329,6 +366,9 @@ impl StrokeLayer { let segs = drawn(&els[r]); let (half, region) = self.body(closed); let full = half * self.profile_max() * dash_caps.unwrap_or(1.0); + // Past a butt cap or a bevel the stroke stops at the segments' corners. A width profile + // keeps the round reach at the ends. + let squared = self.profile.is_none() && self.stops_at_corners(closed); // Every segment near enough to reach `p`: with a width profile the wider side of a // farther segment can paint past the nearest one, so the nearest alone is not enough. let near = segs.iter().enumerate().filter_map(|(i, s)| { @@ -365,7 +405,8 @@ impl StrokeLayer { } }) }; - if d <= half * factor.max(0.0) + tol && on_side(region, d) { + if d <= half * factor.max(0.0) + tol && on_side(region, d) && (!squared || self.paints_at_end(&segs, closed, (i, nr.t), p, half, tol)) + { return true; } } @@ -393,6 +434,33 @@ impl StrokeLayer { false } + /// Does this stroke, where it stops at the corners of its segments ([`Self::stops_at_corners`]), + /// paint `p`, which is within `side` (and `tol`) of segment `i` of the subpath `segs` + /// (`closed`: a closed one) and nearest to it at `t`? It does when `p` is not past the end of + /// the segment that `t` is at, by more than `tol`, and past it when a round cap or a round join + /// paints there, or when `p` is within `tol` of the bevel's triangle. Projecting caps and + /// miter spikes are checked apart. A curve that bends more tightly than `side` ([`gentle`]) + /// counts as painting round its ends. + fn paints_at_end(&self, segs: &[PathSeg], closed: bool, (i, t): (usize, f64), p: Point, side: f64, tol: f64) -> bool { + let (n, at_start) = (segs.len(), t <= 1e-9); + let Some(seg) = segs.get(i) else { return true }; + if (!at_start && t < 1.0 - 1e-9) || !(matches!(seg, PathSeg::Line(_)) || gentle(seg, side)) { + return true; + } + let (at, out) = if at_start { (seg.start(), -tangent(seg, 0.0)) } else { (seg.end(), tangent(seg, 1.0)) }; + if (p - at).dot(out) <= tol { + return true; + } + // The segment on the other side of this end, `None` at the end of an open subpath. + let other = if at_start { (i > 0 || closed).then(|| (i + n - 1) % n) } else { (i + 1 < n || closed).then(|| (i + 1) % n) }; + let Some(other) = other.and_then(|o| segs.get(o)) else { return self.cap == LineCap::Round }; + if self.join == LineJoin::Round { + return true; + } + let (t_in, t_out) = if at_start { (tangent(other, 1.0), tangent(seg, 0.0)) } else { (tangent(seg, 1.0), tangent(other, 0.0)) }; + bevel(at, t_in, t_out, side).is_some_and(|([a, b], _)| in_convex(&[at, a, b], p, tol)) + } + /// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path /// leaving along `out`) can paint? A head reaches [`crate::Arrowhead::reach`] weights from its tip and /// [`crate::Arrowhead::half_width`] either side of its line (4.6 and 2 for most). With @@ -426,7 +494,7 @@ impl Appearance { } /// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape - /// (miter spikes, projecting or butt caps, arrowheads or an alignment) given by `shaped`. + /// (miter spikes, bevels, projecting or butt caps, arrowheads or an alignment) given by `shaped`. pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect { self.painted_strokes().fold(bounds, |acc, s| { if !s.needs_shape() { @@ -437,17 +505,19 @@ impl Appearance { }) } - /// Does this appearance have a visible butt-capped stroke? - pub fn has_butt_stroke(&self) -> bool { - self.painted_strokes().any(|s| s.cap == LineCap::Butt) + /// Does this appearance have a visible stroke that stops at the corners of its segments on an + /// open subpath or a closed one: butt caps, or bevel and miter joins? + pub fn has_cornered_stroke(&self) -> bool { + self.painted_strokes().any(|s| s.stops_at_corners(false)) } - /// Does a visible butt-capped stroke of this appearance run along an open subpath of `bp` with - /// a segment that starts or ends at a slant, not square to the axes, once placed by `a`? The - /// visual box's margin at the stroke's corners there depends on their direction, which a scale - /// out of proportion changes. - pub fn has_slanted_butt_stroke(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool { - if !self.has_butt_stroke() { + /// Does a visible stroke of this appearance that stops at the corners of its segments (butt + /// caps on an open subpath, bevel and miter joins) run along a subpath of `bp` where it does so, + /// with a segment that starts or ends at a slant, not square to the axes, once placed by `a`? + /// The visual box's margin at those corners, and at miter tips, depends on their direction, + /// which a scale out of proportion changes. + pub fn has_slanted_stroke_corners(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool { + if !self.has_cornered_stroke() { return false; } let slanted = |v: Vec2| { @@ -455,9 +525,10 @@ impl Appearance { (d.x * d.y).abs() > 1e-9 }; let els = bp.elements(); - subpaths(bp) - .into_iter() - .filter(|(_, closed)| !closed) + let subs = subpaths(bp); + let cornered = |closed: bool| self.painted_strokes().any(|s| s.stops_at_corners(closed)); + subs.into_iter() + .filter(|(_, closed)| cornered(*closed)) .any(|(r, _)| drawn(&els[r]).iter().any(|s| slanted(tangent(s, 0.0)) || slanted(tangent(s, 1.0)))) } @@ -468,28 +539,28 @@ impl Appearance { } } -/// How many symbol instances [`crate::Node::has_slanted_butt_stroke`] looks into for one object +/// How many symbol instances [`crate::Node::has_slanted_stroke_corners`] looks into for one object /// before it answers yes without looking further. pub const SLANT_INSTANCES: usize = 4096; impl crate::Node { - /// Does a visible butt-capped stroke of this object, of an object in it or of an object in a - /// symbol instance's art from `symbols`, placed by `a`, run along an open path with a segment - /// at a slant ([`Appearance::has_slanted_butt_stroke`])? Past [`SLANT_INSTANCES`] instances it - /// answers yes. - pub fn has_slanted_butt_stroke(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool { - self.slanted_butt(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES)) + /// Does a visible stroke of this object, of an object in it or of an object in a symbol + /// instance's art from `symbols`, placed by `a`, stop at the corners of a segment at a slant + /// ([`Appearance::has_slanted_stroke_corners`])? Past [`SLANT_INSTANCES`] instances it answers + /// yes. + pub fn has_slanted_stroke_corners(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool { + self.slanted_corners(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES)) } - /// [`Self::has_slanted_butt_stroke`] with `left` instances still to look into. - fn slanted_butt(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell) -> bool { + /// [`Self::has_slanted_stroke_corners`] with `left` instances still to look into. + fn slanted_corners(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell) -> bool { if let crate::NodeKind::SymbolInstance { symbol, xf } = &self.kind { let Some(l) = left.get().checked_sub(1) else { return true }; left.set(l); - return symbols.measure_art(symbol, |art| art.slanted_butt(symbols, a * *xf, left)).unwrap_or(false); + return symbols.measure_art(symbol, |art| art.slanted_corners(symbols, a * *xf, left)).unwrap_or(false); } - (self.appearance.has_butt_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_butt_stroke(&bp, a))) - || self.children().is_some_and(|c| c.iter().any(|c| c.slanted_butt(symbols, a, left))) + (self.appearance.has_cornered_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_stroke_corners(&bp, a))) + || self.children().is_some_and(|c| c.iter().any(|c| c.slanted_corners(symbols, a, left))) } } @@ -566,6 +637,68 @@ mod tests { assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0)); } + #[test] + fn a_bevel_stops_at_the_corners_of_the_segments_it_joins() { + // A 40 × 100 pt triangle stroked 20 pt: a miter limit of 2 or a bevel join bevels its + // 22.6° tip at (20, 0), so the stroke reaches 10 · 20 / √10400 pt above it, not 10 pt. + let mut tri = BezPath::new(); + tri.move_to((0.0, 100.0)); + tri.line_to((20.0, 0.0)); + tri.line_to((40.0, 100.0)); + tri.close_path(); + let tb = tri.bounding_box(); + let (top, side) = (-200.0 / 10400.0_f64.sqrt(), 1000.0 / 10400.0_f64.sqrt()); + let limited = stroke(20.0, |s| s.miter_limit = 2.0); + let bevel = stroke(20.0, |s| { + s.join = LineJoin::Bevel; + s.cap = LineCap::Round; + }); + let b = limited.bounds_on(&tri, tb); + assert!((b.y0 - top).abs() < 1e-9, "{b:?}"); + // The limit keeps the base corners' miters, whose tips reach about 12.2 pt left and right. + assert!(b.x0 < -12.0 && b.x1 > 52.0, "{b:?}"); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 20.0); + *ap.stroke_mut().unwrap() = bevel.clone(); + let b = ap.stroked_bounds(tb, || tri.clone()); + for (got, want) in [(b.x0, -side), (b.y0, top), (b.x1, 40.0 + side), (b.y1, 110.0)] { + assert!((got - want).abs() < 1e-9, "{b:?}"); + } + // Open, the same corner is beveled and the round caps reach the half weight round the ends. + let chevron = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]); + let b = bevel.bounds_on(&chevron, chevron.bounding_box()); + for (got, want) in [(b.x0, -10.0), (b.y0, top), (b.x1, 50.0), (b.y1, 110.0)] { + assert!((got - want).abs() < 1e-9, "{b:?}"); + } + // A round join, and the round caps of dashes, which can end at the tip, reach round it. + let round = stroke(20.0, |s| s.join = LineJoin::Round); + assert!((round.bounds_on(&tri, tb).y0 + 10.0).abs() < 1e-9); + let mut dashed = bevel.clone(); + dashed.dash = Some(Dash { pattern: vec![7.0, 4.0], offset: 0.0, align_corners: false }); + assert!((dashed.bounds_on(&chevron, chevron.bounding_box()).y0 + 10.0).abs() < 1e-9); + } + + #[test] + fn a_click_past_a_bevel_or_a_butt_end_misses() { + // 5 pt above the triangle's tip is within the half weight of it, but past the bevel, which + // reaches about 1.96 pt above it. A round join paints there. + let mut tri = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]); + tri.close_path(); + let (above, on) = (Point::new(20.0, -5.0), Point::new(20.0, -1.5)); + for join in [LineJoin::Bevel, LineJoin::Miter] { + let st = stroke(20.0, |s| (s.join, s.miter_limit) = (join, 2.0)); + assert!(!st.hits(&tri, FillRule::NonZero, above, 0.5), "{join:?}"); + assert!(st.hits(&tri, FillRule::NonZero, on, 0.0), "{join:?}"); + } + assert!(stroke(20.0, |s| s.join = LineJoin::Round).hits(&tri, FillRule::NonZero, above, 0.5)); + // 1.5 pt past a butt end of a 4 pt line misses, and a quarter point within it hits; a round + // cap paints past it. + let line = polyline(&[(0.0, 0.0), (0.0, 50.0)]); + let butt = stroke(4.0, |s| s.cap = LineCap::Butt); + assert!(!butt.hits(&line, FillRule::NonZero, Point::new(1.0, -1.5), 0.25)); + assert!(butt.hits(&line, FillRule::NonZero, Point::new(1.0, -0.2), 0.25)); + assert!(stroke(4.0, |s| s.cap = LineCap::Round).hits(&line, FillRule::NonZero, Point::new(1.0, -1.5), 0.25)); + } + /// An open path through `points`. fn polyline(points: &[(f64, f64)]) -> BezPath { let mut bp = BezPath::new(); @@ -691,15 +824,25 @@ mod tests { let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); ap.stroke_mut().unwrap().cap = LineCap::Butt; let id = vectorcraft_geom::Affine::IDENTITY; - assert!(!ap.has_slanted_butt_stroke(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id)); + assert!(!ap.has_slanted_stroke_corners(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id)); // Square ends, slanted segments in the middle. let apex = polyline(&[(0.0, 50.0), (0.0, 40.0), (10.0, 0.0), (20.0, 40.0), (20.0, 50.0)]); - assert!(ap.has_slanted_butt_stroke(&apex, id)); - // A rotation slants a vertical line; a round cap is never slanted. + assert!(ap.has_slanted_stroke_corners(&apex, id)); + // A rotation slants a vertical line. let v = polyline(&[(0.0, 0.0), (0.0, 50.0)]); - assert!(ap.has_slanted_butt_stroke(&v, vectorcraft_geom::Affine::rotate(0.3))); + assert!(ap.has_slanted_stroke_corners(&v, vectorcraft_geom::Affine::rotate(0.3))); + // With round caps, the miter join still stops at the corners of the slanted segments, on a + // closed path too; round caps and round joins never do. ap.stroke_mut().unwrap().cap = LineCap::Round; - assert!(!ap.has_slanted_butt_stroke(&apex, id)); + assert!(ap.has_slanted_stroke_corners(&apex, id)); + let mut tri = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]); + tri.close_path(); + ap.stroke_mut().unwrap().join = LineJoin::Bevel; + assert!(ap.has_slanted_stroke_corners(&tri, id)); + assert!(!ap.has_slanted_stroke_corners(&square(), id)); + ap.stroke_mut().unwrap().join = LineJoin::Round; + assert!(!ap.has_slanted_stroke_corners(&apex, id)); + assert!(!ap.has_slanted_stroke_corners(&tri, id)); } #[test] @@ -723,11 +866,11 @@ mod tests { } let top = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S12".into(), xf: vectorcraft_geom::Affine::IDENTITY }); let symbols = d.symbol_boxes(); - assert!(top.has_slanted_butt_stroke(&symbols, vectorcraft_geom::Affine::IDENTITY)); + assert!(top.has_slanted_stroke_corners(&symbols, vectorcraft_geom::Affine::IDENTITY)); assert!(symbols.art_measured() <= SLANT_INSTANCES, "{}", symbols.art_measured()); // One instance of S1 holds three instances, all looked into. let one = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S1".into(), xf: vectorcraft_geom::Affine::IDENTITY }); - assert!(!one.has_slanted_butt_stroke(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY)); + assert!(!one.has_slanted_stroke_corners(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY)); } #[test] diff --git a/crates/effects/src/stroke/tests_reach.rs b/crates/effects/src/stroke/tests_reach.rs index 29a48343ed..d38964ee01 100644 --- a/crates/effects/src/stroke/tests_reach.rs +++ b/crates/effects/src/stroke/tests_reach.rs @@ -78,6 +78,35 @@ fn a_butt_capped_open_path_is_measured_where_it_paints() { } } +#[test] +fn beveled_corners_are_measured_where_they_paint() { + let mut tip = BezPath::new(); + tip.move_to((0.0, 100.0)); + tip.line_to((20.0, 0.0)); + tip.line_to((40.0, 100.0)); + tip.close_path(); + let mut chevron = BezPath::new(); + chevron.move_to((0.0, 100.0)); + chevron.line_to((20.0, 0.0)); + chevron.line_to((40.0, 100.0)); + // A rectangle with no width turns back at its top and bottom. + let flat = vectorcraft_geom::shapes::rectangle(Rect::new(3.0, 100.0, 3.0, 150.0)).to_bezpath(); + let ps = paths(); + for (name, bp) in [("tip", &tip), ("chevron", &chevron), ("zigzag", &ps[1]), ("triangle", &ps[2]), ("flat", &flat)] { + for cap in [LineCap::Butt, LineCap::Round, LineCap::Square] { + for (join, limit) in [(LineJoin::Bevel, 10.0), (LineJoin::Miter, 1.0), (LineJoin::Miter, 2.0), (LineJoin::Miter, 10.0)] { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 20.0); + (st.cap, st.join, st.miter_limit) = (cap, join, limit); + let painted = outline_region(&PathData::from_bezpath(bp), FillRule::NonZero, &st).to_bezpath().bounding_box(); + let bounds = st.bounds_on(bp, bp.bounding_box()); + for (got, want) in [(bounds.x0, painted.x0), (bounds.y0, painted.y0), (bounds.x1, painted.x1), (bounds.y1, painted.y1)] { + assert!((got - want).abs() < 0.05, "{name} {cap:?} {join:?} {limit}: measured {bounds:?}, paints {painted:?}"); + } + } + } + } +} + #[test] fn bounds_and_clicks_cover_what_every_stroke_paints() { let profiles = [None, Some(WidthProfile::lens()), Some(WidthProfile { points: vec![(0.0, 2.0, 0.5), (0.6, 0.8, 1.6), (1.0, 1.2, 0.3)] })]; diff --git a/crates/engine/src/cmd/object/bounds.rs b/crates/engine/src/cmd/object/bounds.rs index 3dc1c37dad..0a427e3ea0 100644 --- a/crates/engine/src/cmd/object/bounds.rs +++ b/crates/engine/src/cmd/object/bounds.rs @@ -133,16 +133,16 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec, least: f64, anchor // the objects differ, the members of groups and the objects in symbols' art included, and with // strokes that scale, when the sizes are out of proportion. The objects are then measured one // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects, - // objects with a butt-capped stroke along a segment at a slant, whose margins change with the - // segment's direction, and type measured by its glyphs, are measured as drawn after each scale - // tried instead ([`refit_drawn`]). + // objects with a stroke that stops at the corners of a segment at a slant (butt caps, bevel and + // miter joins), whose margins change with the segment's direction, and type measured by its + // glyphs, are measured as drawn after each scale tried instead ([`refit_drawn`]). let to_local = frame.to_doc().inverse(); let drawn = || { by_glyphs || ids .iter() .filter_map(|id| d.node(*id)) - .any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local)) + .any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_stroke_corners(&symbols, to_local)) }; // Without Use Preview Bounds the box has no margins (`v`, `vs` and `g` are one box, glyph ink // included), so the scales above are exact and nothing is refitted. diff --git a/crates/engine/src/tests/previewbounds.rs b/crates/engine/src/tests/previewbounds.rs index 773b2b7842..c0ce01cb57 100644 --- a/crates/engine/src/tests/previewbounds.rs +++ b/crates/engine/src/tests/previewbounds.rs @@ -581,11 +581,13 @@ fn set_bounds_measures_paths_turned_together_square_to_their_box() { fn a_size_below_what_the_kept_strokes_of_several_paths_add_gives_them_no_width() { // With no width, the paths have a visual box 10 pt wide, the width of the thick stroke. A width // of 0 or more below 10 gives both paths no width, and the left edge of the visual box stays at - // -2. A negative width turns the paths over, as it does one path. + // -2. With no width each path turns back at its top and bottom, where the miter join bevels, + // so the visual box runs from y 100 to 150. A negative width turns the paths over, as it does + // one path. let mut wrong = vec![]; for (width, paths, visual) in [ - (8.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 95.0, 8.0, 155.0)), - (0.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 95.0, 8.0, 155.0)), + (8.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 100.0, 8.0, 150.0)), + (0.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 100.0, 8.0, 150.0)), (-37.0, [Rect::new(-44.0, 100.0, 0.0, 150.0), Rect::new(-44.0, 100.0, -13.2, 150.0)], Rect::new(-49.0, 95.0, 1.0, 155.0)), ] { let (mut s, ids) = thin_and_thick(); @@ -666,6 +668,28 @@ fn set_bounds_gives_a_butt_path_with_slanted_corners_the_size_out_of_proportion( assert!((v.width() - 60.0).abs() < 1e-6 && (v.height() - 20.0).abs() < 1e-6, "{v:?}"); } +#[test] +fn set_bounds_gives_a_closed_path_with_beveled_or_mitered_corners_the_size_out_of_proportion() { + // The bevel at the triangle's tip, the miter the limit bevels there and the miter spikes it + // keeps reach past the path by margins that follow the slanted segments' directions, which a + // scale out of proportion changes. + let mut wrong = vec![]; + for (join, limit) in [("bevel", 10.0), ("miter", 2.0), ("miter", 10.0)] { + let mut s = session(); + let anchors = json!([{"x": 100, "y": 150}, {"x": 110, "y": 100}, {"x": 120, "y": 150}]); + let id = NodeId(run(&mut s, "path.create", json!({"anchors": anchors, "closed": true}))["id"].as_u64().unwrap()); + run(&mut s, "stroke.set", json!({"weight": 2, "cap": "round", "join": join, "miterLimit": limit})); + preview(&mut s, true); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.setBounds", json!({"width": 60, "height": 20, "reference": 0, "strokes": false})); + let v = s.transform_bounds(&[id]).unwrap(); + if (v.width() - 60.0).abs() > 1e-6 || (v.height() - 20.0).abs() > 1e-6 { + wrong.push(format!("{join} {limit}: {v:?}")); + } + } + assert!(wrong.is_empty(), "{wrong:?}"); +} + #[test] fn set_bounds_gives_a_symbol_instance_of_a_diagonal_butt_line_the_size_out_of_proportion() { let mut s = session(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 468dce2e91..a16bdc7164 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1146,6 +1146,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). - A butt-capped curve whose first or last handle sits on its end point also stops at that end: a straight 50 pt curve with a 2 pt stroke and its first handle on its start point measures 50 pt tall, not 51 pt (doc `reach::tests::a_butt_curve_with_a_handle_on_its_end_point_stops_near_that_end`). + - A bevel join, and a miter join whose miter limit bevels the corner, end the stroke at the corners of the two segments they join. VectorCraft's visual box of such a stroke, on closed and open paths, reaches a beveled corner only as far as those corners: a 40 × 100 pt triangle with a 20 pt stroke and a miter limit of 2 reaches about 1.96 pt above its tip instead of 10 pt. Round joins and the round caps of dashes, which can end at a corner, still reach half the weight round it, and the projecting caps of dashes reach the diagonal of their square. With Use Preview Bounds on, `object.setBounds` measures an object as drawn when a beveled or mitered corner, or a butt cap, is on a segment at a slant, so a width and height out of proportion give the size typed. A click past a bevel or a butt cap, by more than the click tolerance, does not select the stroke, except on a stroke with a width profile or with dashes that have round or projecting caps, and at the end of a curve that bends more tightly than half the weight. Public references: [Apply stroke color, width, and alignment](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/apply-stroke-color-width-and-alignment.html), [PDF 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.4.3.4 "Line Join Style" and 8.4.3.5 "Miter Limit" (doc `reach::tests::a_bevel_stops_at_the_corners_of_the_segments_it_joins` and `a_click_past_a_bevel_or_a_butt_end_misses`, effects `stroke::tests_reach::beveled_corners_are_measured_where_they_paint`, engine `tests::previewbounds::set_bounds_gives_a_closed_path_with_beveled_or_mitered_corners_the_size_out_of_proportion`). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds`, Swap Objects, Replace with Top Object, Set Size of Each and Sort Objects measure each point type object selected as an object by the ink of its glyphs, underline and strikethrough included, instead of its em box. A typed W or H scales the live type until its ink has that size; the font size follows the vertical scale, so a W typed alone, out of proportion, changes the horizontal scale instead. Moved type snaps by its ink box. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). From 0691e1acbee30059f35f7acbe116967c3d4e5e07 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 11:30:44 -0800 Subject: [PATCH 15/23] Gradients: keep every run of a repeated gradient with many stops When a repeated or reflected gradient needs more stops than a limit allows, each run now leaves out the stops that change its colors least, so every run is still written, instead of writing fewer runs and padding the rest with the last color. Each run keeps its ends, sharp steps, swatch-linked stops, tint and CMYK ink changes and spans shaped by an off-center midpoint, and the colors are compared as the gradient's method mixes them. This applies to gradients along or across a stroke, CSS conic gradients, and PDF, EPS and .ai export. - Outline Stroke's mesh fits the gradient to at most 1,024 stops, and splits a stroke into several meshes when one would hold more than 2^20 points. - Object > Expand leaves a gradient as it is when the box needs more than 256 runs, instead of expanding only the first runs. - CSS writes the stop positions of repeating gradients with four decimals, so the period stays in place across the box. - A swatch chip of a gradient that repeats gets at least four grid cells a run along each side (at most one a point, and 128), so it does not show one color when the count matches the chip's cells. - docs/mcp.md states the stop limits and the PDF import sample budget. --- crates/color/src/gradient/spread.rs | 126 ++++++++++++++++++-- crates/color/src/gradient/spread/tests.rs | 122 ++++++++++++++++++- crates/effects/src/stroke/gradient.rs | 33 ++++- crates/effects/src/stroke/tests_gradient.rs | 29 +++++ crates/engine/src/cmd/expand.rs | 23 ++-- crates/engine/src/tests/objexpand.rs | 20 ++++ crates/svg/src/css.rs | 5 +- crates/svg/tests/it/css_rules.rs | 7 ++ crates/ui-egui/src/widgets.rs | 33 +++++ crates/ui-egui/src/widgets/chips.rs | 38 +++++- docs/mcp.md | 15 ++- 11 files changed, 411 insertions(+), 40 deletions(-) diff --git a/crates/color/src/gradient/spread.rs b/crates/color/src/gradient/spread.rs index e449151d89..8f9933c40c 100644 --- a/crates/color/src/gradient/spread.rs +++ b/crates/color/src/gradient/spread.rs @@ -4,7 +4,8 @@ use kurbo::{Point, Rect, Vec2}; -use super::{Gradient, GradientGeom, GradientKind, GradientSpread, GradientStop}; +use super::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientSpread, GradientStop}; +use crate::Color; /// Most runs of the stops [`Gradient::padded_over`] writes. pub const MAX_SPREAD_RUNS: usize = 256; @@ -33,8 +34,8 @@ pub struct Padded { pub first: f64, /// How many runs are written, evenly spaced from [`Self::first`] to 1. pub runs: usize, - /// The area needed more runs than [`MAX_SPREAD_RUNS`] (or than [`MAX_SPREAD_STOPS`] allows), - /// or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written. + /// The area needed more runs than [`MAX_SPREAD_RUNS`] (or more than half the stops allowed, + /// two to a run), or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written. pub capped: bool, } @@ -50,7 +51,9 @@ impl Gradient { self.padded_within(geom, area, MAX_SPREAD_STOPS) } - /// [`Self::padded_over`] with at most `max_stops` stops (and at least one run). + /// [`Self::padded_over`] with at most `max_stops` stops (and at least one run). When the runs + /// written need more stops than that, each run leaves out the stops that change its colors + /// least ([`fit_run`]), so every run is still written. pub fn padded_within(&self, geom: &GradientGeom, area: Rect, max_stops: usize) -> Option { let kind = self.kind; if !self.is_spread() || matches!(kind, GradientKind::Conic | GradientKind::Freeform) || self.stops.is_empty() { @@ -68,8 +71,8 @@ impl Gradient { // smallest 0 when the area holds the rings' center, else on its edge. let lo = if centered { inner_level(&period, kind, area).floor() } else { ts.iter().copied().fold(f64::INFINITY, f64::min).floor() }; let hi = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max).ceil().max(lo + 1.0); - let one = whole_run(&self.stops); - let most = MAX_SPREAD_RUNS.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1); + let budget = max_stops.min(MAX_SPREAD_STOPS); + let most = MAX_SPREAD_RUNS.min(budget / 2).max(1); let capped = hi - lo > most as f64; // Capped, the runs kept are the area's innermost ones (radial and diamond), or (linear) // those around the vector's start where the area reaches it, else the area's runs nearest @@ -79,6 +82,7 @@ impl Gradient { _ => lo, }; let n = (hi - lo).min(most as f64).max(1.0); + let one = fit_run(whole_run(&self.stops), budget / n as usize, self.method); // A radial or diamond writes its runs among the levels from 0, which its stops' offsets // (single-precision fractions of the outermost level) tell apart only so far out, the // nearer the closer its stops lie: past that, the runs end there and the gradient pads. @@ -112,8 +116,9 @@ impl Gradient { self.unrolled_within(MAX_SPREAD_STOPS) } - /// [`Self::unrolled`] with at most `max_stops` stops (and at least one run): past the runs - /// that fit, the last one pads. + /// [`Self::unrolled`] with at most `max_stops` stops: every run, each run without the stops + /// that change its colors least ([`fit_run`]) when the runs need more stops than that. Only with + /// fewer than two stops for each run do the runs past those that fit pad with the last one. pub fn unrolled_within(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> { use std::borrow::Cow; if !self.is_spread() { @@ -123,13 +128,29 @@ impl Gradient { if self.stops.is_empty() { return Cow::Owned(Gradient { spread: GradientSpread::Pad, repeats: 1, ..self.clone() }); } - let one = whole_run(&self.stops); let n = self.repeat_count() as usize; - let kept = n.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1); + let budget = max_stops.min(MAX_SPREAD_STOPS); + let kept = n.min(budget / 2).max(1); + let one = fit_run(whole_run(&self.stops), budget / kept, self.method); let stops = self.runs(&one, false, 0.0, kept, n as f64); Cow::Owned(Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }) } + /// The gradient as [`Self::unrolled`] gives it with at most `max_stops` stops (at least two): + /// a gradient that repeats or reflects as [`Self::unrolled_within`] gives it, and one that + /// pads with more stops than that without the stops that change its colors least + /// ([`fit_run`]). Others as they are. + pub fn fitted(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> { + use std::borrow::Cow; + if self.is_spread() { + return self.unrolled_within(max_stops); + } + if self.stops.len() <= max_stops.max(2) { + return Cow::Borrowed(self); + } + Cow::Owned(Gradient { stops: fit_run(whole_run(&self.stops), max_stops, self.method), ..self.clone() }) + } + /// One cycle of a gradient that repeats or reflects, which repeated end to end paints it, as /// a gradient that pads: one run of the stops (Repeat), or a run and its mirror (Reflect), /// between 0 and 1, and how many runs that is (1 or 2). `None` for a gradient that pads and @@ -257,6 +278,91 @@ fn whole_run(stops: &[GradientStop]) -> Vec { out } +/// `one` (a run of a gradient's stops, [`whole_run`]) with at most `budget` stops (at least two): +/// as it is when it fits, else without the stops that change its colors least, left out one at a +/// time. Leaving out a stop costs how far its color lies from the one that mixing the stops still +/// on either side of it by `method` gives there, and how far its CMYK inks, opacity, tint or +/// process plates lie from the line between theirs (a whole unit when its swatch link or color +/// model differs from either of theirs, and a whole unit more when its midpoint or the one before +/// it is off center), so the ends, sharp steps, ink changes and shaped spans stay. A stop whose +/// next stop was left out gets its midpoint centered. The work is the stops times their logarithm. +fn fit_run(one: Vec, budget: usize, method: GradientMethod) -> Vec { + use std::cmp::Reverse; + use std::collections::BinaryHeap; + let budget = budget.max(2); + let n = one.len(); + if n <= budget { + return one; + } + let mix = |x: &Color, y: &Color, v: f32| match method { + GradientMethod::Classic => x.lerp_keep_model(y, v), + m => m.mix(x, y, v), + }; + let cost = |a: &GradientStop, s: &GradientStop, b: &GradientStop| -> u32 { + let span = b.offset - a.offset; + let u = if span > 0.0 { ((s.offset - a.offset) / span).clamp(0.0, 1.0) } else { 0.0 }; + // Left out, the span from `a` to `b` has a centered midpoint, so it mixes them by `u`. + let (want, cs) = (mix(&a.color, &b.color, u).to_rgb(), s.color.to_rgb()); + let color = (0..3).map(|k| (want[k] - cs[k]).abs()).fold(0.0, f32::max); + let opacity = (a.opacity + (b.opacity - a.opacity) * u - s.opacity).abs(); + // Process inks, which colors that look alike can print differently: off their line, or a + // change of color model. + let process = match (&a.color, &s.color, &b.color) { + (Color::Cmyk { c: c0, m: m0, y: y0, k: k0 }, Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c1, m: m1, y: y1, k: k1 }) => { + [(c0, c, c1), (m0, m, m1), (y0, y, y1), (k0, k, k1)].iter().map(|(p, q, r)| (*p + (*r - *p) * u - *q).abs()).fold(0.0, f32::max) + } + _ if a.color.model() == s.color.model() && s.color.model() == b.color.model() => 0.0, + _ => 1.0, + }; + let color = color.max(process); + // Separations: a change of swatch link, or of tint or process plates off their line. + let ink = if s.swatch != a.swatch || s.swatch != b.swatch { + 1.0 + } else { + let tint = (a.tint + (b.tint - a.tint) * u - s.tint).abs(); + let plates = match (a.plates, s.plates, b.plates) { + (None, None, None) => 0.0, + (Some(pa), Some(ps), Some(pb)) => (0..4).map(|k| (pa[k] + (pb[k] - pa[k]) * u - ps[k]).abs()).fold(0.0, f32::max), + _ => 1.0, + }; + tint.max(plates) + }; + // Leaving it out merges two spans, and an off-center midpoint shapes one of them. + let shaped = if [a.midpoint, s.midpoint].iter().any(|m| (m - 0.5).abs() > 1e-3) { 1.0 } else { 0.0 }; + // Non-negative, so the bits order as the numbers do (NaN counts as 0). + (color.max(opacity).max(ink) + shaped).max(0.0).to_bits() + }; + // The stops still kept on either side of each stop, and each stop's latest cost entry. + let mut prev: Vec = (0..n).map(|i| i.saturating_sub(1)).collect(); + let mut next: Vec = (1..=n).collect(); + let mut gone = vec![false; n]; + let mut version = vec![0u32; n]; + let mut heap: BinaryHeap> = (1..n - 1).map(|i| Reverse((cost(&one[i - 1], &one[i], &one[i + 1]), i, 0))).collect(); + let mut left = n; + while left > budget { + let Some(Reverse((_, i, v))) = heap.pop() else { break }; + if gone[i] || v != version[i] { + continue; + } + gone[i] = true; + left -= 1; + let (a, b) = (prev[i], next[i]); + next[a] = b; + prev[b] = a; + for j in [a, b] { + if j != 0 && j != n - 1 { + version[j] += 1; + heap.push(Reverse((cost(&one[prev[j]], &one[j], &one[next[j]]), j, version[j]))); + } + } + } + one.into_iter() + .enumerate() + .filter(|(i, _)| !gone[*i]) + .map(|(i, s)| if next[i] == i + 1 { s } else { GradientStop { midpoint: 0.5, ..s } }) + .collect() +} + /// A run mirrored: the stops in reverse, each midpoint measured from the other side. fn mirror(run: &[GradientStop]) -> Vec { let mut g = Gradient::new(GradientKind::Linear, run.to_vec()); diff --git a/crates/color/src/gradient/spread/tests.rs b/crates/color/src/gradient/spread/tests.rs index 2465039ca5..a88319ccaa 100644 --- a/crates/color/src/gradient/spread/tests.rs +++ b/crates/color/src/gradient/spread/tests.rs @@ -148,11 +148,12 @@ fn radial_and_diamond_gradients_pad_in_rings_from_their_first_level() { } } -/// A run of the stops far too short for the area, or a gradient of many stops, is written with at -/// most so many runs and stops, and pads past them; a conic, a padding gradient and one without -/// stops have nothing to pad. +/// A run of the stops far too short for the area is written with at most so many runs, and pads +/// past them; a gradient of many stops is written with every run the area needs, each run +/// without the stops that change its colors least, within so many stops; a conic, a padding +/// gradient and one without stops have nothing to pad. #[test] -fn far_repeats_and_many_stops_are_capped() { +fn far_repeats_are_capped_and_many_stops_are_sampled() { let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.01, 0.0), aspect: 1.0, focal: None }; let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, black_white()) }; let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap(); @@ -162,7 +163,13 @@ fn far_repeats_and_many_stops_are_capped() { let g = Gradient { spread: GradientSpread::Reflect, repeats: 50, ..Gradient::new(GradientKind::Linear, many) }; let wide = GradientGeom { end: Point::new(100.0, 0.0), ..geom }; let p = g.padded_over(&wide, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap(); - assert!(p.capped && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{}", p.gradient.stops.len()); + assert!(!p.capped && p.runs == 50 && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{} {}", p.runs, p.gradient.stops.len()); + for x in [0.7, 33.3, 66.1, 99.5] { + let q = Point::new(x, 0.5); + let want = g.sample(g.spread_param(wide.param_at(GradientKind::Linear, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{x}: {want:?} vs {got:?}"); + } assert!(g.unrolled().stops.len() <= MAX_SPREAD_STOPS); assert!(p.gradient.padded_over(&p.geom, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none(), "it pads"); assert!(spread(GradientKind::Conic, GradientSpread::Repeat, 3).padded_over(&wide, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none()); @@ -325,6 +332,111 @@ fn unrolled_and_cycle_stops() { assert!(matches!(Gradient::default().unrolled(), std::borrow::Cow::Borrowed(_))); } +/// A gradient of more stops than [`MAX_SPREAD_STOPS`] allows for all its runs keeps every run +/// when unrolled, each run without the stops that change its colors least: its last run ends on +/// its last color instead of the rest padding with it. +#[test] +fn unrolled_many_stops_keep_every_run() { + let stops: Vec = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(i as f32 / 199.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, stops) }; + let u = g.unrolled(); + assert!(u.stops.len() <= MAX_SPREAD_STOPS, "{}", u.stops.len()); + for t in [0.005, 0.5051, 0.9149, 0.9951] { + let want = g.sample(g.spread_param(t) as f32).0.to_rgb(); + let got = u.sample(t as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{t}: {want:?} vs {got:?}"); + } + // A padding gradient fitted to fewer stops keeps its ends. + let pad = Gradient::new(GradientKind::Linear, g.stops.clone()); + let fitted = pad.fitted(16); + assert_eq!(fitted.stops.len(), 16); + for (stop, want) in [(&fitted.stops[0], Color::gray(0.0)), (&fitted.stops[15], Color::gray(1.0))] { + assert!(stop.color.to_rgb().iter().zip(want.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), "{:?}", stop.color); + } + assert!(matches!(pad.fitted(500), std::borrow::Cow::Borrowed(_))); +} + +/// One run of more stops than the limit (255 inside it, and a copy of each end color at 0 and 1) +/// is written within the limit, every run the area needs included. +#[test] +fn a_run_over_the_stop_limit_is_written_within_it() { + let stops: Vec = (1..=255).map(|i| GradientStop::new(i as f32 / 256.0, Color::gray(i as f32 / 256.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, stops) }; + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(20.0, 0.0), aspect: 1.0, focal: None }; + let p = g.padded_within(&geom, Rect::new(0.0, 0.0, 20.0, 1.0), 256).unwrap(); + assert!(!p.capped && p.runs == 2 && p.gradient.stops.len() <= 256, "{} {} {}", p.capped, p.runs, p.gradient.stops.len()); + assert!(g.unrolled_within(256).stops.len() <= 256); +} + +/// Fitting a run to fewer stops keeps a narrow band between two sharp steps, a stop linked to a +/// swatch, and each kept stop's link and tint, where sampling at evenly spaced places would lose +/// them. +#[test] +fn fitting_keeps_sharp_steps_and_swatch_links() { + let mut stops: Vec = (0..300).map(|i| GradientStop::new(i as f32 / 299.0, Color::gray(i as f32 / 299.0))).collect(); + let red = Color::rgb(1.0, 0.0, 0.0); + // A band of red from 0.5003 to 0.5013, with a sharp step at each side. + for (offset, color) in [(0.5003, None), (0.5003, Some(red)), (0.5013, Some(red)), (0.5013, None)] { + let gray = Color::gray(offset); + stops.push(GradientStop::new(offset, color.unwrap_or(gray))); + } + let spot = GradientStop { swatch: Some("Ink".into()), tint: 0.4, ..GradientStop::new(0.25, Color::gray(0.25)) }; + stops.push(spot.clone()); + // Three stops of one ink that look alike but print at different tints. + for (offset, tint) in [(0.8, 0.2), (0.81, 0.9), (0.82, 0.2)] { + stops.push(GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(offset)) }); + } + // A span shaped by an off-center midpoint. + if let Some(s) = stops.iter_mut().find(|s| (s.offset - 0.6).abs() < 2e-3) { + s.midpoint = 0.13; + } + stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + let g = Gradient::new(GradientKind::Linear, stops); + let fitted = g.fitted(32); + assert!(fitted.stops.len() <= 32, "{}", fitted.stops.len()); + let red_at = |t: f32| fitted.sample(t).0.to_rgb(); + assert_eq!(red_at(0.5008), [1.0, 0.0, 0.0], "the band stays"); + assert!(red_at(0.499)[1] > 0.4 && red_at(0.503)[1] > 0.4, "the band stays narrow"); + assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.4 && s.offset == 0.25), "{:?}", fitted.stops); + assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink2") && s.tint == 0.9), "the tint change stays"); + assert!(fitted.stops.iter().any(|s| s.midpoint == 0.13), "the shaped span stays"); + // Of a stop that changes only its ink's tint and one that the colors' line nearly gives, the + // first stays. (The stop before an ink starts stays as well: leaving it out would blend the + // ink in sooner.) + let ink = |offset: f32, tint: f32| GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(0.5)) }; + let few = vec![ + GradientStop::new(0.0, Color::gray(0.0)), + GradientStop::new(0.2, Color::gray(0.17)), + GradientStop::new(0.3, Color::gray(0.3)), + ink(0.4, 0.2), + ink(0.5, 0.9), + ink(0.6, 0.2), + GradientStop::new(1.0, Color::gray(1.0)), + ]; + let fitted = Gradient::new(GradientKind::Linear, few).fitted(6).into_owned(); + let offsets: Vec = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.3, 0.4, 0.5, 0.6, 1.0]); + // A Perceptual gradient leaves out the stop its own mixing gives, not the one the RGB line + // gives. + let (blue, yellow, gray) = (Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0), Color::rgb(0.5, 0.5, 0.5)); + let on_curve = GradientMethod::Perceptual.mix(&gray, &yellow, 0.5); + let stops = vec![GradientStop::new(0.0, blue), GradientStop::new(0.5, gray), GradientStop::new(0.75, on_curve), GradientStop::new(1.0, yellow)]; + let perceptual = Gradient { method: GradientMethod::Perceptual, ..Gradient::new(GradientKind::Linear, stops) }; + let fitted = perceptual.fitted(3).into_owned(); + let offsets: Vec = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.5, 1.0]); + // A black of other inks between blacks that look the same prints differently: it stays. + let k = |offset: f32| GradientStop::new(offset, Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0 }); + let rich = GradientStop::new(0.5, Color::Cmyk { c: 0.6, m: 0.4, y: 0.4, k: 1.0 }); + let blacks = vec![k(0.0), k(0.4), rich, k(0.6), k(1.0)]; + let fitted = Gradient::new(GradientKind::Linear, blacks).fitted(3).into_owned(); + let offsets: Vec = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.5, 1.0]); + let spread = Gradient { spread: GradientSpread::Repeat, repeats: 4, ..g.clone() }; + let u = spread.unrolled_within(128); + assert_eq!(u.stops.iter().filter(|s| s.swatch.as_deref() == Some("Ink")).count(), 4, "one linked stop a run"); +} + /// Pixels (what the canvas draws a diamond, dithered or Perceptual gradient as, and what SVG and /// PDF write as images) repeat with the stops, for each kind. #[test] diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index ec54be20cd..ffd128b6dd 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -32,6 +32,12 @@ const SHARP: f64 = 1.25; const HAIRPIN: f64 = 4.0; /// Rows of a mesh across the stroke (more where the gradient has stops). const MESH_ROWS: usize = 16; +/// The most stops of the gradient [`gradient_meshes`] puts rows or columns at +/// ([`Gradient::fitted`]: past it, the stops that change the colors least are left out). +pub(crate) const MAX_MESH_STOPS: usize = 1024; +/// The most points of one mesh [`gradient_meshes`] makes, unless a single pair of ribs needs more: +/// a subpath with more ribs than leave room for a row at each stop becomes several meshes. +pub(crate) const MAX_MESH_POINTS: usize = 1 << 20; /// The most of a subpath one column of a mesh along the stroke spans. const MESH_STEP: f64 = 1.0 / 32.0; /// The most of a subpath one slice spans while a width profile shapes the across gradient. @@ -513,26 +519,33 @@ fn mesh_marks(g: &Gradient) -> Vec { /// Outline Stroke of stroke `st` of `bp` (filled with `rule`) whose gradient `g` runs along or /// across it: a gradient mesh per subpath over all the stroke can paint there (clip it to the -/// stroke's outline), coloured as [`gradient_slices`] paint it. +/// stroke's outline), coloured as [`gradient_slices`] paint it. The gradient's stops are fitted to +/// [`MAX_MESH_STOPS`], and a subpath whose mesh would hold more than [`MAX_MESH_POINTS`] points is +/// split into meshes of fewer ribs ([`rib_pieces`]). pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec { - let g = &*g.unrolled(); + let fitted = g.fitted(MAX_MESH_STOPS); let across = st.gradient_mode == StrokeGradientMode::Across; - let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); - let at_stops = mesh_marks(g); // Along: a column at each stop (colours are linear between columns), close enough to look smooth. - let marks: Vec = if across { vec![] } else { at_stops.clone() }; + let marks: Vec = if across { vec![] } else { mesh_marks(&fitted) }; let max_dt = match (across, st.profile.is_some()) { (false, _) => MESH_STEP, (true, true) => PROFILE_STEP, (true, false) => 1.0, }; // Rows across: the strip's edges, then (across) the band's left edge to its right edge with a row at each stop. - let mut us: Vec = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(at_stops).collect() } else { vec![0.5] }; + let mut us: Vec = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(mesh_marks(&fitted)).collect() } else { vec![0.5] }; us.sort_by(f64::total_cmp); us.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + let us = &us; + let g = &*fitted; + let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); + // A mesh is a point for each row (and its two edges) on each rib: a strip with more ribs than + // that leaves room for is split into meshes of fewer ribs. + let per_mesh = MAX_MESH_POINTS / (us.len() + 2); strips(bp, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt) .into_iter() .filter(|ribs| ribs.len() > 1) + .flat_map(|ribs| rib_pieces(ribs.len(), per_mesh).into_iter().map(move |r| ribs.get(r).unwrap_or_default().to_vec())) .map(|ribs| { let (rows, cols) = (us.len() + 1, ribs.len() - 1); // Each level across: a point on each rib and the gradient position there. @@ -569,3 +582,11 @@ pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradi }) .collect() } + +/// The ribs (index ranges of `n`, at least two) of each mesh [`gradient_meshes`] makes of a strip: +/// at most `per_mesh` ribs each (at least two), each starting on the last rib of the one before, +/// so the meshes meet without a gap. +pub(crate) fn rib_pieces(n: usize, per_mesh: usize) -> Vec> { + let per_mesh = per_mesh.max(2); + (0..n.saturating_sub(1)).step_by(per_mesh - 1).map(|start| start..(start + per_mesh).min(n)).collect() +} diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index 3745e4a6f6..784c84d1db 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -345,6 +345,35 @@ fn a_narrow_peak_where_a_reflecting_gradient_turns_back_stays_in_the_mesh() { } } +/// Outline Stroke's mesh across a stroke of many ribs, with a gradient of many stops repeated 100 +/// times: each mesh holds at most so many points, and every run still starts on its first color. +#[test] +fn a_mesh_across_many_ribs_and_stops_stays_bounded() { + use vectorcraft_color::GradientSpread; + let pts: Vec<(f64, f64)> = (0..1200).map(|i| (i as f64 * 10.0, if i % 2 == 0 { 0.0 } else { 10.0 })).collect(); + let bp = poly(&pts, false); + let stops: Vec = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(1.0 - i as f32 / 199.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(vectorcraft_color::GradientKind::Linear, stops) }; + let mut st = stroke(4.0, StrokeGradientMode::Across); + st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone()))); + let meshes = gradient_meshes(&bp, FillRule::NonZero, &st, &g); + assert!(!meshes.is_empty()); + for m in &meshes { + assert!(m.points.len() <= super::gradient::MAX_MESH_POINTS, "{} points", m.points.len()); + // A row at the start of each of the 100 runs, on every rib, is white. + let light = m.points.iter().filter(|p| p.color.to_rgb()[0] > 0.97).count(); + assert!(light >= 100 * (m.cols as usize + 1), "{light} light points of {}", m.points.len()); + } + // A strip of more ribs than one mesh has room for is split into meshes that share their end + // ribs, every rib in one of them. + use super::gradient::rib_pieces; + assert_eq!(rib_pieces(5, 3), [0..3, 2..5]); + assert_eq!(rib_pieces(4, 3), [0..3, 2..4]); + let one = rib_pieces(2, 1000); + assert_eq!((one.len(), one.first()), (1, Some(&(0..2)))); + assert_eq!(rib_pieces(3, 0), [0..2, 1..3]); +} + /// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along /// the path, however many points lie that far: not as far as it is wide anywhere when more than /// 256 do. diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 5737235963..bcf883b2f3 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", + "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", has_selection, expand ), @@ -476,8 +476,9 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?; vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))] } else { - // A gradient that repeats or reflects expands as its stops repeated over the box. - let (g, runs) = padded_on(g, bounds); + // A gradient that repeats or reflects expands as its stops repeated over the box (and + // stays a gradient when the box needs more runs than are written). + let (g, runs) = padded_on(g, bounds)?; let g = &*g; let frame = Frame::new(g, bounds); match to { @@ -1241,26 +1242,32 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model Box::new(move |p| g.gradient.sample_keep_model(spread(geom.param_at(g.gradient.kind, p)) as f32)) } +/// For stops repeated by [`padded_on`]: where along the vector (0 to 1) their first run starts and +/// how many runs lie evenly from there to 1. +type Runs = Option<(f64, usize)>; + /// `g` placed on box `bounds` as a gradient that pads past its ends: a linear, radial or diamond /// gradient that repeats or reflects with its stops repeated as many times as the box needs /// ([`vectorcraft_color::Gradient::padded_over`]), a conic one with its stops repeated around the /// turn ([`vectorcraft_color::Gradient::unrolled`]); others as they are. With it, for the repeated /// stops, where along the vector (0 to 1) their first run starts and how many runs lie evenly from -/// there to 1. -fn padded_on(g: &GradientPaint, bounds: Rect) -> (std::borrow::Cow<'_, GradientPaint>, Option<(f64, usize)>) { +/// there to 1. `None` when the box needs more runs than are written +/// ([`vectorcraft_color::Padded::capped`]): the gradient stays. +fn padded_on(g: &GradientPaint, bounds: Rect) -> Option<(std::borrow::Cow<'_, GradientPaint>, Runs)> { use std::borrow::Cow; if !g.gradient.is_spread() { - return (Cow::Borrowed(g), None); + return Some((Cow::Borrowed(g), None)); } let geom = g.resolve(bounds); match g.gradient.padded_over(&geom, bounds) { + Some(p) if p.capped => None, Some(p) => { let runs = Some((p.first, p.runs)); - (Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs) + Some((Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs)) } None => { let runs = Some((0.0, g.gradient.repeat_count() as usize)); - (Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs) + Some((Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs)) } } } diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 3dacee533b..65d638e641 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -508,6 +508,26 @@ fn a_spread_gradient_expands_into_whole_bands_for_each_run() { } } +/// A gradient that repeats across a box in more runs than are written (700 runs of 0.2 pt) is not +/// expanded into its first runs and a flat last color: the fill stays a gradient, and the rest of +/// the object (its stroke) still expands. +#[test] +fn a_spread_gradient_with_more_runs_than_written_stays_a_gradient() { + for to in [json!({"steps": 240}), json!({"gradient": "mesh"})] { + let mut s = session(); + let gr = json!({"kind": "linear", "spread": "repeat", "repeats": 100, "start": [30, 100], "end": [50, 100], + "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gr, true); + let before = render(&s); + run(&mut s, "object.expand", to.clone()); + let after = render(&s); + assert!(mean_diff(&before, &after) < 2.0, "{to}: {}", mean_diff(&before, &after)); + let mut gradients = 0; + s.doc().unwrap().doc.walk(|n| gradients += usize::from(matches!(n.appearance.fill_paint(), Paint::Gradient(_)))); + assert_eq!(gradients, 1, "{to}"); + } +} + #[test] fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() { for gradient in [ diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 79d4d52c11..da2049fe8d 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -582,9 +582,12 @@ impl Rules<'_> { } _ => (unrolled, geom, ""), }; + // A repeating gradient's period is the span from its first stop to its last, repeated + // across the box: its positions get four decimals, so the period stays in place there. + let decimals = if repeating.is_empty() { 2 } else { 4 }; let stops = |pos: &dyn Fn(f32) -> f64| { let stops: Vec = - gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, 2))).collect(); + gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, decimals))).collect(); stops.join(", ") }; match g.gradient.kind { diff --git a/crates/svg/tests/it/css_rules.rs b/crates/svg/tests/it/css_rules.rs index 2ca6dcc30b..8abd6eb48b 100644 --- a/crates/svg/tests/it/css_rules.rs +++ b/crates/svg/tests/it/css_rules.rs @@ -359,6 +359,13 @@ fn spread_gradients_give_repeating_css_gradients() { css(GradientKind::Linear, GradientSpread::Reflect, across).as_deref(), Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 25%, #000000 25%, #ffffff 50%)") ); + // A period that is not a whole hundredth of the box keeps four decimals, so it does not drift + // across the box. + let short = GradientGeom { end: Point::new(10.3452, 30.0), ..across }; + assert_eq!( + css(GradientKind::Linear, GradientSpread::Repeat, short).as_deref(), + Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 2.5863%)") + ); let ring = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None }; assert_eq!( css(GradientKind::Radial, GradientSpread::Reflect, ring).as_deref(), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 46fde93aad..826e7e02f6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -759,6 +759,39 @@ mod gradient_chip_tests { } } + /// A gradient that repeats or reflects as many times as a chip has cells (8 on a 24-point chip) + /// still shows its runs: the grid gets more cells than runs, so its points do not all sample + /// the same place in a run. + #[test] + fn a_chip_repeated_once_a_cell_is_not_one_color() { + for spread in [vectorcraft_color::GradientSpread::Repeat, vectorcraft_color::GradientSpread::Reflect] { + let mut gp = paint(GradientKind::Linear, 0.0); + gp.gradient.spread = spread; + gp.gradient.repeats = 8; + let (cols, rows) = chips::chip_grid(&gp.gradient, 24.0, 24.0); + assert!(cols > 8 && rows > 8, "{spread:?}: {cols} x {rows}"); + let chip = gradient_chip_colors(&gp, 24.0, 24.0, cols, rows); + let row: std::collections::HashSet = chip.iter().take(cols + 1).copied().collect(); + assert!(row.len() > 2, "{spread:?}: {row:?}"); + } + // A gradient that pads keeps a cell about every 3 points. + assert_eq!(chips::chip_grid(&Gradient::default(), 24.0, 24.0), (8, 8)); + // Many runs give a small chip at most a cell a point. + let mut many = paint(GradientKind::Linear, 0.0).gradient; + many.spread = vectorcraft_color::GradientSpread::Repeat; + many.repeats = 100; + assert_eq!(chips::chip_grid(&many, 24.0, 12.0), (24, 12)); + assert_eq!(chips::chip_grid(&many, 400.0, 400.0), (128, 128)); + // A count that matches a chip's points still shows its runs. + let mut gp = paint(GradientKind::Linear, 0.0); + gp.gradient.spread = vectorcraft_color::GradientSpread::Repeat; + gp.gradient.repeats = 23; + let (cols, rows) = chips::chip_grid(&gp.gradient, 23.0, 23.0); + let chip = gradient_chip_colors(&gp, 23.0, 23.0, cols, rows); + let row: std::collections::HashSet = chip.iter().take(cols + 1).copied().collect(); + assert!(row.len() > 2, "{cols}: {row:?}"); + } + #[test] fn radial_chip_runs_from_the_centre_out() { let colors = gradient_chip_colors(&paint(GradientKind::Radial, 0.0), 20.0, 20.0, 4, 4); diff --git a/crates/ui-egui/src/widgets/chips.rs b/crates/ui-egui/src/widgets/chips.rs index 87c6034bda..1b814728a1 100644 --- a/crates/ui-egui/src/widgets/chips.rs +++ b/crates/ui-egui/src/widgets/chips.rs @@ -33,11 +33,7 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient if !(rect.width() > 0.0 && rect.height() > 0.0) { return; } - // Freeform sampling is costly per spot, so it gets a coarse grid (the mesh interpolates). - let freeform = gradient.gradient.kind == vectorcraft_color::GradientKind::Freeform; - let max = if freeform { 16 } else { 64 }; - let cols = ((rect.width() / 3.0).ceil() as usize).clamp(1, max); - let rows = ((rect.height() / 3.0).ceil() as usize).clamp(1, max); + let (cols, rows) = chip_grid(&gradient.gradient, rect.width(), rect.height()); let colors = gradient_chip_colors(gradient, rect.width(), rect.height(), cols, rows); // Stops below full opacity show a checkerboard through them. if colors.iter().any(|c| c.a() < u8::MAX) { @@ -59,6 +55,38 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient ui.painter().add(egui::Shape::mesh(mesh)); } +/// Most grid cells across (and down) a chip of a gradient that repeats or reflects. +const SPREAD_CHIP_CELLS: usize = 128; + +/// The grid (columns, rows) a `width` x `height` chip of gradient `g` is sampled on: a cell about +/// every 3 points (at most 64 a side, 16 for a freeform gradient, whose sampling is costly per +/// spot; the mesh interpolates between them), and for a gradient that repeats or reflects at +/// least 4 cells a run along each side (at most one a point, and [`SPREAD_CHIP_CELLS`]), so the +/// grid's points do not all fall at the same place in a run and paint the chip one color. For +/// such a gradient a side gets a cell or two more when its points would fall at fewer than three +/// places in a run. +pub(super) fn chip_grid(g: &vectorcraft_color::Gradient, width: f32, height: f32) -> (usize, usize) { + let freeform = g.kind == vectorcraft_color::GradientKind::Freeform; + let max = if freeform { 16 } else { 64 }; + let runs = if g.is_spread() { g.repeat_count() as usize } else { 1 }; + let least = if runs > 1 { (4 * runs).min(SPREAD_CHIP_CELLS) } else { 1 }; + let gcd = |mut a: usize, mut b: usize| { + while b != 0 { + (a, b) = (b, a % b); + } + a + }; + let side = |len: f32| { + let mut cells = ((len / 3.0).ceil() as usize).clamp(1, max).max(least.min(len.ceil() as usize)); + // Points `i / cells` of the way along fall at `cells / gcd(cells, runs)` places in a run. + while runs > 1 && cells / gcd(cells, runs) < 3 { + cells += 1; + } + cells + }; + (side(width), side(height)) +} + /// A white and light gray checkerboard filling `rect`, with cells a quarter of its shorter side /// (3 to 8 points). fn checkerboard(ui: &Ui, rect: Rect) { diff --git a/docs/mcp.md b/docs/mcp.md index 8181e111a3..f9df09112d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1204,11 +1204,16 @@ is written with its stops repeated as many times as the shape needs: at most 256 legacy `.ai`), and for a radial or diamond gradient no farther from its center than 65,536 runs, or than a million times the smallest gap between its stops (measured in runs) when that is less. Past those it pads, with a warning. The `.ai` editing data has no spread either: it holds the stops repeated across the shape, at most 256 of them, with -a warning. PDF import finds a sharp step between two colors in an axial or radial shading, in any PDF, by halving -the spans between its samples, so the step reads back where it is in the file (to 1/64 × 2^-14 of the axis, with at -most 16,384 samples a shading) instead of blurred across the samples around it. Object › Expand expands the -repeated stops, into the same whole number of `steps` objects for each run (at least one, so about `steps` in all), -each run starting on an object's edge: +a warning. When the runs need more stops than these limits allow, every run is still written, without the stops that +change its colors least (its ends, sharp steps and swatch-linked stops stay); the same applies to a gradient along +or across a stroke, and to CSS's conic gradient, past 16,384 stops, and to Outline Stroke's mesh past 1,024. PDF +import finds a sharp step between two colors in an axial or radial shading by halving the spans between its samples, +so the step reads back where it is in the file (to 1/64 × 2^-14 of the axis) instead of blurred across the samples +around it. A shading takes at most 16,384 samples, so the steps past the point where that budget runs out stay +blurred, and a color band narrower than 1/64 of the axis can be missed when no sample lands in it. Object › Expand +expands the repeated stops, into the same whole number of `steps` objects for each run (at least one, so about +`steps` in all), each run starting on an object's edge; a gradient that needs more than 256 runs across the box stays +a gradient: ```json {"name":"run_command","arguments":{"command":"paint.editGradient","params":{"spread":"reflect","repeats":3}}} From eb80d45ec5ad9817be0f6105ad85bfb057ddffd9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 08:31:45 -0800 Subject: [PATCH 16/23] 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. From 1f458a3ab01465e0558c444fb6fcda5334ca3b36 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 11:45:53 -0800 Subject: [PATCH 17/23] Transform: setBounds measures miter spikes of dashed strokes at a slant as drawn A miter join on a stroke whose dashes have round or projecting caps still paints the miter spikes the limit keeps, and the visual box includes them. object.setBounds with Use Preview Bounds did not count such a stroke as one whose margins follow its segments' directions, so a closed path at a slant missed the size typed when the width and height were out of proportion. --- crates/doc/src/reach.rs | 24 +++++++++++++++++------- crates/engine/src/tests/previewbounds.rs | 15 +++++++++++---- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 7f9b6a1766..4648f3087e 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -217,6 +217,14 @@ impl StrokeLayer { (self.cap == LineCap::Butt && !closed) || (self.join != LineJoin::Round && !capped_dashes) } + /// Does this stroke reach past a subpath that is `closed` or open by a margin that follows the + /// direction of its segments at their corners: where it stops at them + /// ([`Self::stops_at_corners`]) or paints miter spikes, which dashes with round or projecting + /// caps also paint? + fn reaches_by_corners(&self, closed: bool) -> bool { + self.stops_at_corners(closed) || self.join == LineJoin::Miter + } + /// The box this stroke's body paints along `segs`, the segments with length of a subpath, short /// of its caps, joined with `within`: each segment's [`swept_box`] and, with round joins on an /// open subpath, the arc each joint between segments paints ([`round_join_box`]), a shallow one @@ -506,16 +514,18 @@ impl Appearance { } /// Does this appearance have a visible stroke that stops at the corners of its segments on an - /// open subpath or a closed one: butt caps, or bevel and miter joins? + /// open subpath or a closed one, or paints miter spikes: butt caps, or bevel and miter joins + /// ([`StrokeLayer::reaches_by_corners`])? pub fn has_cornered_stroke(&self) -> bool { - self.painted_strokes().any(|s| s.stops_at_corners(false)) + self.painted_strokes().any(|s| s.reaches_by_corners(false)) } /// Does a visible stroke of this appearance that stops at the corners of its segments (butt - /// caps on an open subpath, bevel and miter joins) run along a subpath of `bp` where it does so, - /// with a segment that starts or ends at a slant, not square to the axes, once placed by `a`? - /// The visual box's margin at those corners, and at miter tips, depends on their direction, - /// which a scale out of proportion changes. + /// caps on an open subpath, bevel and miter joins) or paints miter spikes, dashed ones with + /// round or projecting caps included, run along a subpath of `bp` where it does so, with a + /// segment that starts or ends at a slant, not square to the axes, once placed by `a`? The + /// visual box's margin at those corners, and at miter tips, depends on their direction, which a + /// scale out of proportion changes. pub fn has_slanted_stroke_corners(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool { if !self.has_cornered_stroke() { return false; @@ -526,7 +536,7 @@ impl Appearance { }; let els = bp.elements(); let subs = subpaths(bp); - let cornered = |closed: bool| self.painted_strokes().any(|s| s.stops_at_corners(closed)); + let cornered = |closed: bool| self.painted_strokes().any(|s| s.reaches_by_corners(closed)); subs.into_iter() .filter(|(_, closed)| cornered(*closed)) .any(|(r, _)| drawn(&els[r]).iter().any(|s| slanted(tangent(s, 0.0)) || slanted(tangent(s, 1.0)))) diff --git a/crates/engine/src/tests/previewbounds.rs b/crates/engine/src/tests/previewbounds.rs index c0ce01cb57..e395c708bc 100644 --- a/crates/engine/src/tests/previewbounds.rs +++ b/crates/engine/src/tests/previewbounds.rs @@ -672,19 +672,26 @@ fn set_bounds_gives_a_butt_path_with_slanted_corners_the_size_out_of_proportion( fn set_bounds_gives_a_closed_path_with_beveled_or_mitered_corners_the_size_out_of_proportion() { // The bevel at the triangle's tip, the miter the limit bevels there and the miter spikes it // keeps reach past the path by margins that follow the slanted segments' directions, which a - // scale out of proportion changes. + // scale out of proportion changes. The miter spikes of a stroke whose dashes have round or + // projecting caps reach past the path too. let mut wrong = vec![]; - for (join, limit) in [("bevel", 10.0), ("miter", 2.0), ("miter", 10.0)] { + for (join, limit, cap, dash) in [ + ("bevel", 10.0, "round", Value::Null), + ("miter", 2.0, "round", Value::Null), + ("miter", 10.0, "round", Value::Null), + ("miter", 10.0, "round", json!([6, 3])), + ("miter", 10.0, "projecting", json!([6, 3])), + ] { let mut s = session(); let anchors = json!([{"x": 100, "y": 150}, {"x": 110, "y": 100}, {"x": 120, "y": 150}]); let id = NodeId(run(&mut s, "path.create", json!({"anchors": anchors, "closed": true}))["id"].as_u64().unwrap()); - run(&mut s, "stroke.set", json!({"weight": 2, "cap": "round", "join": join, "miterLimit": limit})); + run(&mut s, "stroke.set", json!({"weight": 2, "cap": cap, "join": join, "miterLimit": limit, "dash": dash})); preview(&mut s, true); run(&mut s, "select.set", json!({"ids": [id.0]})); run(&mut s, "object.setBounds", json!({"width": 60, "height": 20, "reference": 0, "strokes": false})); let v = s.transform_bounds(&[id]).unwrap(); if (v.width() - 60.0).abs() > 1e-6 || (v.height() - 20.0).abs() > 1e-6 { - wrong.push(format!("{join} {limit}: {v:?}")); + wrong.push(format!("{join} {limit} {cap} {dash}: {v:?}")); } } assert!(wrong.is_empty(), "{wrong:?}"); From 05cf130fde595b8e2f36480e518f6ea2937cc3ea Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 11:50:59 -0800 Subject: [PATCH 18/23] Document Info: Brushes lists the brushes applied to art, with their types Document Info's Brushes category now lists the brushes the artwork's strokes draw with, each once with its type (Calligraphic, Scatter, Art, Pattern or Bristle), as the Document Info panel help page describes: https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html Without Selection Only the category used to list every brush in the Brushes panel, used or not; it now lists only the brushes art uses. document.info adds `brushes` ([{name, type}]) and `panelBrushNames` (the Brushes panel's brushes), and `brushNames` holds the used brushes. Hidden strokes, strokes with no paint or width, guides, paths with no segment, type that draws no characters, groups' and layers' own strokes, compound path members' own strokes and image strokes without a shape are left out. A brush the panel does not hold is listed as missing, with a null type, and a name two panel brushes share takes the type of the brush the renderer draws with. --- crates/engine/src/cmd/docinfo.rs | 204 ++++++++++++++++++++++++++----- docs/mcp.md | 8 +- docs/target-app-parity.md | 2 +- 3 files changed, 180 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 4ed63e7ec1..d617fad088 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", has_doc, info ), @@ -131,14 +131,16 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S } /// The Document Info categories of `d` (only `category` when given) from `info` (what -/// `document.info` reports), its fonts, its image objects, the rows of its Repeat objects and the -/// rows of its objects filled or stroked with a spot color, with a pattern and with a gradient. +/// `document.info` reports), its fonts, its image objects, the rows of the brushes its art uses, the +/// rows of its Repeat objects and the rows of its objects filled or stroked with a spot color, with a +/// pattern and with a gradient. #[allow(clippy::too_many_arguments)] fn sections( d: &Document, info: &Value, fonts: &BTreeSet, images: &[ImageRow], + brushes: &[(String, String)], repeats: &[(String, String)], spot_objects: &[(String, String)], pattern_objects: &[(String, String)], @@ -184,7 +186,7 @@ fn sections( rows } "graphicStyles" => names("graphicStyleNames"), - "brushes" => names("brushNames"), + "brushes" => brushes.to_vec(), "spotColors" => spot_objects.to_vec(), "patterns" => pattern_objects.to_vec(), "gradients" => gradient_objects.to_vec(), @@ -326,12 +328,18 @@ fn painted_runs<'a>(d: &Document, spots: &HashSet<&str>, n: &'a Node) -> Option< if !own && !t.runs.iter().any(|r| is_listed(&r.style.fill) || is_listed(&r.style.stroke)) { return None; } + Some((t, drawn_runs(d, t))) +} + +/// The runs of type `t` that its layout draws: those with a glyph outline (list markers and ruby +/// included) or an underline or strikethrough bar, laid out as the renderer lays it out. +fn drawn_runs(d: &Document, t: &vectorcraft_doc::TextObject) -> BTreeSet { let db = vectorcraft_text::FontDb::global(); let resolved = d.inline_resolved(t); let lay = vectorcraft_text::layout(db, &resolved); let mut painted: BTreeSet = lay.glyphs.iter().chain(&lay.ruby).filter(|g| !g.outline.elements().is_empty()).map(|g| g.run).collect(); painted.extend(vectorcraft_text::decorations(&lay, db, &resolved).into_iter().map(|(run, _)| run)); - Some((t, painted)) + painted } /// The names a paint is listed by in a Document Info category: its spot colors, its pattern or its @@ -381,6 +389,41 @@ fn one_label(label: fn(&Paint) -> Option) -> impl Fn(&Paint) -> Vec