From f259c2372a6e7d78bed2fef5475e97457d018974 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 13:38:28 -0800 Subject: [PATCH 01/21] 3D: expanded solids keep the art's curves in caps and the walls' cap edges Extrude & Bevel (both kinds) and Rotate wrote every face as straight segments between sample points, so Expand Appearance and the exports gave a corner at every sample. Sampling now records the curve each polygon edge follows, and a facet carries an outline of cubics rebuilt from it: exact without perspective, within 0.001 pt with it, and within the sampling tolerance for bevel insets. Caps, Rotate's face and the wall and bevel edges that meet a cap keep curves; interior bevel edges, edges a twist cuts and edges past 1,048,576 curves stay straight. Inflate's faces stay polygons. Docs: docs/three-d.md, docs/target-app-parity.md. --- crates/three-d/src/solid.rs | 8 ++ crates/three-d/src/solid/build.rs | 155 ++++++++++++++++++++-- crates/three-d/src/solid/outline.rs | 194 ++++++++++++++++++++++++++++ crates/three-d/src/solid/rings.rs | 142 +++++++++++++++----- crates/three-d/src/solid/tests.rs | 157 +++++++++++++++++++++- docs/target-app-parity.md | 1 + docs/three-d.md | 9 ++ 7 files changed, 621 insertions(+), 45 deletions(-) create mode 100644 crates/three-d/src/solid/outline.rs diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 339b148a5..45d7d310b 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -16,6 +16,7 @@ use crate::{MAX_FACES, MAX_SOLID_FACES, Revolve, V3, eye_distance, perspective_s mod build; mod inflate; mod membrane; +mod outline; mod rings; #[cfg(test)] @@ -253,6 +254,9 @@ pub struct Facet { pub normal: [f64; 3], /// Smooth lighting on an inflated triangle, before clipping individual RGB channels. pub rgb_gradient: Option, + /// The facet's outline with the profile's curves kept as cubics, for [`Facet::path`]. The + /// contours stay the polygon that visibility and depth use. + pub outline: Option, /// The contours in view space, before projection, for tests that measure depths. #[cfg(test)] view: Vec>, @@ -268,7 +272,11 @@ impl Facet { if perimeter > 0.0 { 2.0 * area / perimeter } else { 0.0 } } + /// The facet's outline: its curved outline when it has one, else its contours. pub fn path(&self) -> PathData { + if let Some(path) = &self.outline { + return path.clone(); + } let mut bp = BezPath::new(); for c in &self.contours { let Some(first) = c.first() else { continue }; diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index d03773ccd..66a22f80f 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -1,7 +1,14 @@ +use vectorcraft_geom::kurbo::ParamCurveDeriv; + +use super::outline::{self, Along, Budget, CURVE_TOLERANCE}; use super::rings::{ - CURVE_PIECES, MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, inset, left, signed_area, solid_rings, + CURVE_PIECES, MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, Ring, Span, TOO_COMPLEX_SURFACE, curve_pieces, inset, left, signed_area, + solid_rings_traced, +}; +use super::{ + Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, Point, Rect, Revolve, V3, View, eye_distance, + perspective_scale, }; -use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale}; /// Turns 3D points into document points and shades faces for one view. pub(super) struct Camera { @@ -134,6 +141,7 @@ impl Builder<'_> { illumination: crate::illumination(&self.camera.view, n, self.camera.light), normal: [n.0, n.1, n.2], rgb_gradient: None, + outline: None, #[cfg(test)] view, }); @@ -171,6 +179,19 @@ impl Builder<'_> { /// wall with `cuts` (see [`cuts`]) as `cuts[i]` quads along edge `i`. `outward` is the /// direction (before rotation) the band faces away from the solid, for orienting normals. pub(super) fn band(&mut self, a: &[V3], b: &[V3], cuts: &[usize], outward: impl Fn(usize) -> V3) -> Result<(), String> { + self.curved_band(a, b, cuts, outward, None) + } + + /// [`Builder::band`], with `curves` the pieces of curve the rings' edges follow and the + /// outlines at `a` and `b`: a quad whose edge is not cut keeps its edges along the profile curved. + pub(super) fn curved_band( + &mut self, + a: &[V3], + b: &[V3], + cuts: &[usize], + outward: impl Fn(usize) -> V3, + mut curves: Option, + ) -> Result<(), String> { let n = a.len().min(b.len()); // At 0 and 1, the corners themselves. let lerp = |p: V3, q: V3, s: f64| { @@ -198,12 +219,29 @@ impl Builder<'_> { if normal.dot(self.camera.rotate(outward(i))) < 0.0 { normal = V3(-normal.0, -normal.1, -normal.2); } + let before = self.facets.len(); self.face(vec![quad], normal, order)?; + if let Some(w) = curves.as_mut().filter(|_| pieces == 1) + && let Some(span) = w.spans.get(i).copied().flatten() + { + self.outline(before, |c| c.first().and_then(|quad| outline::wall(&span, quad, w.near, w.far, w.budget))); + } } } Ok(()) } + /// Give the facet that [`Builder::face`] added, when it added one after `before` facets, the + /// outline `outline` makes from its projected contours. + pub(super) fn outline(&mut self, before: usize, outline: impl FnOnce(&[Vec]) -> Option) { + if self.mesh.is_some() || self.facets.len() <= before { + return; + } + if let Some(f) = self.facets.last_mut() { + f.outline = outline(&f.contours); + } + } + /// How many faces the builder holds: facets, or faces kept for a mesh. pub(super) fn len(&self) -> usize { self.mesh.as_ref().map_or(self.facets.len(), Vec::len) @@ -216,6 +254,62 @@ impl Builder<'_> { } } +/// The curves a band's quads keep: the piece of curve each edge of its rings follows, and the +/// outlines at its two rings (`None` keeps that ring's edges straight). +pub(super) struct WallCurves<'a> { + pub(super) spans: &'a [Option], + pub(super) near: Option<&'a Along<'a>>, + pub(super) far: Option<&'a Along<'a>>, + pub(super) budget: &'a mut Budget, +} + +/// Where a point of the art goes in view space at one step: [`Camera::place`] after a [`Turn`], +/// at depth `z`, as one affine map worked out once for the step. +pub(super) struct Placing { + centre: Point, + at_centre: V3, + x: V3, + y: V3, +} + +impl Placing { + pub(super) fn new(camera: &Camera, centre: Point, turn: Turn, z: f64) -> Self { + let at = |p: Point| camera.place(turn.apply(p), z); + let at_centre = at(centre); + let x = at(Point::new(centre.x + 1.0, centre.y)).sub(at_centre); + let y = at(Point::new(centre.x, centre.y + 1.0)).sub(at_centre); + Self { centre, at_centre, x, y } + } + pub(super) fn place(&self, p: Point) -> V3 { + let (dx, dy) = (p.x - self.centre.x, p.y - self.centre.y); + V3( + self.at_centre.0 + self.x.0 * dx + self.y.0 * dy, + self.at_centre.1 + self.x.1 * dx + self.y.1 * dy, + self.at_centre.2 + self.x.2 * dx + self.y.2 * dy, + ) + } +} + +/// The point `w` points to the left of `span`'s curve at `t`, as the ring runs along it (toward +/// the filled side): where [`inset`] moves the ring's points, without its miters. +fn offset(span: &Span, t: f64, w: f64) -> Point { + let p = span.curve.eval(t); + if w == 0.0 { + return p; + } + let mut d = span.curve.deriv().eval(t).to_vec2(); + // A handle of zero length leaves no tangent at its end: the curve leaves toward its next point. + if d.hypot() < 1e-12 { + d = span.curve.p2 - span.curve.p0; + if t > 0.5 { + d = span.curve.p3 - span.curve.p1; + } + } + let d = if span.t1 < span.t0 { -d } else { d }; + let l = d.hypot(); + if l < 1e-12 { p } else { Point::new(p.x - d.y / l * w, p.y + d.x / l * w) } +} + /// How many pieces each edge of a twisted wall is drawn in, from two neighboring slices of the /// wall, `a` and `b` (partner rings): as many as make each piece about as long along the edge as /// the band between the slices is wide there, 1 to [`MAX_PIECES`]. Twisted walls pass in front of @@ -416,8 +510,9 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio let (bands, caps) = (steps.len().saturating_sub(1), if o.cap { 2 } else { 0 }); let size = bounds.width().max(bounds.height()); let most = curve_pieces(path, size, bands, caps)?; - let rings = solid_rings(path, rule, size, MAX_SOLID_POINTS, most)?; - let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + let traced = solid_rings_traced(path, rule, size, MAX_SOLID_POINTS, most)?; + let rings: Vec<&[Point]> = traced.iter().map(|r| r.points.as_slice()).collect(); + let edges = rings.iter().map(|r| r.len()).fold(0usize, usize::saturating_add); if edges.saturating_mul(bands).saturating_add(caps) > MAX_SOLID_FACES { return Err(TOO_COMPLEX_SURFACE.into()); } @@ -441,7 +536,33 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio }) .collect(); fit(&mut pieces, steps.len().saturating_sub(1), MAX_FACES); - for (ring, pieces) in rings.iter().zip(&pieces) { + // The outline at each step, through the profile's curves: exact where the step is not inset, + // and within the sampling tolerance (which the inset rings themselves keep to) where it is. + let sampled = (size / 2000.0).max(0.05); + let maps: Vec = steps.iter().map(|s| Placing::new(&camera, bounds.center(), Turn::new(bounds, o.twist, o.taper, s.t), z(s.t))).collect(); + let at: Vec<_> = steps + .iter() + .zip(&maps) + .map(|(s, m)| { + let (w, camera) = (s.inset, &camera); + move |span: &Span, t: f64| camera.project(m.place(offset(span, t, w))) + }) + .collect(); + // A step that is not inset moves the art point by point, affinely without perspective. + let point_maps: Vec<_> = maps.iter().map(|m| |p: Point| camera.project(m.place(p))).collect(); + let along: Vec = steps + .iter() + .zip(&at) + .zip(&point_maps) + .map(|((s, at), map)| Along { + at, + map: (s.inset == 0.0).then_some(map as &dyn Fn(Point) -> Point), + affine: camera.eye.is_none(), + tolerance: if s.inset == 0.0 { CURVE_TOLERANCE } else { sampled }, + }) + .collect(); + let mut budget = Budget::default(); + for ((ring, traced), pieces) in rings.iter().zip(&traced).zip(&pieces) { let placed: Vec> = steps.iter().map(|s| ring_at(ring, s)).collect(); for (k, pair) in placed.windows(2).enumerate() { let ([a, c], Some(s0), Some(s1)) = (pair, steps.get(k), steps.get(k + 1)) else { continue }; @@ -454,15 +575,25 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio } else { 0.0 }; - b.band(a, c, pieces, |i| outward(ring, i, face))?; + // The edges a band shares with a cap follow the cap's curves; the edges between two bands + // stay straight, within the sampling tolerance. + let (near, far) = (along.get(k).filter(|_| k == 0), along.get(k + 1).filter(|_| k + 2 == steps.len())); + let curves = (near.is_some() || far.is_some()).then(|| WallCurves { spans: &traced.spans, near, far, budget: &mut budget }); + b.curved_band(a, c, pieces, |i| outward(ring, i, face), curves)?; } } if o.cap { // A cap turned toward the viewer covers the walls it overlaps, even those nearer than its // center: twisted walls, and the walls of a fill inside the solid of its stroke. - for (s, n) in [(steps.first(), 1.0), (steps.last(), -1.0)] { + let caps = [(steps.first(), along.first(), 1.0), (steps.last(), along.last(), -1.0)]; + for (s, along, n) in caps { let Some(s) = s else { continue }; + let before = b.len(); b.face(rings.iter().map(|r| ring_at(r, s)).collect(), camera.rotate(V3(0.0, 0.0, n)), Order::Last)?; + if let Some(along) = along { + let traced: Vec<&Ring> = traced.iter().collect(); + b.outline(before, |c| outline::face(&traced, c, along, &mut budget)); + } } } Ok(b.finish()) @@ -477,15 +608,21 @@ pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Resul /// [`plane`], or with `mesh` its face before projection instead of its facet. pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result { check_bounds(bounds)?; - let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS, CURVE_PIECES)?; + let rings = solid_rings_traced(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS, CURVE_PIECES)?; let camera = Camera::new(view, bounds, 0.0); let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; - let contours: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); + let contours: Vec> = rings.iter().map(|r| r.points.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); // A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so // it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in. let up = camera.rotate(V3(0.0, 0.0, 1.0)); let n = if camera.facing(V3::default(), up) < 0.0 { V3(-up.0, -up.1, -up.2) } else { up }; b.face(contours, n, Order::Last)?; + let m = Placing::new(&camera, bounds.center(), Turn::new(bounds, 0.0, 1.0, 0.0), 0.0); + let at = |span: &Span, t: f64| camera.project(m.place(span.curve.eval(t))); + let map = |p: Point| camera.project(m.place(p)); + let along = Along { at: &at, map: Some(&map), affine: camera.eye.is_none(), tolerance: CURVE_TOLERANCE }; + let rings: Vec<&Ring> = rings.iter().collect(); + b.outline(0, |c| outline::face(&rings, c, &along, &mut Budget::default())); Ok(b.finish()) } diff --git a/crates/three-d/src/solid/outline.rs b/crates/three-d/src/solid/outline.rs new file mode 100644 index 000000000..1d22e6450 --- /dev/null +++ b/crates/three-d/src/solid/outline.rs @@ -0,0 +1,194 @@ +//! Curved outlines for the facets of a solid: a cap, the flat plane of Rotate and the edges of a +//! wall along the profile follow the profile's curves as cubics, while each facet's polygon stays +//! the shape that visibility and depth are worked out on. + +use vectorcraft_geom::{BezPath, CubicBez, ParamCurve, PathData, Point}; + +use super::rings::{Ring, Span}; + +/// How far a fitted cubic may lie from the outline it follows, in points. +pub(super) const CURVE_TOLERANCE: f64 = 0.001; + +/// How many times one piece of curve is halved at most to fit it. A piece that still misses the +/// tolerance keeps its sampled straight edges. +const MAX_DEPTH: u32 = 8; + +/// Fitted cubics one solid's outlines hold at most. Past it, the remaining edges stay straight. +pub(super) const MAX_OUTLINE_CURVES: usize = 1 << 20; + +/// The fitted cubics of one solid: how many it holds so far, against [`MAX_OUTLINE_CURVES`]. +#[derive(Default)] +pub(super) struct Budget(usize); + +/// The outline of a solid at one step, as a point of the document for a point of a profile's +/// curve: `at(span, t)` is where the outline lies at `t` on `span`'s curve. `tolerance` is how far +/// a fitted cubic may lie from it. With `map`, the outline is the curve moved point by point by +/// that map, and a piece of curve is tried first as its control points moved by it: exact when +/// `affine`, otherwise kept when it lies within the tolerance. +pub(super) struct Along<'a> { + pub(super) at: &'a dyn Fn(&Span, f64) -> Point, + pub(super) map: Option<&'a dyn Fn(Point) -> Point>, + pub(super) affine: bool, + pub(super) tolerance: f64, +} + +/// `g` from `p` at `a` to `q` at `b` as cubics within `tolerance`, appended to `out`. False when it +/// does not fit within [`MAX_DEPTH`] halvings, or reaches a point that is not finite. +#[allow(clippy::too_many_arguments)] +fn fit(g: &dyn Fn(f64) -> Point, p: Point, q: Point, a: f64, b: f64, tolerance: f64, depth: u32, out: &mut Vec) -> bool { + // Second-order one-sided differences, taken inside the piece: a handle of zero length at an end + // of the source curve turns its normal, and so an inset outline's tangent, at that end. + let h = (b - a) * 1e-5; + if h == 0.0 || !h.is_finite() { + return false; + } + let (g0, g1, g2) = (g(a), g(a + h), g(a + 2.0 * h)); + let start = (g1 - g0) * 2.0 - (g2 - g0) * 0.5; + let (g0, g1, g2) = (g(b), g(b - h), g(b - 2.0 * h)); + let end = (g0 - g1) * 2.0 - (g0 - g2) * 0.5; + let (start, end) = (start / h, end / h); + let curve = CubicBez::new(p, p + start * ((b - a) / 3.0), q - end * ((b - a) / 3.0), q); + let points = [curve.p1, curve.p2]; + if points.iter().any(|c| !c.x.is_finite() || !c.y.is_finite()) { + return false; + } + let mut error = 0.0f64; + for u in [0.25, 0.5, 0.75] { + let want = g(a + (b - a) * u); + if !want.x.is_finite() || !want.y.is_finite() { + return false; + } + error = error.max(curve.eval(u).distance(want)); + } + if error <= tolerance { + out.push(curve); + return true; + } + if depth >= MAX_DEPTH { + return false; + } + let mid = (a + b) / 2.0; + let m = g(mid); + if !m.x.is_finite() || !m.y.is_finite() { + return false; + } + fit(g, p, m, a, mid, tolerance, depth + 1, out) && fit(g, m, q, mid, b, tolerance, depth + 1, out) +} + +/// The cubics from `p` to `q` along the piece of `span`'s curve from `t0` to `t1` at `step`, or +/// `None` when they do not fit, or would take the budget past [`MAX_OUTLINE_CURVES`]. +/// +/// `p` and `q` are the face's own corners, which a bevel's miter can move off the curve's offset +/// where the curve meets another edge at an angle. The difference is eased out from `p` by `fade.0` +/// and into `q` from `fade.1` (the ends of the first and last sampled edge), as the face's polygon +/// does over those edges, so the curves meet the face's corners. +#[allow(clippy::too_many_arguments)] +fn piece(step: &Along, span: &Span, t0: f64, t1: f64, fade: (f64, f64), p: Point, q: Point, budget: &mut Budget) -> Option> { + if let Some(map) = step.map { + let c = span.curve.subsegment(t0..t1); + let c = CubicBez::new(p, map(c.p1), map(c.p2), q); + let finite = [c.p1, c.p2].iter().all(|q| q.x.is_finite() && q.y.is_finite()); + let fits = || [0.25, 0.5, 0.75].into_iter().all(|u| c.eval(u).distance((step.at)(span, t0 + (t1 - t0) * u)) <= step.tolerance); + if finite && (step.affine || fits()) { + budget.0 = budget.0.saturating_add(1); + return (budget.0 <= MAX_OUTLINE_CURVES).then(|| vec![c]); + } + } + let (dp, dq) = (p - (step.at)(span, t0), q - (step.at)(span, t1)); + // 1 at the end, 0 from `to` on, with no slope at either. + let ease = |t: f64, from: f64, to: f64| { + let x = if to == from { 1.0 } else { ((t - from) / (to - from)).clamp(0.0, 1.0) }; + 1.0 - x * x * (3.0 - 2.0 * x) + }; + let g = |t: f64| (step.at)(span, t) + dp * ease(t, t0, fade.0) + dq * ease(t, t1, fade.1); + let mut out = vec![]; + if !fit(&g, p, q, t0, t1, step.tolerance, 0, &mut out) { + return None; + } + budget.0 = budget.0.saturating_add(out.len()); + (budget.0 <= MAX_OUTLINE_CURVES).then_some(out) +} + +/// Append contour `points` (closed), whose edges follow `ring`'s, to `path`: each run of edges along +/// one piece of curve as cubics, and the rest straight. False when no edge is curved. +fn contour(path: &mut BezPath, ring: &Ring, points: &[Point], step: &Along, budget: &mut Budget) -> bool { + let n = points.len(); + let Some(&first) = points.first() else { return false }; + if ring.spans.len() != n { + return false; + } + path.move_to(first); + let mut curved = false; + let mut i = 0; + while i < n { + let end = |k: usize| points.get((k + 1) % n).copied().unwrap_or(first); + let Some(span) = ring.spans.get(i).copied().flatten() else { + if i + 1 < n { + path.line_to(end(i)); + } + i += 1; + continue; + }; + // The run of edges that follow this curve one after the other. + let (mut j, mut t1, mut last) = (i, span.t1, span.t0); + while let Some(Some(next)) = ring.spans.get(j + 1) + && next.curve == span.curve + && next.t0 == t1 + { + (j, t1, last) = (j + 1, next.t1, next.t0); + } + let (p, q) = (points.get(i).copied().unwrap_or(first), end(j)); + match piece(step, &span, span.t0, t1, (span.t1, last), p, q, budget) { + Some(curves) => { + curved = true; + for c in curves { + path.curve_to(c.p1, c.p2, c.p3); + } + } + // The edge back to the first point is drawn by `close_path`. + None => (i..=j).filter(|k| k + 1 < n).for_each(|k| path.line_to(end(k))), + } + i = j + 1; + } + path.close_path(); + curved +} + +/// The outline of a face made of `rings` at one `step` (a cap, or Rotate's plane), projected as +/// `points` (the face's contours, one for each ring and as long). `None` when no edge is curved. +pub(super) fn face(rings: &[&Ring], points: &[Vec], step: &Along, budget: &mut Budget) -> Option { + if rings.len() != points.len() || rings.iter().all(|r| r.spans.iter().all(Option::is_none)) { + return None; + } + let mut path = BezPath::new(); + let mut curved = false; + for (ring, points) in rings.iter().zip(points) { + curved |= contour(&mut path, ring, points, step, budget); + } + curved.then(|| PathData::from_bezpath(&path)) +} + +/// The outline of a wall's quad `quad` (`a`, `a'`, `b'`, `b` projected: the edge at `near` from +/// `a` to `a'`, then the edge at `far` back from `b'` to `b`), whose edges along the profile follow +/// `span`. An edge without its outline stays straight. `None` when neither edge is curved. +pub(super) fn wall(span: &Span, quad: &[Point], near: Option<&Along>, far: Option<&Along>, budget: &mut Budget) -> Option { + let [a, a2, b2, b] = <[Point; 4]>::try_from(quad).ok()?; + let front = near.and_then(|near| piece(near, span, span.t0, span.t1, (span.t1, span.t0), a, a2, budget)); + let back = far.and_then(|far| piece(far, span, span.t1, span.t0, (span.t0, span.t1), b2, b, budget)); + if front.is_none() && back.is_none() { + return None; + } + let mut path = BezPath::new(); + path.move_to(a); + match front { + Some(curves) => curves.iter().for_each(|c| path.curve_to(c.p1, c.p2, c.p3)), + None => path.line_to(a2), + } + path.line_to(b2); + match back { + Some(curves) => curves.iter().for_each(|c| path.curve_to(c.p1, c.p2, c.p3)), + None => path.line_to(b), + } + path.close_path(); + Some(PathData::from_bezpath(&path)) +} diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index 02fb6f406..7aed5e419 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -101,15 +101,7 @@ pub(super) const DEBRIS: f64 = 1e-6; /// under even-odd), or that are all thinner than [`DEBRIS`], fill nothing, so nothing is built. /// Each curve takes at most `most` pieces, on the profile as drawn and on the merged outline. pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize, most: u32) -> Result>, String> { - let sampled = sample(path, size, limit, most)?; - let closed = closed_straight(path); - if !meets(&sampled, &closed) { - return orient(sampled, rule); - } - match vectorcraft_pathops::try_unite_all_with(&[(&closed, rule)], DEBRIS) { - Ok(merged) => sample(&merged, size, limit, most).map(wound), - Err(_) => orient(sampled, rule), - } + Ok(solid_rings_traced(path, rule, size, limit, most)?.into_iter().map(|r| r.points).collect()) } /// Do the `sampled` rings of `closed` (the profile as the boolean takes it) meet, so that it must @@ -155,10 +147,10 @@ pub(super) fn closed_straight(path: &PathData) -> std::borrow::Cow<'_, PathData> /// The boolean's rings turned so the filled side is on each one's left. Its holes already run /// against their outlines, so only the direction of the whole is chosen: the one whose rings /// enclose positive area in all. -pub(super) fn wound(mut rings: Vec>) -> Vec> { - rings.retain(|r| signed_area(r).abs() > 1e-9); - if rings.iter().map(|r| signed_area(r)).sum::() < 0.0 { - rings.iter_mut().for_each(|r| r.reverse()); +pub(super) fn wound(mut rings: Vec) -> Vec { + rings.retain(|r| signed_area(r.points()).abs() > 1e-9); + if rings.iter().map(|r| signed_area(r.points())).sum::() < 0.0 { + rings.iter_mut().for_each(Turnable::turn_around); } rings } @@ -251,6 +243,71 @@ fn segments_meet(a: Point, b: Point, c: Point, d: Point, touch: f64) -> bool { dist(a, c, d).min(dist(b, c, d)).min(dist(c, a, b)).min(dist(d, a, b)) <= touch } +/// Where one edge of a sampled ring lies on the curve it follows: the piece of `curve` from `t0` +/// to `t1` (`t1` below `t0` on a ring that runs against its curve). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Span { + pub(super) curve: vectorcraft_geom::CubicBez, + pub(super) t0: f64, + pub(super) t1: f64, +} + +/// A sampled ring with, for each edge (point `i` to point `i + 1`, the last back to the first), +/// the piece of curve it follows, or `None` for a straight edge. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Ring { + pub(super) points: Vec, + pub(super) spans: Vec>, +} + +/// A ring that [`orient`] and [`wound`] can turn around. +pub(super) trait Turnable { + fn points(&self) -> &[Point]; + fn turn_around(&mut self); +} + +impl Turnable for Vec { + fn points(&self) -> &[Point] { + self + } + fn turn_around(&mut self) { + self.reverse(); + } +} + +impl Turnable for Ring { + fn points(&self) -> &[Point] { + &self.points + } + fn turn_around(&mut self) { + // The ring keeps its first point, an anchor of the art, so that a run of edges along one + // curve doesn't start part way along it. Edge `j` of the turned ring is edge `n - 1 - j` + // of the ring, run the other way. + self.points.reverse(); + if !self.points.is_empty() { + self.points.rotate_right(1); + } + self.spans.reverse(); + for s in self.spans.iter_mut().flatten() { + std::mem::swap(&mut s.t0, &mut s.t1); + } + } +} + +/// [`solid_rings`] with the piece of curve each edge follows. +pub(super) fn solid_rings_traced(path: &PathData, rule: FillRule, size: f64, limit: usize, most: u32) -> Result, String> { + let sampled = sample_traced(path, size, limit, most)?; + let closed = closed_straight(path); + let points: Vec> = sampled.iter().map(|r| r.points.clone()).collect(); + if !meets(&points, &closed) { + return orient(sampled, rule); + } + match vectorcraft_pathops::try_unite_all_with(&[(&closed, rule)], DEBRIS) { + Ok(merged) => sample_traced(&merged, size, limit, most).map(wound), + Err(_) => orient(sampled, rule), + } +} + /// The rings of a shape: closed polygons with the filled side on their left, (-dy, dx): outer /// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves /// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take @@ -266,6 +323,11 @@ pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> /// The closed polygons that follow `path`'s subpaths, as [`rings`] samples them, each running as /// its subpath does, each curve in at most `most` pieces. pub(super) fn sample(path: &PathData, size: f64, limit: usize, most: u32) -> Result>, String> { + Ok(sample_traced(path, size, limit, most)?.into_iter().map(|r| r.points).collect()) +} + +/// [`sample`] with the piece of curve each edge follows. +fn sample_traced(path: &PathData, size: f64, limit: usize, most: u32) -> Result, String> { let tolerance = (size / 2000.0).max(0.05); let mut count = 0; let mut out = vec![]; @@ -275,34 +337,44 @@ pub(super) fn sample(path: &PathData, size: f64, limit: usize, most: u32) -> Res if segments > limit { return Err(TOO_COMPLEX_SHAPE.into()); } - let mut ring: Vec = vec![]; + // Each point with the piece of curve of the edge that ends at it. + let mut ring: Vec<(Point, Option)> = vec![]; if let Some(first) = sp.anchors.first() { - ring.push(first.p); + ring.push((first.p, None)); } for i in 0..segments { let curve = sp.segment(i); - let samples = if sp.segment_is_line(i) { 1 } else { pieces(curve, tolerance).min(most.max(1)) }; + let line = sp.segment_is_line(i); + let samples = if line { 1 } else { pieces(curve, tolerance).min(most.max(1)) }; count += samples as usize; if count > limit { return Err(TOO_COMPLEX_SHAPE.into()); } for k in 1..=samples { - ring.push(curve.eval(f64::from(k) / f64::from(samples))); + let (t0, t1) = (f64::from(k - 1) / f64::from(samples), f64::from(k) / f64::from(samples)); + ring.push((curve.eval(t1), (!line).then_some(Span { curve, t0, t1 }))); } } - if ring.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1e8 || p.y.abs() > 1e8) { + if ring.iter().any(|(p, _)| !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1e8 || p.y.abs() > 1e8) { return Err("The shape contains invalid coordinates".into()); } - ring.dedup_by(|a, b| a.distance(*b) < 1e-9); - while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) { - ring.pop(); + ring.dedup_by(|a, b| a.0.distance(b.0) < 1e-9); + // The last point back on the first closes the ring along the edge that ends at it. + while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| a.0.distance(b.0) < 1e-9) { + let closing = ring.pop().and_then(|(_, s)| s); + if let Some(first) = ring.first_mut() { + first.1 = closing; + } } + let points: Vec = ring.iter().map(|(p, _)| *p).collect(); // Rings with no area are kept here: a contour that crosses itself into lobes that cancel // (a bowtie) has none, and [`edges_meet`] must see it. [`orient`] leaves them out. A flat // ring, every point on one line, fills nothing under either rule and is left out now, so // that it doesn't send the profile through the boolean. - if ring.len() >= 3 && !flat(&ring) { - out.push(ring); + if points.len() >= 3 && !flat(&points) { + let n = points.len(); + let spans = (0..n).map(|i| ring.get((i + 1) % n).and_then(|(_, s)| *s)).collect(); + out.push(Ring { points, spans }); } } Ok(out) @@ -310,15 +382,15 @@ pub(super) fn sample(path: &PathData, size: f64, limit: usize, most: u32) -> Res /// `out` (sampled rings) turned so the filled side is on each one's left, and under the nonzero /// `rule` without the rings that are no edge of the shape, as [`rings`] describes. -fn orient(mut out: Vec>, rule: FillRule) -> Result>, String> { - out.retain(|r| signed_area(r).abs() > 1e-9); +fn orient(mut out: Vec, rule: FillRule) -> Result, String> { + out.retain(|r| signed_area(r.points()).abs() > 1e-9); // Which rings hold which: each ring against the others, its edges walked only where its // bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges and pairs in all (a pair // whose bounds miss the point costs a step too: many small rings are a square of pairs). let too_complex = || String::from(TOO_COMPLEX_SHAPE); let mut work = 0usize; - let mut within = |out: &[Vec], bounds: &[Rect], i: usize| -> Result, String> { - let p = out.get(i).and_then(|r| r.first()).copied().unwrap_or_default(); + let mut within = |out: &[R], bounds: &[Rect], i: usize| -> Result, String> { + let p = out.get(i).and_then(|r| r.points().first()).copied().unwrap_or_default(); let mut held_by = vec![]; for (j, (o, b)) in out.iter().zip(bounds).enumerate() { work = work.saturating_add(1); @@ -328,18 +400,18 @@ fn orient(mut out: Vec>, rule: FillRule) -> Result>, S } continue; } - work = work.saturating_add(o.len()); + work = work.saturating_add(o.points().len()); if work > MAX_CONTAINMENT_EDGES { return Err(too_complex()); } - if contains(o, p) { + if contains(o.points(), p) { held_by.push(j); } } Ok(held_by) }; - let bounds_of = |out: &[Vec]| -> Vec { - out.iter().map(|r| r.iter().map(|q| Rect::from_points(*q, *q)).reduce(|a, b| a.union(b)).unwrap_or_default()).collect() + let bounds_of = |out: &[R]| -> Vec { + out.iter().map(|r| r.points().iter().map(|q| Rect::from_points(*q, *q)).reduce(|a, b| a.union(b)).unwrap_or_default()).collect() }; if rule == FillRule::NonZero { let turn = |r: &[Point]| if signed_area(r) > 0.0 { 1 } else { -1 }; @@ -347,8 +419,8 @@ fn orient(mut out: Vec>, rule: FillRule) -> Result>, S let mut edge = Vec::with_capacity(out.len()); for (i, r) in out.iter().enumerate() { // The winding number just outside the ring, and just inside it. - let outside: i32 = within(&out, &bounds, i)?.into_iter().filter_map(|j| out.get(j)).map(|o| turn(o)).sum(); - edge.push((outside != 0) != (outside + turn(r) != 0)); + let outside: i32 = within(&out, &bounds, i)?.into_iter().filter_map(|j| out.get(j)).map(|o| turn(o.points())).sum(); + edge.push((outside != 0) != (outside + turn(r.points()) != 0)); } let mut edge = edge.into_iter(); out.retain(|_| edge.next().unwrap_or(true)); @@ -357,8 +429,8 @@ fn orient(mut out: Vec>, rule: FillRule) -> Result>, S let bounds = bounds_of(&out); let holes: Vec = (0..out.len()).map(|i| within(&out, &bounds, i).map(|held| held.len() % 2 == 1)).collect::>()?; for (r, hole) in out.iter_mut().zip(holes) { - if (signed_area(r) > 0.0) == hole { - r.reverse(); + if (signed_area(r.points()) > 0.0) == hole { + r.turn_around(); } } Ok(out) diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index f3efe8455..5749e2e36 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -316,6 +316,161 @@ fn a_wider_perspective_brings_the_eye_nearer() { assert!(close(last, 100.0 * eye / (eye - 50.0), 1e-6), "{last}"); } +/// A circle of four cubics about `c`. +fn circle(c: Point, r: f64) -> BezPath { + let k = 0.552_284_749_830_793_6 * r; + let mut b = BezPath::new(); + b.move_to((c.x + r, c.y)); + b.curve_to((c.x + r, c.y + k), (c.x + k, c.y + r), (c.x, c.y + r)); + b.curve_to((c.x - k, c.y + r), (c.x - r, c.y + k), (c.x - r, c.y)); + b.curve_to((c.x - r, c.y - k), (c.x - k, c.y - r), (c.x, c.y - r)); + b.curve_to((c.x + k, c.y - r), (c.x + r, c.y - k), (c.x + r, c.y)); + b.close_path(); + b +} + +/// A "D" with its counter, as outlined type draws one: straight stems, round bowls, and corners +/// where they meet. +fn letter_d() -> BezPath { + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.line_to((40.0, 0.0)); + b.curve_to((95.0, 0.0), (95.0, 100.0), (40.0, 100.0)); + b.line_to((0.0, 100.0)); + b.close_path(); + b.move_to((20.0, 20.0)); + b.line_to((20.0, 80.0)); + b.line_to((40.0, 80.0)); + b.curve_to((70.0, 80.0), (70.0, 20.0), (40.0, 20.0)); + b.close_path(); + b +} + +/// A path's curved segments, segments, smooth anchors and anchors. +fn anatomy(p: &PathData) -> [usize; 4] { + let mut n = [0; 4]; + for sp in &p.subpaths { + let count = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + n[0] += (0..count).filter(|i| !sp.segment_is_line(*i)).count(); + n[1] += count; + n[2] += sp.anchors.iter().filter(|a| a.kind == vectorcraft_geom::AnchorKind::Smooth).count(); + n[3] += sp.anchors.len(); + } + n +} + +/// `b` flattened to within 1e-6 pt, each point moved by `map`. +fn fine(b: &BezPath, map: impl Fn(Point) -> Point) -> Vec> { + let mut out: Vec> = vec![]; + vectorcraft_geom::kurbo::flatten(b, 1e-6, |el| match el { + vectorcraft_geom::kurbo::PathEl::MoveTo(p) => out.push(vec![map(p)]), + vectorcraft_geom::kurbo::PathEl::LineTo(p) => out.last_mut().into_iter().for_each(|r| r.push(map(p))), + _ => {} + }); + out +} + +/// How far `p` lies from the closed polygons `target` at most, sampled along each segment. +fn farthest(p: &PathData, target: &[Vec]) -> f64 { + let mut far = 0.0f64; + for sp in &p.subpaths { + let count = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + for i in 0..count { + let c = sp.segment(i); + for k in 0..=32 { + let q = c.eval(f64::from(k) / 32.0); + assert!(q.x.is_finite() && q.y.is_finite(), "{q:?}"); + far = far.max(edge_distance(target, q)); + } + } + } + far +} + +/// Seen straight on, the front cap of an extruded circle or letter is the art: each curve stays +/// one cubic, smooth anchors stay smooth, and no point lies farther than the curve tolerance from +/// the art. The facet's polygon, which visibility uses, is still the sampled outline. +#[test] +fn a_cap_keeps_the_curves_of_the_art() { + for art in [circle(Point::new(50.0, 50.0), 40.0), letter_d()] { + let (p, b) = shape(&art); + let f = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), front()).unwrap(); + assert_eq!(f.len(), 1); + let cap = f[0].path(); + assert_eq!(anatomy(&cap), anatomy(&p), "{cap:?}"); + assert!(f[0].contours.iter().map(Vec::len).sum::() > anatomy(&p)[3]); + let far = farthest(&cap, &fine(&art, |q| q)); + assert!(far <= outline::CURVE_TOLERANCE, "{far}"); + } +} + +/// Turned and in perspective, the front cap's curves are fitted to the projected circle within +/// the curve tolerance, every anchor stays smooth, and the walls' edges along it are curved too. +#[test] +fn a_cap_in_perspective_fits_the_projected_curves() { + let art = circle(Point::new(50.0, 50.0), 40.0); + let (p, b) = shape(&art); + let o = Extrude { depth: 50.0, ..Extrude::default() }; + for view in [View { rotation_x: 20.0, rotation_y: 30.0, perspective: 60.0, ..front() }, View { perspective: 120.0, ..front() }] { + let f = extrude(&p, FillRule::EvenOdd, b, o, view).unwrap(); + let cap = f.iter().find(|f| f.depth == f64::MAX).unwrap().path(); + let [curves, segments, smooth, anchors] = anatomy(&cap); + assert!(curves >= 4 && curves == segments && smooth == anchors, "{:?}", anatomy(&cap)); + let camera = Camera::new(view, b, o.depth); + let projected = fine(&art, |q| camera.project(camera.place(q, o.depth / 2.0))); + let far = farthest(&cap, &projected); + assert!(far <= outline::CURVE_TOLERANCE + 1e-6, "{far}"); + if view.rotation_y != 0.0 { + assert!(f.iter().filter(|f| f.depth != f64::MAX).any(|f| anatomy(&f.path())[0] == 2), "the walls' edges along the circle are curved"); + } + } +} + +/// A twisted, tapered back cap follows the turned, scaled art; a cap inset by a bevel stays +/// within the sampling tolerance of the solid; a tiny circle and a curve with a handle of zero +/// length keep their cubics. +#[test] +fn twisted_beveled_tiny_and_degenerate_caps_keep_their_curves() { + let art = circle(Point::new(50.0, 50.0), 40.0); + let (p, b) = shape(&art); + let o = Extrude { depth: 50.0, twist: 45.0, taper: 0.5, ..Extrude::default() }; + let behind = View { rotation_y: 180.0, ..front() }; + let f = extrude(&p, FillRule::EvenOdd, b, o, behind).unwrap(); + let cap = f.iter().find(|f| f.depth == f64::MAX).unwrap().path(); + assert_eq!(anatomy(&cap)[0], 4); + let camera = Camera::new(behind, b, o.depth); + let back = fine(&art, |q| section(&[q], b, &o, 1.0).first().map_or(q, |q| camera.project(camera.place(*q, -o.depth / 2.0)))); + let far = farthest(&cap, &back); + assert!(far <= outline::CURVE_TOLERANCE + 1e-6, "{far}"); + + // Where a curve meets a straight edge at a corner, the bevel's miter moves the cap's corner off + // the curve's offset; the cap's curves still meet it. + let bevel = Extrude { bevel: Some(Bevel { inside: true, width: 4.0, height: 4.0, ..Bevel::default() }), ..Extrude::default() }; + for art in [art.clone(), letter_d()] { + let (p, b) = shape(&art); + let f = extrude(&p, FillRule::EvenOdd, b, bevel, front()).unwrap(); + let cap = f.iter().find(|f| f.depth == f64::MAX).unwrap(); + // An offset curve is no cubic: it may take several, but no edge of a curve stays straight. + let ([curves, segments, ..], [art_curves, art_segments, ..]) = (anatomy(&cap.path()), anatomy(&p)); + assert!(curves >= art_curves && segments - curves == art_segments - art_curves, "{:?}", cap.path()); + let far = farthest(&cap.path(), &cap.contours); + assert!(far <= 2.0 * 0.05, "{far}"); + } + + let mut flat = PathData::from_bezpath(&art); + if let Some(a) = flat.subpaths.first_mut().and_then(|sp| sp.anchors.first_mut()) { + a.h_out = a.p; + } + for p in [PathData::from_bezpath(&circle(Point::new(1.0, 1.0), 0.01)), flat] { + let b = p.bounds().unwrap(); + let f = extrude(&p, FillRule::NonZero, b, Extrude::default(), front()).unwrap(); + let cap = f[0].path(); + assert_eq!(anatomy(&cap)[0], anatomy(&p)[0], "{cap:?}"); + let far = farthest(&cap, &fine(&p.to_bezpath(), |q| q)); + assert!(far <= outline::CURVE_TOLERANCE, "{far}"); + } +} + #[test] fn the_inflate_membrane_matches_known_solutions() { // ∇²φ = −1, zero on the outline: a disc of radius r peaks at r²/4, a square of side l at @@ -1172,7 +1327,7 @@ fn meets_within_twice_the_boolean_tolerance() { fn the_merged_rings_are_turned_as_a_whole() { let outline = vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)]; let hole = vec![Point::new(2.0, 2.0), Point::new(2.0, 8.0), Point::new(8.0, 8.0), Point::new(8.0, 2.0)]; - let turned = rings::wound(vec![outline.iter().rev().copied().collect(), hole.iter().rev().copied().collect()]); + let turned = rings::wound::>(vec![outline.iter().rev().copied().collect(), hole.iter().rev().copied().collect()]); assert!(rings::signed_area(&turned[0]) > 0.0 && rings::signed_area(&turned[1]) < 0.0); let kept = rings::wound(vec![outline.clone(), hole.clone()]); assert_eq!(kept, vec![outline, hole]); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 22815e7a7..92aabc074 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1059,6 +1059,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). + - Extrude & Bevel (both kinds) and Rotate keep the art's curves in the faces they draw, export and expand: caps and the edges of walls and bevels where they meet a cap are cubics with smooth anchors where the art's are, fitted within 0.001 pt in perspective and within the sampling tolerance on a bevel's inset (VectorCraft's own values; [three-d.md](three-d.md#what-differs-from-the-public-documentation)). Tests `solid::tests::a_cap_keeps_the_curves_of_the_art`, `a_cap_in_perspective_fits_the_projected_curves`, `twisted_beveled_tiny_and_degenerate_caps_keep_their_curves`. - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617). diff --git a/docs/three-d.md b/docs/three-d.md index e541a078a..38d96369c 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -245,6 +245,15 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. builds them, against the same 163,840. The point limit is counted on the shape as drawn and, when its contours meet, again on the merged outline, which can have more points (bars crossing in a grid trace every hole between them); past it, the shape is too complex for the effect. +- The faces drawn, exported and made by **Object › Expand Appearance** keep the shape's curves: the caps of + Extrude & Bevel, the flat face of Rotate, and the edges of the walls and bevels where they meet a cap are cubic + curves, with smooth anchors where the art's are smooth, while visibility and depth are worked out on the + sampled points. Without perspective and bevel each curve maps exactly. With perspective, a curve is split into + cubics until each lies within 0.001 pt of the projected outline. A face inset or outset by a bevel follows its + sampled outline within the sampling tolerance above. The edges between two bevel steps or two wall slices, a + piece of curve that does not fit after 8 halvings, an edge of a twisted wall cut into pieces, and the curves + past 1,048,576 for one solid stay straight. The tolerances and limits are VectorCraft's own values. Inflate's + faces are polygons. - Inflate also limits the work of filling its grid, which grows with the outline's points times the grid cells its outline crosses. An outline of tens of thousands of points that crosses nearly every cell (a comb of 4,000 teeth across the shape) is refused because the 3D surface is too complex; a closed path of 65,536 points round a From 7dfe2d9d2aa5675b00f41018917e426d90d3402b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 14:20:41 -0800 Subject: [PATCH 02/21] 3D: a symbol's 9-slice guides and overrides count before its art is placed The check that refuses a symbol instance before its art is placed counted only the symbol's own objects. Placing art through 9-slice guides can make up to 28 objects of each one, and an instance's dynamic-symbol overrides can turn a path that would be cut at the guides into clip groups in every cell it reaches. Such an instance passed the check, and its art was placed in full before the work limit refused it. The check now counts the symbol's own objects first, then the most objects placing makes (NineSliceMap::most_placed) of the art with the instance's overrides applied, and refuses the instance with the same message before anything is placed. --- crates/effects/src/solids/source.rs | 23 +++++++---- crates/effects/src/solids/tests_sources.rs | 48 ++++++++++++++++++++++ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index 090688c4f..c5f664c68 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -129,16 +129,23 @@ impl Resolver<'_> { } self.spend(1)?; match &n.kind { - NodeKind::SymbolInstance { symbol, .. } => { - // Counted before the art is placed, so a symbol past the limit is never copied. - let held = self.doc.and_then(|d| d.symbols.iter().find(|s| s.name == *symbol)).map_or(0, |s| s.art.count_with_masks()); - if self.work.saturating_add(held) > MAX_WORK { + NodeKind::SymbolInstance { symbol, xf } => { + let missing = || format!("{label} can't build a solid from the instance of symbol \"{symbol}\": the symbol's art isn't available"); + let doc = self.doc.ok_or_else(missing)?; + let sym = doc.symbols.iter().find(|s| s.name == *symbol).ok_or_else(missing)?; + // Counted before the art is placed, so a symbol past the limit is never copied: + // first its own objects, which the instance's overrides keep, then the most + // objects placing makes, with those overrides and the symbol's 9-slice guides. + if self.work.saturating_add(sym.art.count_with_masks()) > MAX_WORK { return Err(self.too_much_work()); } - let art = crate::outline_object(self.doc, n).ok_or_else(|| { - format!("{label} can't build a solid from the instance of symbol \"{symbol}\": the symbol's art isn't available") - })?; - Ok(self.list(kids(&art), depth + 1)?.0) + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + let most = doc.symbol_slice_map(symbol, *xf).map_or(art.count_with_masks() as u64, |m| m.most_placed(&art, doc)); + if (self.work as u64).saturating_add(most) > MAX_WORK as u64 { + return Err(self.too_much_work()); + } + let placed = doc.place_symbol_art(symbol, &art, *xf).ok_or_else(missing)?; + Ok(self.list(&[Arc::new(placed)], depth + 1)?.0) } NodeKind::Blend { children, spec } => { // Counted before the steps are made, so a blend past the limit is never expanded. diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index 35e89e61f..4957f88fa 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -375,6 +375,54 @@ fn a_symbol_holding_more_objects_than_the_work_limit_is_refused_before_its_art_i assert!(work <= 2, "refused before the symbol's art is placed and listed: {work}"); } +/// A gradient fill: a path filled with one is drawn in clip groups where it crosses 9-slice guides. +fn gradient() -> Paint { + use vectorcraft_doc::color::{Gradient, GradientPaint}; + Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))) +} + +/// A document whose symbol "Sliced" has 9-slice guides and holds `count` paths across all of them, +/// each filled with `fill`, and an Extrude on an instance of it at twice its size. +fn sliced(count: usize, fill: impl Fn() -> Paint) -> (Document, Node) { + let mut d = document(); + let members = (0..count).map(|i| { + Arc::new(Node::path(NodeId(i as u64 + 1), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill(), Paint::None, 0.0))) + }); + d.symbols.push(Symbol { dynamic: true, name: "Sliced".into(), art: Arc::new(Node::group(NodeId(0), members.collect())) }); + d.set_symbol_nine_slice("Sliced", Some(vectorcraft_doc::SymbolNineSlice::default())); + let n = with(instance(30, "Sliced", Affine::scale(2.0)), EXTRUDE, json!({})); + (d, n) +} + +#[test] +fn a_symbol_whose_9_slice_guides_would_place_too_many_objects_is_refused_before_its_art_is_placed() { + // Each gradient path becomes a group of nine clip groups, so the art placed is far past the + // limit while the symbol itself holds less than a tenth of it. + let (d, n) = sliced(source::MAX_WORK / 20, gradient); + assert!(d.symbols[1].art.count_with_masks() < source::MAX_WORK); + let err = validate_three_d_in(Some(&d), &n, EXTRUDE, &json!({})).unwrap_err(); + assert!(err.contains("hold too many objects"), "{err}"); + let work = source::WORK.with(std::cell::Cell::get); + assert!(work <= 2, "refused before the symbol's art is placed and listed: {work}"); +} + +#[test] +fn a_dynamic_symbol_whose_overrides_would_place_too_many_objects_is_refused_before_its_art_is_placed() { + // Solid paths are cut at the guides and stay one path each; the instance's overrides fill them + // with a gradient, which places each in nine clip groups. + let (d, mut n) = sliced(source::MAX_WORK / 20, || Paint::solid(Color::BLACK)); + n.extras_mut().symbol_overrides = (0..source::MAX_WORK / 20) + .map(|i| vectorcraft_doc::SymbolOverride { path: vec![i as u32], fill: Some(gradient()), ..Default::default() }) + .collect(); + let err = validate_three_d_in(Some(&d), &n, EXTRUDE, &json!({})).unwrap_err(); + assert!(err.contains("hold too many objects"), "{err}"); + let work = source::WORK.with(std::cell::Cell::get); + assert!(work <= 2, "refused before the instance's art is placed and listed: {work}"); + // A few such paths build. + let (d, n) = sliced(4, || Paint::solid(Color::BLACK)); + assert!(solid_art_in(Some(&d), &n).is_some()); +} + #[test] fn empty_sources_and_sources_inside_sources_build_or_refuse_cleanly() { let mut d = document(); From e105548fdb517d144a4e47fe9ea9e0a2f530f80e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 14:29:15 -0800 Subject: [PATCH 03/21] Docs: the 3D curves entry names the edges that stay straight The progress entry said caps and the walls' cap edges are cubics without its limits. It now lists the cases three-d.md describes: an edge of a twisted wall that is cut into pieces, a piece of curve that does not fit after 8 halvings, and the curves past 1,048,576 for one solid. --- 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 92aabc074..3d4d6b95c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1059,7 +1059,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). - - Extrude & Bevel (both kinds) and Rotate keep the art's curves in the faces they draw, export and expand: caps and the edges of walls and bevels where they meet a cap are cubics with smooth anchors where the art's are, fitted within 0.001 pt in perspective and within the sampling tolerance on a bevel's inset (VectorCraft's own values; [three-d.md](three-d.md#what-differs-from-the-public-documentation)). Tests `solid::tests::a_cap_keeps_the_curves_of_the_art`, `a_cap_in_perspective_fits_the_projected_curves`, `twisted_beveled_tiny_and_degenerate_caps_keep_their_curves`. + - Extrude & Bevel (both kinds) and Rotate keep the art's curves in the faces they draw, export and expand: caps and the edges of walls and bevels where they meet a cap are cubics with smooth anchors where the art's are, fitted within 0.001 pt in perspective and within the sampling tolerance on a bevel's inset. An edge of a twisted wall that is cut into pieces, a piece of curve that does not fit after 8 halvings, and the curves past 1,048,576 for one solid stay straight (VectorCraft's own values; [three-d.md](three-d.md#what-differs-from-the-public-documentation)). Tests `solid::tests::a_cap_keeps_the_curves_of_the_art`, `a_cap_in_perspective_fits_the_projected_curves`, `twisted_beveled_tiny_and_degenerate_caps_keep_their_curves`. - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617). From 24155e1f75f84b94699326b83af1c4ec3d25ee29 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 14:30:10 -0800 Subject: [PATCH 04/21] 3D: an opacity mask on a group member masks its solids A 3D effect on a group built its solids from the members' paths and paint and left out each member's opacity mask, and the mask of a group inside the 3D group. Clipping groups, blends and symbol instances resolved for the effect lost their masks the same way. A member, or a group inside the 3D group, with an opacity mask now gets a scope like one with its own opacity or blending mode, and the mask goes on the group that holds that member's surfaces. Revolve applies it with the member's opacity and blending mode; Extrude & Bevel, Extrude & Bevel (Classic), Inflate and Rotate draw each solid on its own when a mask is present and put the masked members' solids in a group with the mask. A resolved clipping group, blend or symbol instance keeps its mask. A disabled mask is ignored. --- crates/effects/src/revolve.rs | 17 ++- crates/effects/src/solids.rs | 121 +++++++++++++++------ crates/effects/src/solids/source.rs | 26 ++--- crates/effects/src/solids/tests_sources.rs | 61 +++++++++++ docs/mcp.md | 3 +- docs/revolve.md | 7 +- docs/three-d.md | 6 +- 7 files changed, 181 insertions(+), 60 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index a5004ab99..c42b0c69d 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -463,22 +463,19 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { /// The members' `surfaces`, or runs of their faces, in order, from the [`Scope`] at `level` of each /// one's `Profile::scopes` inward: consecutive surfaces that share a scope go in one group with its -/// opacity and blending mode, so each applies once, to the faces it holds together. -fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Arc)>, level: usize) -> Vec> { - fn close(id: NodeId, run: &mut Vec<(&[Scope], Arc)>, scope: Option, level: usize, out: &mut Vec>) { +/// opacity, blending mode and opacity mask, so each applies once, to the faces it holds together. +pub(crate) fn scoped<'a>(id: NodeId, surfaces: Vec<(&'a [Scope], Arc)>, level: usize) -> Vec> { + fn close(id: NodeId, run: &mut Vec<(&[Scope], Arc)>, scope: Option<&Scope>, level: usize, out: &mut Vec>) { if let Some(scope) = scope { - let mut group = Node::group(id, scoped(id, std::mem::take(run), level + 1)); - group.opacity = scope.opacity; - group.blend = scope.blend; - out.push(Arc::new(group)); + out.push(Arc::new(scope.group(id, scoped(id, std::mem::take(run), level + 1)))); } } let mut out = vec![]; let mut run = vec![]; - let mut scope = None; + let mut scope: Option<&'a Scope> = None; for (scopes, s) in surfaces { - let at = scopes.get(level).copied(); - if at.map(|a| a.key) != scope.map(|s: Scope| s.key) { + let at = scopes.get(level); + if at.map(|a| a.key) != scope.map(|s| s.key) { close(id, &mut run, scope, level, &mut out); scope = at; } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 4ac4628b1..fed0a9d74 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData, Rect}; use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, View}; @@ -238,20 +238,68 @@ pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRul }) } -/// A group in a 3D effect's group, or a member of it, whose opacity is below 100% or whose blending -/// mode is not Normal. The members of one group share its `key`. -#[derive(Clone, Copy, Debug, PartialEq)] +/// A group in a 3D effect's group, or a member of it, whose opacity is below 100%, whose blending +/// mode is not Normal or that has an opacity mask. The members of one group share its `key`. +#[derive(Clone, Debug, PartialEq)] pub(crate) struct Scope { pub(crate) key: usize, pub(crate) opacity: f32, pub(crate) blend: BlendMode, + /// The object's opacity mask, unless it has none or it is disabled. The mask art is in + /// document coordinates, as the solids' faces are. + pub(crate) mask: Option>, +} + +impl Scope { + /// The scope of `n` with key `key`, or `None` when `n` is opaque, blends normally and has no + /// opacity mask that applies. + fn of(n: &Node, key: usize) -> Option { + let mask = n.extras().mask.as_deref().filter(|m| !m.disabled); + (n.opacity < 1.0 || n.blend != BlendMode::Normal || mask.is_some()).then(|| Scope { + key, + opacity: n.opacity, + blend: n.blend, + mask: mask.map(|m| Arc::new(m.clone())), + }) + } + + /// A group of `children` with id `id` and this scope's opacity, blending mode and opacity mask. + pub(crate) fn group(&self, id: NodeId, children: Vec>) -> Node { + let mut group = Node::group(id, children); + group.opacity = self.opacity; + group.blend = self.blend; + if let Some(mask) = &self.mask { + group.extras_mut().mask = Some(Box::new((**mask).clone())); + } + group + } +} + +/// `surfaces`, each with the scopes of the member it was built from, in groups with the opacity +/// masks of those scopes, so each mask applies to the faces of the members it holds. The members' +/// opacity is already in their paint ([`Profile::opacity`]), so these groups are opaque. +fn masked(id: NodeId, surfaces: Vec<(&[Scope], Arc)>) -> Vec> { + let masks: Vec<(Vec, Arc)> = surfaces + .into_iter() + .map(|(scopes, s)| { + let masks = scopes.iter().filter(|s| s.mask.is_some()).map(|s| Scope { opacity: 1.0, blend: BlendMode::Normal, ..s.clone() }).collect(); + (masks, s) + }) + .collect(); + crate::revolve::scoped(id, masks.iter().map(|(scopes, s)| (scopes.as_slice(), s.clone())).collect(), 0) +} + +/// Does any of `shapes` sit in a scope with an opacity mask? +fn has_masks(shapes: &[Profile]) -> bool { + shapes.iter().any(|s| s.scopes.iter().any(|s| s.mask.is_some())) } /// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint /// it, and the opacity of the objects it is in multiplied together (1 for the object with the -/// effect itself). `scopes` lists those objects with transparency of their own ([`Scope`]), from -/// the outermost group down to the member itself (none for the object itself). Group Revolve -/// applies each once, to the surfaces of the members it holds together. +/// effect itself). `scopes` lists those objects with transparency or an opacity mask of their own +/// ([`Scope`]), from the outermost group down to the member itself (none for the object itself). +/// Group Revolve applies each once, to the surfaces of the members it holds together; the other 3D +/// effects apply each opacity mask that way. pub(crate) struct Profile { pub(crate) path: PathData, pub(crate) rule: FillRule, @@ -298,9 +346,9 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { } let opacity = opacity * c.opacity; let mut scopes = scopes.to_vec(); - if c.blend != BlendMode::Normal || c.opacity < 1.0 { + if let Some(scope) = Scope::of(c, w.keys + 1) { w.keys += 1; - scopes.push(Scope { key: w.keys, opacity: c.opacity, blend: c.blend }); + scopes.push(scope); } if is_container(c) { let first = w.out.len(); @@ -445,22 +493,26 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, } } +/// One part of [`solid_parts`]: a fill or stroke, its shape's opacity, the region it paints and its +/// fill rule, and its shape's scopes. +type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope]); + /// The parts Extrude & Bevel and Inflate build solids from: each shape's top fill and its strokes, -/// a group's members from the back up, each with its shape's opacity and the region it paints. -/// The solids share the bounds of all their parts (`fallback` when none has any): one center to -/// turn, twist and taper about, and Inflate's grid reaches over the strokes' outlines. -fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<(&AppearanceItem, f32, PathData, FillRule)>, Rect) { - let parts: Vec<(&AppearanceItem, f32, PathData, FillRule)> = shapes +/// a group's members from the back up, each with its shape's opacity, the region it paints and its +/// shape's scopes. The solids share the bounds of all their parts (`fallback` when none has any): +/// one center to turn, twist and taper about, and Inflate's grid reaches over the strokes' outlines. +fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec>, Rect) { + let parts: Vec = shapes .iter() .flat_map(|s| { let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_))); s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| { let (area, rule) = region(&s.path, s.rule, i); - (i, s.opacity, area, rule) + (i, s.opacity, area, rule, s.scopes.as_slice()) }) }) .collect(); - let bounds = parts.iter().filter_map(|(_, _, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback); + let bounds = parts.iter().filter_map(|(_, _, area, _, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback); (parts, bounds) } @@ -483,12 +535,14 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile /// each in its own paint. When every one is opaque with normal blending and no colored light makes /// a surface one composite or masks it, their faces are drawn in one back-to-front order (where /// depths tie, as for the caps turned toward the viewer, in the order of the appearance); -/// otherwise each solid is a surface of its own, in that order. +/// otherwise each solid is a surface of its own, in that order. A member's opacity mask, or that of +/// a group it is in, masks the surfaces of the members it holds ([`masked`]). fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); let classic = fx.id == CLASSIC_EXTRUDE; let colored = !classic && view_options(&p).has_colored_lights(); + let masks = has_masks(shapes); if fx.id == ROTATE { // Every part turns about the centre of the object's own bounds (a group's members about // the centre of theirs), so they stay together. @@ -498,24 +552,24 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> let (paint, opacity, blend) = paint_of(item, s.opacity); let (area, rule) = region(&s.path, s.rule, item); let f = facets(ROTATE, &area, rule, b, &p).ok()?; - out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored))); + out.push((s.scopes.as_slice(), Arc::new(surface(n, f, &paint, opacity, blend, colored)))); } } - return Some(out); + return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }); } let (parts, bounds) = solid_parts(shapes, b); let built = if fx.id == INFLATE { // One eye for every puff, as far away as the highest needs. - let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect(); + let shapes: Vec<(&PathData, 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() + parts.iter().map(|(_, _, area, rule, _)| facets(&fx.id, area, *rule, bounds, &p)).collect() }; let mut solids = vec![]; - for ((item, opacity, _, _), f) in parts.iter().zip(built) { + for ((item, opacity, _, _, scopes), f) in parts.iter().zip(built) { let item: &AppearanceItem = item; match f { - Ok(f) => solids.push((item, *opacity, f)), + Ok(f) => solids.push((item, *opacity, *scopes, f)), // A stroke's outline past the limits is left out; the fill's solid still draws. Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} // A loaded file or a shape edited past the limits keeps drawing its source path @@ -527,30 +581,33 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> if solids.is_empty() { return None; } - let opaque = solids.iter().all(|(item, member, _)| { + let opaque = solids.iter().all(|(item, member, _, _)| { let (_, opacity, blend) = paint_of(item, *member); opacity >= 1.0 && blend == BlendMode::Normal }); - let surfaces: Vec<(Vec, Node)> = solids + let surfaces: Vec<(Vec, Node, &[Scope])> = solids .into_iter() - .map(|(item, member, f)| { + .map(|(item, member, scopes, f)| { let depths: Vec = f.iter().map(|f| f.depth).collect(); let (paint, opacity, blend) = paint_of(item, member); let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; - (depths, art) + (depths, art, scopes) }) .collect(); + if masks { + return Some(masked(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect())); + } // One order takes a plain group of faces from each solid, one face for each facet. With // colored lights a surface can instead be one clipped composite of all its facets, or carry an // opacity mask, and then it stays whole. - let plain = surfaces.iter().all(|(depths, s)| { + let plain = surfaces.iter().all(|(depths, s, _)| { s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()) }); if !opaque || !plain { - return Some(surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect()); + return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect()); } let mut faces = vec![]; - for (depths, s) in surfaces { + for (depths, s, _) in surfaces { if let NodeKind::Group { children, .. } = s.kind { faces.extend(depths.into_iter().zip(children)); } @@ -702,11 +759,11 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) // Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel // solid and its mesh are built as the loop reaches them. let mut inflated = (fx.id == INFLATE).then(|| { - let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect(); + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect(); vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) }); let extrude = extrude_options(&p); - for (item, member, area, rule) in &parts { + for (item, member, area, rule, _) in &parts { let item: &AppearanceItem = item; let mesh = match inflated.as_mut() { Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())), diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index 090688c4f..f87bc39f8 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -343,25 +343,22 @@ fn clip_area(first: &Node) -> (PathData, FillRule) { } /// `pieces`, each with the [`Scope`]s of the groups it was in, back in groups of `n`'s id with -/// those groups' opacity and blending mode: consecutive pieces sharing the scope at `level` go into -/// one group, so its transparency applies to them together. `level` stays below the nesting the -/// pieces came from ([`nested_past`]). +/// those groups' opacity, blending mode and opacity mask: consecutive pieces sharing the scope at +/// `level` go into one group, so its transparency applies to them together. `level` stays below the +/// nesting the pieces came from ([`nested_past`]). fn nest(n: &Node, pieces: Vec<(Vec, Node)>, level: usize) -> Vec> { let mut out = vec![]; let mut run: Vec<(Vec, Node)> = vec![]; - let mut key = None; + let mut key: Option = None; let close = |run: &mut Vec<(Vec, Node)>, scope: Option, out: &mut Vec>| { let Some(scope) = scope else { return }; - let mut g = Node::group(n.id, nest(n, std::mem::take(run), level + 1)); - g.opacity = scope.opacity; - g.blend = scope.blend; - out.push(Arc::new(g)); + out.push(Arc::new(scope.group(n.id, nest(n, std::mem::take(run), level + 1)))); }; for (scopes, piece) in pieces { - let here = scopes.get(level).copied(); - if here.map(|s| s.key) != key.map(|s: Scope| s.key) { - close(&mut run, key, &mut out); - key = here; + let here = scopes.get(level).cloned(); + if here.as_ref().map(|s| s.key) != key.as_ref().map(|s| s.key) { + close(&mut run, key.take(), &mut out); + key = here.clone(); } match here { Some(_) => run.push((scopes, piece)), @@ -430,12 +427,15 @@ fn shape(n: &Node, path: PathData, rule: FillRule, items: Vec) - piece } -/// A plain group of `children` with `n`'s id, visibility, opacity and blending mode. +/// A plain group of `children` with `n`'s id, visibility, opacity, blending mode and opacity mask. fn wrap(n: &Node, children: Vec>) -> Node { let mut g = Node::group(n.id, children); g.name = n.name.clone(); g.opacity = n.opacity; g.blend = n.blend; + if let Some(mask) = &n.extras().mask { + g.extras_mut().mask = Some(mask.clone()); + } g } diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index 35e89e61f..8cb7c6fab 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -414,6 +414,67 @@ fn empty_sources_and_sources_inside_sources_build_or_refuse_cleanly() { assert!(close(bounds(&solid_art_in(Some(&d), &placed).unwrap()), bounds(&solid_art(&cut).unwrap()), 0.01)); } +/// The id of the art of [`masked_by`]'s opacity masks. +const MASK_ART: NodeId = NodeId(900); + +/// `n` with an opacity mask of white art covering `r`. +fn masked_by(mut n: Node, r: Rect) -> Node { + let art = Node::path(MASK_ART, shapes::rectangle(r), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)); + n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); + n +} + +/// The faces (paths) of `art`, and how many of them are inside an object [`masked_by`] masks with +/// a mask that is not disabled. +fn masked_faces(art: &Node) -> (usize, usize) { + fn walk(n: &Node, masked: bool, out: &mut (usize, usize)) { + let masked = masked || n.extras().mask.as_ref().is_some_and(|m| m.art.id == MASK_ART && !m.disabled); + match n.children() { + Some(children) => children.iter().for_each(|c| walk(c, masked, out)), + None => { + out.0 += 1; + out.1 += usize::from(masked); + } + } + } + let mut out = (0, 0); + walk(art, false, &mut out); + out +} + +#[test] +fn an_opacity_mask_on_a_member_or_a_group_in_a_three_d_group_masks_its_solids() { + let d = document(); + let mask = Rect::new(0.0, 0.0, 400.0, 400.0); + let mut members = vec![("a path", rect(1, 100.0, 100.0, 150.0, 200.0, 2.0))]; + members.extend(sources()); + for (what, member) in members { + let other = || Arc::new(rect(91, 600.0, 600.0, 650.0, 650.0, 0.0)); + let in_group = Node::group(NodeId(80), vec![Arc::new(member.clone())]); + let cases = [ + ("masked", Node::group(NodeId(90), vec![Arc::new(masked_by(member.clone(), mask)), other()])), + ("in a masked group", Node::group(NodeId(90), vec![Arc::new(masked_by(in_group, mask)), other()])), + ]; + for (how, g) in cases { + for id in TYPES { + let g = with(g.clone(), id, json!({})); + let (faces, masked) = masked_faces(&art(&d, &g, id).expect("drawn")); + assert!(masked > 0, "{id}: the faces of {what} {how} are masked"); + assert!(masked < faces, "{id}: the other member's faces are not ({masked} of {faces})"); + } + } + // A disabled mask masks nothing. + let mut off = masked_by(member.clone(), mask); + if let Some(m) = off.extras_mut().mask.as_mut() { + m.disabled = true; + } + for id in TYPES { + let g = with(Node::group(NodeId(90), vec![Arc::new(off.clone()), other()]), id, json!({})); + assert_eq!(masked_faces(&art(&d, &g, id).expect("drawn")).1, 0, "{id}: {what} with a disabled mask"); + } + } +} + #[test] fn resolved_objects_and_groups_in_a_three_d_group_keep_their_geometry_effects() { let mut d = document(); diff --git a/docs/mcp.md b/docs/mcp.md index 58d235b4c..80e7acb64 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2595,7 +2595,8 @@ Every 3D type, `threeD.classicExtrude` and `threeD.revolve` included, also appli symbol instance, alone or in a group, and builds from the art it shows, the same way for the canvas, exports and `assets.add3D` ([three-d.md](three-d.md#clipping-groups-blends-and-symbol-instances)); like a group, it takes the effect on the object itself, not on a fill or stroke. An image, alone or in a group, is refused with "trace it first -with Object › Image Trace › Make", and nothing changes. +with Object › Image Trace › Make", and nothing changes. On a group, an opacity mask on a member, or on a group inside +it, masks that member's solids ([three-d.md](three-d.md#groups)). `threeD.extrude`: `depth` (points, 0..2000, default 50), `twist` (degrees, 0..360, 0), `taper` (1..100%, 100), `cap` (true solid, false hollow), `bevel` (`none`, `classic`, `rounded`, `cove`, `step`, `ogee`), `bevelWidth` diff --git a/docs/revolve.md b/docs/revolve.md index 18be779d2..e52770fd6 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -6,9 +6,10 @@ Select a path, compound path, type, clipping group, blend, symbol instance, or a On a group or layer, every visible member sweeps about one axis, as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes for a group. The following rules are VectorCraft's own: type stays editable and sweeps as its glyph outlines, the shared axis lies on the chosen edge of the members' bounds together, and each member keeps its own paint. -The faces of all members draw in one back-to-front order. The opacity and blending mode of a member, or of a group -inside the group, apply once to all its faces. Where a face of another member lies between its faces in that order -and overlaps them on the page, its faces are split there, and the opacity and blending mode apply once to each part. +The faces of all members draw in one back-to-front order. The opacity, blending mode and opacity mask of a member, +or of a group inside the group, apply once to all its faces. Where a face of another member lies between its faces +in that order and overlaps them on the page, its faces are split there, and the opacity, blending mode and opacity +mask apply once to each part. The mask stays where it is on the page; it does not turn with the faces. Two faces overlap when the convex hulls of their outlines come within the 0.75 pt seam stroke of each other. After 4,194,304 overlap tests on one group, each further face of such a member is drawn as a part of its own. The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right diff --git a/docs/three-d.md b/docs/three-d.md index 0a9ac897d..bd55ac154 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -122,7 +122,11 @@ them all. The members stay editable: moving or editing one reshapes the solids. - Each member's own geometry effects shape it first, then those of each group inside the 3D group that holds it, applied to that group's members together; the 3D group's geometry effects before the 3D effect reshape the members, and those after it bend the result as a whole. The members' other effects, and the group's own fills and - strokes, are not drawn. A member's opacity applies to its solids; its blending mode does not. + strokes, are not drawn. A member's opacity applies to its solids; its blending mode does not. An opacity mask on + a member masks its solids, and one on a group inside the 3D group masks the solids of that group's members + together. The mask stays where it is on the page; it does not turn with the solids. A disabled mask is ignored. +- When a member, or a group inside the 3D group, has an opacity mask, each solid is drawn on its own, the backmost + member's first, as when a solid is transparent. - When every solid is opaque, all members' faces are drawn in one back-to-front order; otherwise each solid is drawn on its own, the backmost member's first. - Hidden members and guides are left out. A group holding a live object other than a blend (an envelope, a mesh, a From 6c84c929072b4319a73f611668c700f77f02875b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 14:36:28 -0800 Subject: [PATCH 05/21] 3D: a beveled cap and its walls draw their shared edge as the same curves A cap inset by a bevel fitted each run of sampled edges along one curve as a whole, while the wall next to it fitted each sampled edge by itself. The two outlines lay up to 0.25 pt apart on an 800 pt circle, which left a gap or an overlap between the cap and the wall. An inset outline is now fitted one sampled edge at a time in the cap as well, so both faces draw the same curves. Caps that are not inset keep one cubic for each curve of the art. Test: solid::tests::a_beveled_cap_and_its_walls_share_their_curves. Docs: docs/three-d.md, docs/target-app-parity.md. --- crates/three-d/src/solid/outline.rs | 6 +++-- crates/three-d/src/solid/tests.rs | 34 +++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- docs/three-d.md | 3 ++- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/three-d/src/solid/outline.rs b/crates/three-d/src/solid/outline.rs index 1d22e6450..07fb0ce58 100644 --- a/crates/three-d/src/solid/outline.rs +++ b/crates/three-d/src/solid/outline.rs @@ -129,9 +129,11 @@ fn contour(path: &mut BezPath, ring: &Ring, points: &[Point], step: &Along, budg i += 1; continue; }; - // The run of edges that follow this curve one after the other. + // The run of edges that follow this curve one after the other. An inset outline is fitted + // one edge at a time, as `wall` fits it, so a cap and its walls share the same curves. let (mut j, mut t1, mut last) = (i, span.t1, span.t0); - while let Some(Some(next)) = ring.spans.get(j + 1) + while step.map.is_some() + && let Some(Some(next)) = ring.spans.get(j + 1) && next.curve == span.curve && next.t0 == t1 { diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 5749e2e36..af9322bc1 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -426,6 +426,40 @@ fn a_cap_in_perspective_fits_the_projected_curves() { } } +/// A cap inset by a bevel and the walls next to it draw the edge they share as the same curves, so +/// no gap or overlap opens between them. +#[test] +fn a_beveled_cap_and_its_walls_share_their_curves() { + for (r, width) in [(40.0, 4.0), (400.0, 40.0)] { + for art in [circle(Point::new(r, r), r), letter_d()] { + let (p, b) = shape(&art); + let o = Extrude { depth: 50.0, bevel: Some(Bevel { inside: true, width, height: width, ..Bevel::default() }), ..Extrude::default() }; + let f = extrude(&p, FillRule::EvenOdd, b, o, View { rotation_x: 20.0, rotation_y: 30.0, ..front() }).unwrap(); + let caps: Vec<&Facet> = f.iter().filter(|f| f.depth == f64::MAX).collect(); + let outlines: Vec> = caps.iter().flat_map(|f| fine(&f.path().to_bezpath(), |q| q)).collect(); + let on_cap = |q: Point| caps.iter().flat_map(|f| f.contours.iter().flatten()).any(|v| v.distance(q) < 1e-9); + let (mut far, mut shared) = (0.0f64, 0); + for wall in f.iter().filter(|f| f.depth != f64::MAX && f.outline.is_some()) { + let path = wall.path(); + for sp in &path.subpaths { + for i in (0..sp.anchors.len()).filter(|i| !sp.segment_is_line(*i)) { + let c = sp.segment(i); + if !on_cap(c.p0) || !on_cap(c.p3) { + continue; + } + shared += 1; + for k in 0..=16 { + far = far.max(edge_distance(&outlines, c.eval(f64::from(k) / 16.0))); + } + } + } + } + assert!(shared > 0, "no wall shares a curved edge with a cap"); + assert!(far <= 1e-4, "{far}"); + } + } +} + /// A twisted, tapered back cap follows the turned, scaled art; a cap inset by a bevel stays /// within the sampling tolerance of the solid; a tiny circle and a curve with a handle of zero /// length keep their cubics. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3d4d6b95c..f0bfe6802 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1059,7 +1059,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). - - Extrude & Bevel (both kinds) and Rotate keep the art's curves in the faces they draw, export and expand: caps and the edges of walls and bevels where they meet a cap are cubics with smooth anchors where the art's are, fitted within 0.001 pt in perspective and within the sampling tolerance on a bevel's inset. An edge of a twisted wall that is cut into pieces, a piece of curve that does not fit after 8 halvings, and the curves past 1,048,576 for one solid stay straight (VectorCraft's own values; [three-d.md](three-d.md#what-differs-from-the-public-documentation)). Tests `solid::tests::a_cap_keeps_the_curves_of_the_art`, `a_cap_in_perspective_fits_the_projected_curves`, `twisted_beveled_tiny_and_degenerate_caps_keep_their_curves`. + - Extrude & Bevel (both kinds) and Rotate keep the art's curves in the faces they draw, export and expand: caps and the edges of walls and bevels where they meet a cap are cubics with smooth anchors where the art's are, fitted within 0.001 pt in perspective and within the sampling tolerance on a bevel's inset. An edge of a twisted wall that is cut into pieces, a piece of curve that does not fit after 8 halvings, and the curves past 1,048,576 for one solid stay straight (VectorCraft's own values; [three-d.md](three-d.md#what-differs-from-the-public-documentation)). Tests `solid::tests::a_cap_keeps_the_curves_of_the_art`, `a_cap_in_perspective_fits_the_projected_curves`, `a_beveled_cap_and_its_walls_share_their_curves`, `twisted_beveled_tiny_and_degenerate_caps_keep_their_curves`. - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617). diff --git a/docs/three-d.md b/docs/three-d.md index 38d96369c..1362563d2 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -250,7 +250,8 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. curves, with smooth anchors where the art's are smooth, while visibility and depth are worked out on the sampled points. Without perspective and bevel each curve maps exactly. With perspective, a curve is split into cubics until each lies within 0.001 pt of the projected outline. A face inset or outset by a bevel follows its - sampled outline within the sampling tolerance above. The edges between two bevel steps or two wall slices, a + sampled outline within the sampling tolerance above, one curve for each sampled edge through its two sampled + points, so the cap and the walls next to it draw the edge they share as the same curves. The edges between two bevel steps or two wall slices, a piece of curve that does not fit after 8 halvings, an edge of a twisted wall cut into pieces, and the curves past 1,048,576 for one solid stay straight. The tolerances and limits are VectorCraft's own values. Inflate's faces are polygons. From d9e9adbf4508bffd2d1ad496e77269e19daeb156 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 14:51:50 -0800 Subject: [PATCH 06/21] MCP: answer_dialog sets the fields and answers the dialog in one request answer_dialog sent ui.dialog.set and then ui.dialog.confirm or ui.dialog.cancel as two requests. A dialog another control connection opened between them was confirmed or canceled without the fields. ui.dialog.set takes `action: confirm | cancel`, checked before any field is set, and confirms or cancels the dialog the fields were set in within the same request, replying {dialog, result}. answer_dialog sends set and action together; an app whose reply has no `dialog` key did not answer it, and the dialog is then confirmed or canceled with its own request. --- crates/mcp/src/tests.rs | 54 +++++++++++++++++++++++++---- crates/mcp/src/tools/definitions.rs | 4 +-- crates/mcp/src/tools/handlers.rs | 43 ++++++++++++++++------- crates/ui-egui/src/control.rs | 39 +++++++++++++++++++-- crates/ui-egui/src/control/tests.rs | 32 +++++++++++++++++ docs/control-protocol.md | 2 +- docs/mcp.md | 2 +- 7 files changed, 150 insertions(+), 26 deletions(-) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 02e5624b3..dba3ed9de 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -723,6 +723,13 @@ fn fake_app() -> (String, std::thread::JoinHandle>) { /// [`fake_app`], answering `ui.dialog.confirm` with the error `refusal` when one is given. fn fake_app_refusing_confirm(refusal: Option<&'static str>) -> (String, std::thread::JoinHandle>) { + fake_app_with(refusal, true) +} + +/// [`fake_app_refusing_confirm`]. With `answers_in_set`, `ui.dialog.set {action}` also confirms or +/// cancels the dialog and replies `{dialog, result}`, as the app does; without it the set ignores +/// `action`, as an app that predates it does. +fn fake_app_with(refusal: Option<&'static str>, answers_in_set: bool) -> (String, std::thread::JoinHandle>) { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let h = std::thread::spawn(move || { @@ -738,6 +745,17 @@ fn fake_app_refusing_confirm(refusal: Option<&'static str>) -> (String, std::thr "engine.execute" => json!({"ok": true, "result": {"echo": msg["params"]}}), "ui.inspect" => json!({"ok": true, "result": {"tool": "selection"}}), "ui.dialog.set" if !dialog_open => json!({"ok": false, "error": "no dialog open"}), + "ui.dialog.set" if answers_in_set && msg["params"].get("action").is_some() => { + let dialog = json!({"echo": {"fields": msg["params"]["fields"]}}); + match msg["params"]["action"].as_str() { + Some("confirm") if refusal.is_some() => json!({"ok": false, "error": refusal}), + Some("confirm") => json!({"ok": true, "result": {"dialog": dialog, "result": {"echo": {}}}}), + _ => { + dialog_open = false; + json!({"ok": true, "result": {"dialog": dialog, "result": null}}) + } + } + } "ui.dialog.confirm" if refusal.is_some() => json!({"ok": false, "error": refusal}), m if m.starts_with("ui.dialog.") => { dialog_open &= m != "ui.dialog.cancel"; @@ -775,8 +793,10 @@ fn remote_forwards_methods() { assert_eq!(seen[1]["params"], json!({"command": "object.group", "params": {}})); } +/// Fields and `action` go to the app in one `ui.dialog.set`, so the dialog the fields are set in is +/// the one confirmed or canceled. #[test] -fn answer_dialog_sets_fields_in_order_then_confirms() { +fn answer_dialog_sets_fields_in_order_and_confirms_in_one_request() { let (addr, h) = fake_app(); let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap())); let set = json!([{"field": "discard", "value": true}, {"field": "note"}]); @@ -785,7 +805,11 @@ fn answer_dialog_sets_fields_in_order_then_confirms() { let reply: Value = serde_json::from_str(&text_of(&r)).unwrap(); let fields = json!([{"field": "discard", "value": true}, {"field": "note", "value": null}]); assert_eq!(reply, json!({"dialog": {"echo": {"fields": fields}}, "result": {"echo": {}}})); - let r = call(&mut s, 2, "answer_dialog", json!({"action": "cancel"})); + let r = call(&mut s, 2, "answer_dialog", json!({"set": set})); + assert_eq!(r["isError"], false, "{r}"); + let reply: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert_eq!(reply, json!({"dialog": {"echo": {"fields": fields}}, "result": null})); + let r = call(&mut s, 3, "answer_dialog", json!({"action": "cancel"})); assert_eq!(r["isError"], false, "{r}"); // Bad arguments are refused before anything reaches the app. for (i, args) in [ @@ -807,8 +831,26 @@ fn answer_dialog_sets_fields_in_order_then_confirms() { drop(s); let seen = h.join().unwrap(); let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect(); - assert_eq!(methods, ["ui.dialog.set", "ui.dialog.confirm", "ui.dialog.cancel"]); - assert_eq!(seen[0]["params"], json!({"fields": fields})); + assert_eq!(methods, ["ui.dialog.set", "ui.dialog.set", "ui.dialog.cancel"]); + assert_eq!(seen[0]["params"], json!({"fields": fields, "action": "confirm"})); + assert_eq!(seen[1]["params"], json!({"fields": fields})); +} + +/// An app whose `ui.dialog.set` predates `action` replies with the dialog alone; the dialog is +/// then confirmed with its own `ui.dialog.confirm`. +#[test] +fn answer_dialog_confirms_separately_when_the_app_set_did_not_answer() { + let (addr, h) = fake_app_with(None, false); + let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap())); + let set = json!([{"field": "copy", "value": true}]); + let r = call(&mut s, 1, "answer_dialog", json!({"set": set, "action": "confirm"})); + assert_eq!(r["isError"], false, "{r}"); + let reply: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert_eq!(reply, json!({"dialog": {"echo": {"fields": set, "action": "confirm"}}, "result": {"echo": {}}})); + drop(s); + let seen = h.join().unwrap(); + let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect(); + assert_eq!(methods, ["ui.dialog.set", "ui.dialog.confirm"]); } /// When the app refuses the `ui.dialog.set`, the call is an error and nothing is confirmed. @@ -826,7 +868,7 @@ fn answer_dialog_does_not_confirm_when_the_app_refuses_the_set() { let seen = h.join().unwrap(); let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect(); assert_eq!(methods, ["ui.dialog.cancel", "ui.dialog.set"], "one set call, refused, and no confirm"); - assert_eq!(seen[1]["params"], json!({"fields": set})); + assert_eq!(seen[1]["params"], json!({"fields": set, "action": "confirm"})); } /// The app refuses to confirm a plug-in's install approval for automation: the call is an error @@ -843,7 +885,7 @@ fn answer_dialog_reports_that_the_app_refuses_to_approve_a_plugin_install() { drop(s); let seen = h.join().unwrap(); let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect(); - assert_eq!(methods, ["ui.dialog.set", "ui.dialog.confirm"]); + assert_eq!(methods, ["ui.dialog.set"]); } #[test] diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 8fe45b83e..27d92394f 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -307,7 +307,7 @@ pub fn tool_definitions() -> Vec { tool( "answer_dialog", "Answer dialog", - "Set fields of the desktop app's open dialog in order, then confirm (OK) or cancel it, as the control protocol's ui.dialog.set, ui.dialog.confirm and ui.dialog.cancel do. The app stores all the fields or none. 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. inspect_ui shows the open dialog and its fields (ui.dialog). After invoke_menu app.quit or file.close replies {\"pending\":\"saveChanges\"}: {action:\"confirm\"} saves, {set:[{field:\"discard\",value:true}],action:\"confirm\"} closes without saving, {action:\"cancel\"} cancels. Returns {dialog, result}: the dialog after the fields were set, and the reply to confirm or cancel. Desktop app only.", + "Set fields of the desktop app's open dialog in order, then confirm (OK) or cancel it, as the control protocol's ui.dialog.set, ui.dialog.confirm and ui.dialog.cancel do. The app stores all the fields or none and answers that same dialog in the same request (ui.dialog.set with action). 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. inspect_ui shows the open dialog and its fields (ui.dialog). After invoke_menu app.quit or file.close replies {\"pending\":\"saveChanges\"}: {action:\"confirm\"} saves, {set:[{field:\"discard\",value:true}],action:\"confirm\"} closes without saving, {action:\"cancel\"} cancels. Returns {dialog, result}: the dialog after the fields were set, and the reply to confirm or cancel. Desktop app only.", obj( json!({ "set": { @@ -316,7 +316,7 @@ pub fn tool_definitions() -> Vec { "items": obj(json!({"field": string("Dialog field"), "value": {"description": "The field's new value (any JSON; default null)"}}), &["field"]), "description": "Fields to set, in order (one ui.dialog.set that sets all of them or none)", }, - "action": {"type": "string", "enum": ["confirm", "cancel"], "description": "Then confirm (OK) or cancel the dialog"}, + "action": {"type": "string", "enum": ["confirm", "cancel"], "description": "Then confirm (OK) or cancel the dialog the fields were set in"}, }), &[], ), diff --git a/crates/mcp/src/tools/handlers.rs b/crates/mcp/src/tools/handlers.rs index ea2c6ec70..e76b5704a 100644 --- a/crates/mcp/src/tools/handlers.rs +++ b/crates/mcp/src/tools/handlers.rs @@ -334,9 +334,10 @@ pub(super) fn transform(b: &mut dyn Backend, a: &Args) -> Result /// Most fields one `answer_dialog` call may set. pub(super) const MAX_DIALOG_FIELDS: usize = 256; -/// Sets the open dialog's fields in order with one `ui.dialog.set {fields}`, which stores all of -/// them or none, then confirms or cancels it. Every argument is checked before anything is sent to -/// the app. +/// Sets the open dialog's fields in order with one `ui.dialog.set {fields, action?}`, which stores +/// all of them or none and then confirms or cancels that same dialog, so a dialog another +/// connection opens in between is not answered in its place. Every argument is checked before +/// anything is sent to the app. pub(super) fn answer_dialog(b: &mut dyn Backend, a: &Args) -> Result { let set = match a.get("set") { None => &[][..], @@ -355,21 +356,37 @@ pub(super) fn answer_dialog(b: &mut dyn Backend, a: &Args) -> Result None, - Some(v) if v == "confirm" => Some("ui.dialog.confirm"), - Some(v) if v == "cancel" => Some("ui.dialog.cancel"), + let (action, then) = match a.get("action") { + None => (None, None), + Some(v) if v == "confirm" => (Some("confirm"), Some("ui.dialog.confirm")), + Some(v) if v == "cancel" => (Some("cancel"), Some("ui.dialog.cancel")), Some(_) => return Err("`action` must be confirm or cancel".into()), }; if fields.is_empty() && then.is_none() { return Err("give `set` and/or `action`".into()); } - let dialog = if fields.is_empty() { Value::Null } else { b.call("ui.dialog.set", json!({"fields": fields}))? }; - let result = match then { - Some(method) => b.call(method, json!({}))?, - None => Value::Null, - }; - Ok(json!({"dialog": dialog, "result": result})) + if fields.is_empty() { + let result = match then { + Some(method) => b.call(method, json!({}))?, + None => Value::Null, + }; + return Ok(json!({"dialog": Value::Null, "result": result})); + } + let mut params = json!({"fields": fields}); + if let Some(action) = action { + params["action"] = json!(action); + } + let reply = b.call("ui.dialog.set", params)?; + match then { + None => Ok(json!({"dialog": reply, "result": Value::Null})), + // The app answered the dialog in the same request. + Some(_) if reply.get("dialog").is_some() => Ok(reply), + // An app that predates `action` replied with the dialog alone and did not answer it. + Some(method) => { + let result = b.call(method, json!({}))?; + Ok(json!({"dialog": reply, "result": result})) + } + } } pub(super) fn screenshot(b: &mut dyn Backend, a: &Args) -> Result { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 419d4ab90..7fd0d8edf 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -17,8 +17,9 @@ //! real egui pointer input in screen points (reaches every widget: panels, flyouts, dialogs) //! - `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`]) / -//! `ui.dialog.confirm` / `ui.dialog.cancel` +//! the dialog does not read is an error that lists its fields: [`crate::dialogs::accepts_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 //! comes back as `pngBase64` as well) //! - `ui.render {path?, scale?, artboard?, data?, traceViews?}`: render an artboard headlessly @@ -87,6 +88,22 @@ fn dialog_fields(p: &Value) -> Result, String> { .collect() } +/// What `ui.dialog.set {action}` does with the dialog once its fields are set. +enum DialogAnswer { + Confirm, + Cancel, +} + +/// The `action` of a `ui.dialog.set`: none, `confirm` or `cancel`. It is checked before any field is set. +fn dialog_action(p: &Value) -> Result, String> { + match p.get("action") { + None => Ok(None), + Some(v) if v == "confirm" => Ok(Some(DialogAnswer::Confirm)), + Some(v) if v == "cancel" => Ok(Some(DialogAnswer::Cancel)), + Some(_) => Err("`action` must be confirm or cancel".into()), + } +} + fn reserve_synthetic(app: &mut VectorcraftApp, additional: usize) -> Result<(), String> { if additional > MAX_SYNTHETIC_EVENTS.saturating_sub(app.synthetic.len()) { return Err(format!("synthetic input queue limit ({MAX_SYNTHETIC_EVENTS} events); retry after pending input drains")); @@ -401,6 +418,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques Ok(fields) => fields, Err(e) => return err(e), }; + let action = match dialog_action(p) { + Ok(action) => action, + Err(e) => return err(e), + }; // 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)) { let names = crate::dialogs::field_names(d).join(", "); @@ -419,7 +440,19 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques return err(e); } } - ok(app.ui.dialog.as_ref().and_then(|d| serde_json::to_value(d).ok()).unwrap_or_default()) + let dialog = app.ui.dialog.as_ref().and_then(|d| serde_json::to_value(d).ok()).unwrap_or_default(); + // `action` answers the dialog the fields were just set in, within this request. + match action { + None => ok(dialog), + Some(DialogAnswer::Confirm) => match crate::dialogs::confirm(app) { + Ok(result) => ok(json!({"dialog": dialog, "result": result})), + Err(e) => err(e), + }, + Some(DialogAnswer::Cancel) => { + crate::dialogs::cancel(app); + ok(json!({"dialog": dialog, "result": null})) + } + } } None => err("no dialog open"), }, diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 6d46e6c0f..0a9c042a1 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -254,6 +254,38 @@ fn dialog_set_refuses_a_field_list_over_the_limit() { assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields["dx"].clone()), Some(json!(last)), "the whole list is stored, in order"); } +/// `ui.dialog.set {fields, action}` confirms or cancels the dialog it set the fields in, within the +/// same request, and replies `{dialog, result}`. A bad `action` or a refused field sets nothing and +/// leaves the dialog open. +#[test] +fn dialog_set_with_an_action_answers_the_same_dialog() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let open = |app: &mut VectorcraftApp| app.ui.dialog = Some(crate::state::Dialog::new("note", json!({"text": "a"}))); + let text = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.fields["text"].clone()); + open(&mut app); + for params in [ + json!({"fields": [{"field": "text", "value": "b"}], "action": "ok"}), + json!({"fields": [{"field": "text", "value": "b"}], "action": true}), + json!({"fields": [{"field": "text", "value": "b"}], "action": null}), + json!({"fields": [{"field": "text", "value": "b"}, {"field": "nope", "value": 1}], "action": "confirm"}), + ] { + let r = input_request(&mut app, "ui.dialog.set", params.clone()); + assert_eq!(r["ok"], false, "{params}: {r}"); + assert_eq!(text(&app), Some(json!("a")), "{params}: the dialog stays open as it was"); + } + let r = input_request(&mut app, "ui.dialog.set", json!({"fields": [{"field": "text", "value": "b"}], "action": "confirm"})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"], json!({"dialog": {"kind": "note", "fields": {"text": "b"}}, "result": null})); + assert!(app.ui.dialog.is_none(), "confirmed in the same request"); + open(&mut app); + let r = input_request(&mut app, "ui.dialog.set", json!({"fields": [{"field": "text", "value": "c"}], "action": "cancel"})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"], json!({"dialog": {"kind": "note", "fields": {"text": "c"}}, "result": null})); + assert!(app.ui.dialog.is_none(), "canceled in the same request"); + let r = input_request(&mut app, "ui.dialog.set", json!({"fields": [], "action": "confirm"})); + assert_eq!(r["error"], "no dialog open"); +} + /// #1004: `ui.render` (the MCP `screenshot` of a connected app) renders the artboard asked /// for, at the scale asked for; the first without one. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4fa8de6da..02cc5c438 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}` or `{fields: [{field, value}…]}` | 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 | +| `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.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 58d235b4c..298676ce0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -460,7 +460,7 @@ opens the picker through `run_command` with the remote backend. | `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}` (a missing `value` is `null`). The app stores all of them or none. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and `action` is not sent. Then `action` `confirm` (OK, `ui.dialog.confirm`) or `cancel` (`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. `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. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `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`. | From bf09404c33cfab9ee26b4a7101ff781ea319ff14 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 14:56:13 -0800 Subject: [PATCH 07/21] Type: character formats, Ruby and Move Type Path Handle reject a param of the wrong type `text.setStyle` read `font`, `style` and `justify` with `str_param`, `size` with `len_param` and `tracking` with `num_param`, so a value of the wrong type was skipped: `{"size": 30, "style": ["Bold"]}` set the size and ignored the style, and `{"font": "Inter", "size": [12]}` set the font and ignored the size. `features` dropped an entry that was not a string and applied the rest. `text.setRangeStyle` skipped a `font` or `style` that was not a string, `type.ruby` skipped such a `text`, `position` or `align`, and `text.setPathHandle` moved the out handle for any `which` other than "in". Each of these now returns an error that names the param, before the command edits the document. Omitting a param or giving null keeps its default. `size` and `leading` still take a length with a unit ("12 pt", "5 mm"), and `tracking` still takes a number written as a string ("20"). - `text.setStyle`: `font`, `style`, `justify` (`text_param`), `size` and `tracking` (checked reads in `typecmd.rs`), `features`. - `text.setRangeStyle`: `font`, `style`, `features`. - `type.ruby`: `text`, `position`, `align`. - `text.setPathHandle`: `which` must be "in" or "out". Docs: docs/mcp.md. --- crates/engine/src/cmd/textedit.rs | 9 ++-- crates/engine/src/cmd/typecmd.rs | 39 +++++++++++---- crates/engine/src/tests/textedit.rs | 76 +++++++++++++++++++++++++++++ docs/mcp.md | 11 ++++- 4 files changed, 120 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index b6d20999a..c315c2a1c 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -12,7 +12,7 @@ use super::stroke::StrokeChange; use super::typecmd::refresh_bounds; use super::{ CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param, - index, number_or, point_param, str_param, switch_or, targets, + index, number_or, point_param, str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec { @@ -568,7 +568,8 @@ pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) { /// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults). pub(crate) fn features_param(p: &Value, cmd: &str) -> Result>> { let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) }; - let tags: Vec<&str> = v.as_array().ok_or_else(|| bad(cmd, "features must be a list of tags"))?.iter().filter_map(Value::as_str).collect(); + let not_tags = || bad(cmd, "features must be a list of tags"); + let tags: Vec<&str> = v.as_array().ok_or_else(not_tags)?.iter().map(|t| t.as_str().ok_or_else(not_tags)).collect::>()?; if let Some(t) = tags.iter().find(|t| !vectorcraft_text::OtFeatures::known_tag(t)) { return Err(bad( cmd, @@ -708,8 +709,8 @@ impl CharChange { stroke_opts.weight = Some(w.clamp(0.0, 1000.0)); } let c = Self { - font: str_param(p, "font").map(str::to_string), - style: str_param(p, "style").map(str::to_string), + font: text_param(p, "font", cmd)?.map(str::to_string), + style: text_param(p, "style", cmd)?.map(str::to_string), size: num("size"), leading: auto_or_num(p, "leading", cmd)?, tracking: num("tracking"), diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 2257b1a8a..42b35d33d 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -11,7 +11,7 @@ use super::edit::selected_roots; use super::pathops::{len_param, num_param, shape_node}; use super::{ CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok, - str_param, switch_or, targets, + str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec { @@ -249,16 +249,16 @@ fn ruby(s: &mut Session, p: &Value) -> Result { let ids = text_targets(s, p, C)?; let range = TextRange::parse(p, C)?; let remove = switch_or(p, "remove", false, C)?; - let reading = str_param(p, "text").map(str::trim).filter(|t| !t.is_empty()).map(str::to_string); + let reading = text_param(p, "text", C)?.map(str::trim).filter(|t| !t.is_empty()).map(str::to_string); if !remove && reading.is_none() { return Err(bad(C, "give `text` (the reading) or `remove`")); } - let position = match str_param(p, "position").map(str::to_ascii_lowercase).as_deref() { + let position = match text_param(p, "position", C)?.map(str::to_ascii_lowercase).as_deref() { None | Some("above") | Some("right") => RubyPosition::Above, Some("below") | Some("left") => RubyPosition::Below, Some(_) => return Err(bad(C, "`position` is above or below")), }; - let align = match str_param(p, "align").map(str::to_ascii_lowercase).as_deref() { + let align = match text_param(p, "align", C)?.map(str::to_ascii_lowercase).as_deref() { None | Some("center") | Some("centre") => RubyAlign::Center, Some("start") | Some("left") | Some("top") => RubyAlign::Start, Some("end") | Some("right") | Some("bottom") => RubyAlign::End, @@ -810,11 +810,30 @@ pub(crate) fn justify_param(v: &str) -> Option { }) } +/// The number `p` gives `cmd` for `key`, as [`num_param`] reads it (`"20"` included), or `None` when +/// `p` leaves it out or gives null. Any other value, such as `true` or `"wide"`, is an error: +/// "`{key}` must be a number, not {v}". +fn number_given(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => num_param(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))), + } +} + +/// The distance `p` gives `cmd` for `key`, as [`len_param`] reads it (`"5 mm"` included), or `None` +/// when `p` leaves it out or gives null. Any other value is an error, as [`number_given`] gives it. +fn length_given(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => len_param(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))), + } +} + fn set_style(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setStyle"; - let font = str_param(p, "font").map(str::to_string); - let style = str_param(p, "style").map(str::to_string); - let size = len_param(p, "size"); + let font = text_param(p, "font", C)?.map(str::to_string); + let style = text_param(p, "style", C)?.map(str::to_string); + let size = length_given(p, "size", C)?; if size.is_some_and(|v| v <= 0.0) { return Err(bad(C, "size must be positive")); } @@ -823,8 +842,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result { Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None), Some(_) => Some(Some(len_param(p, "leading").ok_or_else(|| bad(C, "leading must be a number or \"auto\""))?.clamp(0.1, 5000.0))), }; - let tracking = num_param(p, "tracking"); - let justify = match str_param(p, "justify") { + let tracking = number_given(p, "tracking", C)?; + let justify = match text_param(p, "justify", C)? { Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be auto|left|center|right|justifyAll"))?), None => None, }; @@ -1282,7 +1301,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?; - let out = str_param(p, "which") != Some("in"); + let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in"); let pos = vectorcraft_geom::Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); let independent = switch_or(p, "independent", false, C)?; let node = s.doc()?.doc.node(id); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 885ce09e1..425d545df 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -467,6 +467,82 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() { assert!(!t.ruby.is_empty() && t.ruby.iter().all(|r| !r.overhang && r.size_percent == 50.0), "{:?}", t.ruby); } +/// The Character commands, Ruby and Move Type Path Handle refuse a param of the wrong type with an +/// error naming the param, also when another param would change something, and leave the +/// document, its revision, the selection and the undo history as they were. Null counts as a param +/// left out, which keeps its default, and `size` and `leading` take a length with a unit. +#[test] +fn character_ruby_and_path_handle_refuse_a_param_of_the_wrong_type() { + use crate::tests::{history, not_a_number, select_with_a_step_to_redo}; + let mut s = session(); + let id = text(&mut s, "abc"); + let path = s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 300, "in": [50, 300], "out": [150, 300]}, {"x": 400, "y": 300}]})); + let on_path = s.execute("text.createInPath", &json!({"path": path.unwrap()["id"], "mode": "onPath", "text": "Along"})).unwrap()["id"].clone(); + select_with_a_step_to_redo(&mut s, &[id]); + let string = |k: &str| format!("`{k}` must be a string"); + let tags = "features must be a list of tags".to_string(); + let which = "`which` must be one of in, out".to_string(); + let handle = |which: Value| json!({"id": on_path, "anchor": 0, "which": which, "x": 100, "y": 250}); + let mut calls = vec![ + ("text.setStyle", json!({"font": 5}), string("font")), + ("text.setStyle", json!({"style": true}), string("style")), + ("text.setStyle", json!({"size": 30, "style": ["Bold"]}), string("style")), + ("text.setStyle", json!({"justify": 1}), string("justify")), + ("text.setStyle", json!({"size": 30, "justify": {"value": "center"}}), string("justify")), + ("text.setStyle", json!({"features": ["dlig", 5]}), tags.clone()), + ("text.setStyle", json!({"leading": true}), "leading must be a number or \"auto\"".to_string()), + ("text.setRangeStyle", json!({"id": id.0, "font": 5}), string("font")), + ("text.setRangeStyle", json!({"id": id.0, "size": 30, "style": false}), string("style")), + ("text.setRangeStyle", json!({"id": id.0, "features": [null]}), tags), + ("type.ruby", json!({"text": 5}), string("text")), + ("type.ruby", json!({"remove": true, "text": ["a"]}), string("text")), + ("type.ruby", json!({"text": "a", "position": 1}), string("position")), + ("type.ruby", json!({"text": "a", "align": true}), string("align")), + ("text.setPathHandle", handle(json!(1)), which.clone()), + ("text.setPathHandle", handle(json!("up")), which), + ]; + calls.extend( + [ + ("text.setStyle", json!({"size": true}), "size"), + ("text.setStyle", json!({"size": "big"}), "size"), + ("text.setStyle", json!({"font": "Inter", "size": [12]}), "size"), + ("text.setStyle", json!({"tracking": "wide"}), "tracking"), + ("text.setStyle", json!({"size": 30, "tracking": false}), "tracking"), + ] + .map(|(cmd, p, key)| { + let (_, p, msg) = not_a_number(cmd, p, key); + (cmd, p, msg) + }), + ); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.doc.clone(), st.revision, st.selection.objects.clone(), history(s)) + }; + let before = state(&s); + let wrong: Vec = calls + .iter() + .filter_map(|(cmd, p, want)| { + let r = s.execute(cmd, p); + let refused = matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want); + let unchanged = state(&s) == before; + (!refused || !unchanged).then(|| format!("{cmd} {p}: {r:?}, unchanged: {unchanged}")) + }) + .collect(); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + // Null keeps each default; a size and a leading with a unit are lengths. + let r = json!({"font": null, "style": null, "justify": null, "tracking": null, "features": null, "leading": null, "size": "5 mm"}); + s.execute("text.setStyle", &r).unwrap(); + let st = obj(&s, id).runs[0].style.clone(); + assert!((st.size - 5.0 * 72.0 / 25.4).abs() < 1e-6 && st.tracking == 0.0 && st.leading.is_none(), "{st:?}"); + s.execute("text.setStyle", &json!({"size": "12 pt", "leading": "18 pt", "tracking": 50})).unwrap(); + let st = obj(&s, id).runs[0].style.clone(); + assert_eq!((st.size, st.leading, st.tracking), (12.0, Some(18.0), 50.0)); + s.execute("type.ruby", &json!({"text": "a", "position": null, "align": null})).unwrap(); + let ruby = obj(&s, id).ruby; + assert!(ruby.iter().all(|r| r.position == vectorcraft_doc::RubyPosition::Above && r.align == vectorcraft_doc::RubyAlign::Center), "{ruby:?}"); + s.execute("text.setPathHandle", &handle(Value::Null)).unwrap(); +} + /// Where each glyph of type object `id` is drawn from (its origin in font units), document space. fn glyph_pens(s: &Session, id: NodeId) -> Vec<(usize, Point)> { let t = obj(s, id); diff --git a/docs/mcp.md b/docs/mcp.md index 972731ccc..a0a7f20b4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1358,7 +1358,7 @@ naming the key, and change nothing: `radius` in `shape.rectangle`; `sides` and ` in `shape.flare`; `decay` and `segments` in `shape.spiral`; `rows` and `columns` in `shape.rectangularGrid`; `concentric` and `radial` in `shape.polarGrid`; `size` and the `width`, `height` and `fitMinPercent` of `area` in `text.create`; `size` and `fitMinPercent` in `text.createInPath`; `start`, `end`, `size`, `tracking`, -`baselineShift`, `hScale`, `vScale`, `rotation` and `strokeWidth` in `text.setRangeStyle`; `start` and `end` in +`baselineShift`, `hScale`, `vScale`, `rotation` and `strokeWidth` in `text.setRangeStyle`; `strokeWidth` in `text.setStyle`; `start` and `end` in `text.editRange` and `text.getRange`; `at` in `text.startList`; `start`, `hScale`, `vScale` and `rotation` in `text.touchType`; `start` in `text.glyphAlternates` and `text.setGlyphAlternate`; `by`, `start` and `end` in `type.step`; `by` in `type.size.increase` and `type.size.decrease`; `size` in `type.ruby`; `width`, `height` @@ -1370,6 +1370,15 @@ and `amount` in `color.harmony`; `angle` in `paint.editGradient` and in the `gra `width` and `tolerance` in `livePaint.strokeEdge`. A null is read as a key left out. A gradient stop that names a swatch refuses a `tint` that is not a number with `` stop 0: `tint` must be a number ``. +`text.setStyle` refuses a `size` that is neither a number nor a length with a unit (`"12 pt"`, `"5 mm"`) with +`` `size` must be a number, not true ``, and a `tracking` that is neither a number nor a number written as a string +(`"20"`) with `` `tracking` must be a number, not false ``. It and `text.setRangeStyle` refuse a `font` or `style` that is not a string +with `` `font` must be a string ``, and a `features` list with an entry that is not a string with +`features must be a list of tags`; `text.setStyle` refuses such a `justify` too. `type.ruby` refuses a `text`, +`position` or `align` that is not a string, and `text.setPathHandle` refuses a `which` other than `in` or `out` with +`` `which` must be one of in, out ``. Each leaves the document, the selection and the undo history as they were, also +when the call gives another param it could apply. A null is read as a key left out. + A byte offset must be a whole number of at least 0. `at` in `text.startList`, and `start` and `end` in `text.editRange`, `text.getRange` and `text.setRangeStyle`, and in `type.step` and `addon.type.textTweaker` given an `id`, refuse a negative or fractional number with From 7eb1006abbf504f6b46e13573a3611dcf7f30d81 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 15:04:50 -0800 Subject: [PATCH 08/21] Scissors: a cut leaves one of the two new end points selected After a Scissors cut (`path.split`) the whole path stayed selected: both pieces of an open path, or the opened path of a closed one. A Direct Selection drag from the cut then moved every anchor, so an ellipse cut at an anchor could not be pulled open by dragging its end. - `path.split` selects the first anchor of the piece after the cut and nothing else, as `path.cutAtAnchors` does: anchor 0 of an opened closed subpath, of the new path when an open path becomes two, or of the subpath added to a path of several subpaths. - A Direct Selection press on anchors of a selected path that lie on top of each other takes the selected one. It took the first in path order, which in a path of several subpaths was the unselected end. Docs: docs/mcp.md, docs/target-app-parity.md, the `path.split` params text. --- crates/engine/src/cmd/draw2/commands.rs | 2 +- crates/engine/src/cmd/draw2/cut.rs | 13 +++- crates/engine/src/tests/draw2.rs | 79 ++++++++++++++++++++++++- crates/tools/src/direct.rs | 17 ++++-- docs/mcp.md | 4 +- docs/target-app-parity.md | 1 + 6 files changed, 104 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 0358acddf..1d2f006f1 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -74,7 +74,7 @@ pub fn specs() -> Vec { "Split Path", [], None, - "{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}", + "{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two; one of the two new end points is left selected → {ids}", has_doc, split ), diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index aa49606cb..126fa57a1 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -200,9 +200,16 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result { _ => return Err(EngineError::Other("no such segment".into())), }; let mut pieces = sp.cut_at(&std::collections::BTreeSet::from([k])); + // The cut leaves one of its two coincident end points selected, as `path.cutAtAnchors` + // does, so a Direct Selection drag pulls the path apart there. + let pick = |sel: &mut vectorcraft_doc::Selection, id: NodeId, si: usize| { + sel.clear(); + sel.add(id); + sel.anchors.entry(id).or_default().insert((si, 0)); + }; if sp.closed { path.subpaths[si] = pieces.pop().ok_or_else(|| EngineError::Other("no such anchor".into()))?; - sel.set([id]); + pick(sel, id, si); return Ok(vec![id]); } let [left, right] = <[SubPath; 2]>::try_from(pieces).map_err(|_| EngineError::Other("cannot split an open path at its end point".into()))?; @@ -211,12 +218,12 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result { let node = sibling_with(d, id, PathData::single(right))?; let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; let nid = d.insert(parent, idx + 1, node)?; - sel.set([id, nid]); + pick(sel, nid, 0); Ok(vec![id, nid]) } else { path.subpaths[si] = left; path.subpaths.insert(si + 1, right); - sel.set([id]); + pick(sel, id, si + 1); Ok(vec![id]) } })?; diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index c0946a465..df3a5f066 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1058,11 +1058,88 @@ fn scissors_splits_open_path_in_two() { assert!(near(path(&s, id).subpaths[0].anchors[1].p, Point::new(150.0, 100.0))); assert!(near(ps[1].1.subpaths[0].anchors[0].p, Point::new(150.0, 100.0))); assert_eq!(ps[1].1.subpaths[0].anchors[1].p, Point::new(300.0, 100.0)); - assert_eq!(s.doc().unwrap().selection.objects.len(), 2); + // One of the two new end points is selected: a Direct Selection drag from the cut pulls the + // second piece's end away and leaves the first piece as it was. + assert_eq!(s.doc().unwrap().selection.objects, [ps[1].0]); + assert_eq!(selected_anchors(&s, ps[1].0), [(0, 0)]); + drag(&mut s, "directSelection", &[(150.0, 100.0), (150.0, 115.0), (150.0, 130.0)], Mods::default()); + assert_eq!(path(&s, id), ps[0].1); + let moved = anchor_points(&s, ps[1].0); + assert!(near(moved[0], Point::new(150.0, 130.0)) && moved[1] == Point::new(300.0, 100.0), "{moved:?}"); // Clicking an end point is refused without panicking. assert!(s.execute("path.split", &json!({"id": id.0, "subpath": 0, "anchor": 0})).is_err()); } +/// An ellipse cut once with the Scissors tool, at an anchor or along a segment, opens with its two +/// ends on top of each other and one of them selected. A short Direct Selection drag from there, +/// plain or with Shift, moves that end alone with its handles, in one undo step, so the open path +/// becomes one turn that spirals out; every other anchor stays where it was. +#[test] +fn scissors_cut_ellipse_end_drags_alone() { + let mods = [Mods::default(), Mods { shift: true, ..Mods::default() }]; + for at_anchor in [true, false] { + for m in mods { + let mut s = session(); + drag(&mut s, "ellipse", &[(100.0, 100.0), (200.0, 200.0), (300.0, 200.0)], Mods::default()); + let id = paths(&s)[0].0; + let ellipse = path(&s, id); + let cut = if at_anchor { + ellipse.subpaths[0].anchors[0].p + } else { + use kurbo::ParamCurve; + ellipse.subpaths[0].segment(1).eval(0.5) + }; + click(&mut s, "scissors", cut.x, cut.y); + let opened = path(&s, id); + let sp = &opened.subpaths[0]; + assert!(!sp.closed && near(sp.anchors[0].p, cut) && near(sp.anchors[sp.anchors.len() - 1].p, cut), "{sp:?}"); + assert_eq!(s.doc().unwrap().selection.objects, [id]); + assert_eq!(selected_anchors(&s, id), [(0, 0)]); + // Shift is pressed once the drag is under way: held at the press it toggles the anchor. + let v = view(); + s.select_tool("directSelection", v).unwrap(); + let undo = undo_steps(&s); + s.pointer(&PointerEvent::new(PointerKind::Down, cut.x, cut.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, cut.x + 4.0, cut.y + 4.0).with_mods(m), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, cut.x + 8.0, cut.y + 8.0).with_mods(m), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, cut.x + 8.0, cut.y + 8.0).with_mods(m), v).unwrap(); + assert_eq!(undo_steps(&s), undo + 1); + let after = path(&s, id); + let (old, new) = (&sp.anchors, &after.subpaths[0].anchors); + assert_eq!(new.len(), old.len()); + assert_eq!(new[1..], old[1..], "only the dragged end moves (at anchor: {at_anchor}, {m:?})"); + let d = Point::new(cut.x + 8.0, cut.y + 8.0) - cut; + assert!(near(new[0].p, old[0].p + d) && near(new[0].h_out, old[0].h_out + d) && near(new[0].h_in, new[0].p), "{:?}", new[0]); + } + } +} + +/// The Scissors tool on a path of several subpaths: the cut end stays selected in the subpath it +/// opened, and a second cut of that subpath selects the end of the new piece. +#[test] +fn scissors_cut_in_a_compound_path_selects_the_cut_end() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let square = + |x: f64| json!({"closed": true, "anchors": [{"x": x, "y": 100}, {"x": x + 100.0, "y": 100}, {"x": x + 100.0, "y": 200}, {"x": x, "y": 200}]}); + s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [square(100.0), square(300.0)]})).unwrap(); + click(&mut s, "scissors", 400.0, 100.0); + let pd = path(&s, id); + assert!(pd.subpaths[0].closed && !pd.subpaths[1].closed && pd.subpaths[1].anchors[0].p == Point::new(400.0, 100.0), "{pd:?}"); + assert_eq!(selected_anchors(&s, id), [(1, 0)]); + // Cut again at the opened subpath's corner (400, 200): its second piece follows it. + click(&mut s, "scissors", 400.0, 200.0); + let pd = path(&s, id); + assert_eq!(pd.subpaths.len(), 3); + assert_eq!(pd.subpaths[2].anchors[0].p, Point::new(400.0, 200.0)); + assert_eq!(selected_anchors(&s, id), [(2, 0)]); + drag(&mut s, "directSelection", &[(400.0, 200.0), (410.0, 210.0), (420.0, 220.0)], Mods::default()); + let after = path(&s, id); + assert_eq!(after.subpaths[2].anchors[0].p, Point::new(420.0, 220.0)); + assert_eq!(after.subpaths[1], pd.subpaths[1]); + assert_eq!(after.subpaths[0], pd.subpaths[0]); +} + #[test] fn knife_cuts_rect_into_two_pieces() { let mut s = session(); diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 1477cd5bb..292f1d25e 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -492,7 +492,7 @@ fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, us break; } if let Some(pd) = cx.doc.node(id).and_then(editable_path) - && let Some((si, ai, a)) = nearest_anchor(&pd, p, tol) + && let Some((si, ai, a)) = nearest_anchor(&pd, p, tol, cx.selection.partial(id)) { return Some((id, si, ai, a)); } @@ -524,7 +524,7 @@ fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, us return None; } let pd = cx.doc.node(id).and_then(editable_path)?; - nearest_anchor(&pd, p, tol).map(|(si, ai, a)| (id, si, ai, a)) + nearest_anchor(&pd, p, tol, None).map(|(si, ai, a)| (id, si, ai, a)) }) } @@ -535,9 +535,14 @@ fn over_path_type_anchor(cx: &ToolContext, p: Point) -> bool { hit_anchor(cx, p, cx.point_tol()).is_some_and(|(id, ..)| cx.doc.node(id).is_some_and(is_path_type)) } -/// The anchor of `pd` within `tol` of `p` nearest to it: (subpath, anchor, where it is). -fn nearest_anchor(pd: &PathData, p: Point, tol: f64) -> Option<(usize, usize, Point)> { - pd.anchors().map(|(si, ai, a)| (si, ai, a.p)).filter(|(.., a)| a.distance(p) <= tol).min_by(|x, y| x.2.distance(p).total_cmp(&y.2.distance(p))) +/// The anchor of `pd` within `tol` of `p` nearest to it: (subpath, anchor, where it is). Of anchors +/// on top of each other, such as the two ends a cut leaves, one in `picked` comes first. +fn nearest_anchor(pd: &PathData, p: Point, tol: f64, picked: Option<&BTreeSet>) -> Option<(usize, usize, Point)> { + let unpicked = |si: usize, ai: usize| !picked.is_some_and(|set| set.contains(&(si, ai))); + pd.anchors() + .map(|(si, ai, a)| (si, ai, a.p)) + .filter(|(.., a)| a.distance(p) <= tol) + .min_by(|x, y| x.2.distance(p).total_cmp(&y.2.distance(p)).then_with(|| unpicked(x.0, x.1).cmp(&unpicked(y.0, y.1)))) } /// An edge of an area type frame under `p`, topmost first: (text, the edge's two anchors). @@ -1145,7 +1150,7 @@ fn hovered_anchor_with_hit(cx: &ToolContext, p: Point, hit: Option<&Hit>) -> Opt let under = hit.map(|h| cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)).unwrap_or(h.leaf)); cx.selection.objects.iter().copied().filter(|id| in_scope(cx.doc, cx.isolation, *id)).chain(under).find_map(|id| { let path = cx.doc.node(id).and_then(editable_path)?; - nearest_anchor(&path, p, tol).map(|(.., a)| (id, a)) + nearest_anchor(&path, p, tol, None).map(|(.., a)| (id, a)) }) } diff --git a/docs/mcp.md b/docs/mcp.md index f8b92aa17..cd9ba2ea5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3574,7 +3574,9 @@ Selected End Points) joins the ends again; `path.join {ids: [a, b], ends: ["last (Average & Join, Cmd+Alt+Shift+J) moves two ends to their midpoint and joins them there, the joined anchor a corner or a smooth point, in one undo step: the two direct-selected anchors when they are open path ends, else the nearest ends of the two open paths in `ids` or the selection (both ends of one, closing it); anything else is refused. `path.convertAnchor {id, subpath?, anchor, to, x?, y?}` and -`path.split {id, subpath?, anchor}` do the same to one anchor (the Anchor Point and Scissors tools); the Pen with Alt +`path.split {id, subpath?, anchor}` do the same to one anchor (the Anchor Point and Scissors tools); `path.split`, +at an anchor or at `segment` and `t`, leaves the first anchor of the piece after the cut selected alone, and a Direct +Selection press on two anchors on top of each other takes the selected one; the Pen with Alt held over a selected path's handle, anchor or segment works as the Anchor Point tool. `press_key` Escape between two `pointer_gesture` calls of an Anchor Point drag cancels it: the path goes back and no undo step is added. `path.reshapeSegment {id, subpath?, segment, t, dx, dy, perpendicular?}` (a segment dragged with Direct Selection or the Anchor Point tool) moves the segment's diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index aafebdbfa..c1f8fa8c0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -921,6 +921,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur While a spiral is dragged out, Alt adds or removes whole winds of four segments and Ctrl/Cmd changes the decay. The spiral's inner end keeps the distance from the center it had on the first modified drag event, while the outer end follows the pointer. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list Alt-drag to add or subtract winds and Ctrl-drag to change the decay rate; [Draw spirals](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-spirals.html) defines four segments per full wind and Decay as the amount each wind decreases. An 80% spiral has 80% of its outer radius after four segments; newly drawn spirals with the same settings wind more broadly than in earlier VectorCraft builds. Rounding the fitted count to whole winds from the count on the first Alt-only drag event, the held inner radius, the ranges (2…1000 segments, 5…99.99 % decay), Ctrl/Cmd taking precedence when both are held, and the new values staying as Spiral tool options are our own rules. Switching back to Alt uses the current count as its new base while keeping the held inner end. Escape while an arc, spiral or grid is dragged out cancels it and puts back the options its keys, Alt and Ctrl/Cmd changed (slope, open or closed, base axis, segments, decay, dividers and skews), so the next drag and the tool's options start from the values before the canceled drag; a drag that is let go keeps them, and an option set with `tool.setOption` during the drag stays. This is VectorCraft's own rule. - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). + A Scissors cut (`path.split`) leaves one of the two new end points selected and the rest of the path unselected, as [Cut paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/split-paths.html) and the "Split a path" section of the [CS3 user guide](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) describe: the two end points lie on top of each other, and a Direct Selection drag from them moves the selected one with its handles and no other anchor. A closed path cut once stays one open path. While Alt is held, the Scissors tool works as the Add Anchor Point tool ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): a click on a segment adds an anchor point without cutting (`path.insertAnchor`), and the pointer shows the Add Anchor Point cursor. When Alt is let go, a click cuts again. Alt-drag with the Knife cuts in a straight line, and Shift+Alt-drag cuts at 45° or 90°, as [Cut objects using the Knife and Scissors tools](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) and [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) describe. Alt counts only when held at the press; the dashed line runs from the press point to the pointer, stays straight after Alt is let go, and release sends `path.knife` with those two points. Alt pressed after the press leaves the cut freehand. Shift+Alt keeps the endpoint at 45° steps from the horizontal and vertical, whatever the Constrain Angle. With Smart Guides on, a straight Knife cut snaps to anchor points and paths: [Cut objects using the Knife and Scissors tools](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) describes the Knife snapping to anchor points and paths while Smart Guides are on. Its ends snap as the ends of the Pencil's straight line do, to anchor points and path segments of art that is shown: the press, and the release of an Alt-drag. Shift+Alt steps 45° from the snapped press, and its release stays on that step. With Smart Guides off, or with Snap to Pixel or Snap to Grid on, the cut is not snapped. The dashed line and `path.knife` use the snapped points. From 81dc541d5d94ddab671506d0a0bab903221fc6a5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 15:08:03 -0800 Subject: [PATCH 09/21] Repeat, Pattern: the Repeat and pattern commands reject a param of the wrong type object.repeat.radial, object.repeat.grid, object.repeat.mirror and object.repeat.options, and object.pattern.make, object.pattern.edit, object.pattern.saveCopy, pattern.options and pattern.delete, read their params with helpers that fell back to the default for a value of the wrong type: "instances": "8" made 8 instances whatever the text said, "rows": "2" made 3 rows, "edit": "false" entered pattern editing mode, and a `name` that was not a string edited the selected object's pattern. Each such value is now an error naming the param, and the document, the selection and the undo history stay as they were. An omitted or null param keeps its default. - pattern.options refuses an `overlap` that is not an object, and an `overlap.h` other than left or right or an `overlap.v` other than top or bottom. - object.repeat.options refuses a `center` that is not [x, y]. - object.repeat.radial refuses a negative `radius`; it used the automatic radius before. - object.repeat.grid reads `rows` and `cols` as object.repeat.options does: a fraction is cut to a whole number, within 1 to 500. - object.repeat.options applies `brickOffset` given without `gridType` to a brick grid, and its params text lists the key. Docs: docs/mcp.md. --- crates/engine/src/cmd/patterncmds.rs | 89 +++++++++---- crates/engine/src/tests/pattern.rs | 181 +++++++++++++++++++++++++++ docs/mcp.md | 8 ++ 3 files changed, 255 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 55c4390c2..ef7820b71 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -15,13 +15,13 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; use vectorcraft_doc::pattern::{self, Overlap, PATTERN_EDIT_LAYER, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType, pattern_paint}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection}; -use vectorcraft_geom::Rect; +use vectorcraft_geom::{Point, Rect}; use super::edit::{outermost, selected_roots}; use super::pathops::Beside; use super::{ - CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, f64_or, has_doc, has_selection, ids_param, matrix_param, ok, point_param, - str_param, targets, + CommandSpec, EngineError, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, finite_numbers, has_doc, has_selection, ids_param, + matrix_param, ok, switch_or, targets, text_param, }; pub fn specs() -> Vec { @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "Options…", ["Object", "Repeat"], None, - "{instances?, radius?, reverseOverlap?, startAngle?, endAngle?, center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, flipRows?, flipCols? | angle?} set the options of the selected repeats", + "{instances?, radius?, reverseOverlap?, startAngle?, endAngle?, center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, brickOffset?, flipRows?, flipCols? | angle?} set the options of the selected repeats", has_repeat, repeat_options ), @@ -287,9 +287,12 @@ fn after_edit_mode(s: &mut Session, layer: Option) -> Result<()> { Ok(()) } +/// The tile type `p` gives `cmd` (`tileType`, `brickOffset`), else `current` with a given brick offset. +/// A `tileType` that is not a string, or a `brickOffset` that is not a number, is an error. fn tile_type_param(p: &Value, cmd: &str, current: TileType) -> Result { + check_numbers(p, &["brickOffset"], cmd)?; let off = p.get("brickOffset").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }); - match str_param(p, "tileType") { + match text_param(p, "tileType", cmd)? { Some(t) => TileType::parse(t, off).ok_or_else(|| bad(cmd, format!("unknown tileType `{t}` ({})", TileType::IDS.join("|")))), None => Ok(match (current, off) { (TileType::BrickByRow { .. }, Some(o)) => TileType::BrickByRow { offset: o }, @@ -311,12 +314,13 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "select the art for the pattern tile")); } let tt = tile_type_param(p, C, TileType::Grid)?; + check_numbers(p, &["width", "height"], C)?; let (w, h) = (p.get("width").and_then(Value::as_f64), p.get("height").and_then(Value::as_f64)); if w.is_some_and(|v| !(v.is_finite() && v > 0.0)) || h.is_some_and(|v| !(v.is_finite() && v > 0.0)) { return Err(bad(C, "width/height must be > 0")); } - let edit = bool_or(p, "edit", true); - let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + let edit = switch_or(p, "edit", true, C)?; + let wanted = text_param(p, "name", C)?.map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); let name = s.edit("Make Pattern", |d, sel| { let name = match &wanted { Some(n) => unique_pattern_name(d, n), @@ -343,7 +347,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result { fn pattern_edit(s: &mut Session, p: &Value) -> Result { const C: &str = "object.pattern.edit"; - let name = match str_param(p, "name") { + let name = match text_param(p, "name", C)? { Some(n) => n.to_string(), None => { // The selected object's fill (or stroke) pattern. @@ -419,7 +423,7 @@ fn pattern_cancel(s: &mut Session, _: &Value) -> Result { } fn pattern_save_copy(s: &mut Session, p: &Value) -> Result { - let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + let wanted = text_param(p, "name", "object.pattern.saveCopy")?.map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); let name = s.edit("Save a Copy", |d, _| { let pe = d.pattern_edit.clone().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?; let mut def = d.pattern(&pe.pattern).cloned().ok_or_else(|| EngineError::Other("pattern missing".into()))?; @@ -438,19 +442,21 @@ fn pattern_save_copy(s: &mut Session, p: &Value) -> Result { fn pattern_options(s: &mut Session, p: &Value) -> Result { const C: &str = "pattern.options"; - let name = match str_param(p, "name") { + let name = match text_param(p, "name", C)? { Some(n) => n.to_string(), None => editing(s).map(|e| e.pattern.clone()).ok_or_else(|| bad(C, "give `name` (or edit a pattern)"))?, }; let cur = s.doc()?.doc.pattern(&name).cloned().ok_or_else(|| bad(C, format!("no pattern `{name}`")))?; let tt = tile_type_param(p, C, cur.tile_type)?; + check_numbers(p, &["width", "height", "hSpacing", "vSpacing", "copies", "dimCopies"], C)?; + check_switches(p, &["sizeTileToArt", "showTileEdge", "showSwatchBounds"], C)?; let num = |k: &str| p.get(k).and_then(Value::as_f64); for k in ["width", "height"] { if num(k).is_some_and(|v| !(v.is_finite() && v > 0.0)) { return Err(bad(C, format!("{k} must be > 0"))); } } - let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string); + let new_name = text_param(p, "newName", C)?.map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string); if let Some(n) = &new_name { let d = &s.doc()?.doc; if d.pattern(n).is_some() { @@ -461,11 +467,15 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { } } let overlap = match p.get("overlap") { - Some(o) => Overlap { - right_in_front: str_param(o, "h").map_or(cur.overlap.right_in_front, |v| v == "right"), - bottom_in_front: str_param(o, "v").map_or(cur.overlap.bottom_in_front, |v| v == "bottom"), + None | Some(Value::Null) => cur.overlap, + Some(o) if o.is_object() => Overlap { + right_in_front: overlap_side(o, "h", ["left", "right"], cur.overlap.right_in_front, C)?, + bottom_in_front: overlap_side(o, "v", ["top", "bottom"], cur.overlap.bottom_in_front, C)?, }, - None => cur.overlap, + Some(got) => { + let (path, expected) = ("overlap", "an object"); + return Err(bad(C, format!("`{path}` must be {expected}, not {got}"))); + } }; let edit_layer = editing(s).filter(|e| e.pattern == name).map(|e| e.layer); let out_name = new_name.clone().unwrap_or(name.clone()); @@ -514,6 +524,19 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { Ok(json!({ "name": out_name })) } +/// Side `side` of the `overlap` object `o` gives `cmd`: true for the second of `names`, false for the +/// first, `current` when `o` leaves it out or gives null. Any other value is an error. +fn overlap_side(o: &Value, side: &str, names: [&str; 2], current: bool, cmd: &str) -> Result { + match o.get(side) { + None | Some(Value::Null) => Ok(current), + Some(Value::String(v)) if names.contains(&v.as_str()) => Ok(v == names[1]), + Some(_) => { + let key = format!("overlap.{side}"); + Err(bad(cmd, format!("`{key}` must be one of {}", names.join(", ")))) + } + } +} + /// Is `new` taken for pattern `old` by a swatch or a color group (they share one namespace, /// [`Document::swatch_name_taken`])? It is free when the swatches named `new` are one or more /// swatches of the pattern and none of the pattern's swatches is named `old` (those take the new @@ -571,7 +594,7 @@ fn nodes_using(d: &Document, name: Option<&str>) -> Vec { fn pattern_delete(s: &mut Session, p: &Value) -> Result { const C: &str = "pattern.delete"; - let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?.to_string(); + let name = text_param(p, "name", C)?.ok_or_else(|| bad(C, "missing name"))?.to_string(); if s.doc()?.doc.pattern(&name).is_none() { return Err(bad(C, format!("no pattern `{name}`"))); } @@ -694,18 +717,25 @@ fn make_repeat(s: &mut Session, p: &Value, cmd: &str, label: &str, make: impl Fn } fn repeat_radial(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.repeat.radial"; + check_numbers(p, &["instances", "radius"], C)?; let n = f64_or(p, "instances", 8.0); if !(1.0..=1000.0).contains(&n) { - return Err(bad("object.repeat.radial", "instances must be 1..1000")); + return Err(bad(C, "instances must be 1..1000")); } - let radius = p.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r >= 0.0); - make_repeat(s, p, "object.repeat.radial", "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius)) + let radius = p.get("radius").and_then(Value::as_f64); + if radius.is_some_and(|r| r < 0.0) { + let key = "radius"; + return Err(bad(C, format!("`{key}` must be a number not below 0"))); + } + make_repeat(s, p, C, "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius)) } fn repeat_grid(s: &mut Session, p: &Value) -> Result { + check_numbers(p, &["hSpacing", "vSpacing", "rows", "cols"], "object.repeat.grid")?; let (hs, vs) = (p.get("hSpacing").and_then(Value::as_f64), p.get("vSpacing").and_then(Value::as_f64)); - let rows = p.get("rows").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32); - let cols = p.get("cols").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32); + let rows = p.get("rows").and_then(Value::as_f64).map(|v| (v as u32).clamp(1, 500)); + let cols = p.get("cols").and_then(Value::as_f64).map(|v| (v as u32).clamp(1, 500)); make_repeat(s, p, "object.repeat.grid", "Grid Repeat", |src| { let b = vectorcraft_doc::live::nodes_bounds(&src).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0)); let mut r = RepeatSpec::grid(src, hs.unwrap_or(b.width() / 4.0), vs.unwrap_or(b.height() / 4.0)); @@ -718,6 +748,7 @@ fn repeat_grid(s: &mut Session, p: &Value) -> Result { } fn repeat_mirror(s: &mut Session, p: &Value) -> Result { + check_numbers(p, &["angle", "offset"], "object.repeat.mirror")?; let angle = f64_or(p, "angle", 90.0); let offset = f64_or(p, "offset", 10.0); make_repeat(s, p, "object.repeat.mirror", "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset)) @@ -803,16 +834,26 @@ pub(crate) fn expand_repeats(d: &mut Document, roots: &[NodeId]) -> Result Result { const C: &str = "object.repeat.options"; let reps = selected_repeats(s); + let numbers = ["instances", "radius", "startAngle", "endAngle", "hSpacing", "vSpacing", "rows", "cols", "brickOffset", "angle"]; + check_numbers(p, &numbers, C)?; + check_switches(p, &["reverseOverlap", "flipRows", "flipCols"], C)?; let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); let flag = |k: &str| p.get(k).and_then(Value::as_bool); if num("instances").is_some_and(|n| !(1.0..=1000.0).contains(&n)) { return Err(bad(C, "instances must be 1..1000")); } - let grid_type = match str_param(p, "gridType") { + let grid_type = match text_param(p, "gridType", C)? { Some(t) => Some(TileType::parse(t, num("brickOffset")).ok_or_else(|| bad(C, format!("unknown gridType `{t}`")))?), None => None, }; - let center = point_param(p, "center"); + let center = match p.get("center") { + None | Some(Value::Null) => None, + Some(v) => { + let key = "center"; + let [x, y] = finite_numbers::<2>(v).ok_or_else(|| bad(C, format!("{key} must be [x, y] with finite coordinates")))?; + Some(Point::new(x, y)) + } + }; s.edit("Repeat Options", |d, _| { for r in &reps { let Some(NodeKind::Repeat(spec)) = d.node_mut(*r).map(|n| &mut n.kind) else { continue }; @@ -852,6 +893,8 @@ fn repeat_options(s: &mut Session, p: &Value) -> Result { } if let Some(t) = grid_type { *gt = t; + } else if let (Some(v), TileType::BrickByRow { offset } | TileType::BrickByColumn { offset }) = (num("brickOffset"), &mut *gt) { + *offset = v; } if let Some(v) = flag("flipRows") { *flip_rows = v; diff --git a/crates/engine/src/tests/pattern.rs b/crates/engine/src/tests/pattern.rs index c36d84fd9..0b948242d 100644 --- a/crates/engine/src/tests/pattern.rs +++ b/crates/engine/src/tests/pattern.rs @@ -1112,3 +1112,184 @@ fn mirror_axis_hidden_controls_and_other_repeat_kinds_do_not_capture_drags() { assert_eq!(node(&s, id), before); } } + +/// The document, its revision, the undo and redo stacks and the selection: what a refused call +/// must leave as it was. +fn untouched(s: &Session) -> (std::sync::Arc, u64, (usize, usize, bool), Vec) { + let st = s.doc().unwrap(); + (st.doc.clone(), st.revision, crate::tests::history(s), st.selection.objects.to_vec()) +} + +/// Each call in `calls` is refused with the parameter error `want` and changes nothing. +fn refused(s: &mut Session, calls: Vec<(&str, Value, String)>) { + let before = untouched(s); + for (cmd, p, want) in calls { + let r = s.execute(cmd, &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}, want {want:?}"); + assert!(untouched(s) == before, "{cmd} {p}: nothing changes"); + } +} + +fn number(k: &str, v: Value) -> String { + format!("`{k}` must be a number, not {v}") +} + +fn switch(k: &str) -> String { + format!("`{k}` must be true or false") +} + +fn string(k: &str) -> String { + format!("`{k}` must be a string") +} + +/// Radial, Grid, Mirror and Repeat Options refuse a param of the wrong type with an error naming +/// it, and change nothing; a distance given as text with a unit is refused like any other string. +/// Null counts as a param left out, which keeps its default. +#[test] +fn repeat_commands_refuse_a_param_of_the_wrong_type() { + let mut s = session(); + let r = rect(&mut s, 100.0, 100.0, 60.0, 80.0); + sel(&mut s, &[r]); + refused( + &mut s, + vec![ + ("object.repeat.radial", json!({"instances": "8"}), number("instances", json!("8"))), + ("object.repeat.radial", json!({"radius": true}), number("radius", json!(true))), + ("object.repeat.radial", json!({"radius": "5 mm"}), number("radius", json!("5 mm"))), + ("object.repeat.radial", json!({"radius": -5}), "`radius` must be a number not below 0".into()), + ("object.repeat.grid", json!({"hSpacing": "5 mm"}), number("hSpacing", json!("5 mm"))), + ("object.repeat.grid", json!({"vSpacing": [1]}), number("vSpacing", json!([1]))), + ("object.repeat.grid", json!({"rows": "3"}), number("rows", json!("3"))), + ("object.repeat.grid", json!({"cols": false}), number("cols", json!(false))), + ("object.repeat.mirror", json!({"angle": "45"}), number("angle", json!("45"))), + ("object.repeat.mirror", json!({"offset": {}}), number("offset", json!({}))), + ], + ); + // Null params keep the defaults: each repeat matches the one made with `{}`. + for (cmd, nulls) in [ + ("object.repeat.radial", json!({"instances": null, "radius": null})), + ("object.repeat.grid", json!({"hSpacing": null, "vSpacing": null, "rows": null, "cols": null})), + ("object.repeat.mirror", json!({"angle": null, "offset": null})), + ] { + let made = |p: &Value| { + let mut s = session(); + let r = rect(&mut s, 100.0, 100.0, 60.0, 80.0); + sel(&mut s, &[r]); + let id = id_of(&s.execute(cmd, p).unwrap()); + node(&s, id) + }; + assert_eq!(made(&nulls), made(&json!({})), "{cmd}"); + } + // A grid's rows and cols take any number, as Repeat Options does: 2.5 rows are 2. + let id = id_of(&s.execute("object.repeat.grid", &json!({"rows": 2.5, "cols": 4})).unwrap()); + assert!(matches!(node(&s, id).kind, NodeKind::Repeat(ref r) if matches!(r.kind, RepeatKind::Grid { rows: 2, cols: 4, .. }))); + // `brickOffset` alone sets the offset of a brick grid, and leaves a plain grid as it is. + let grid_type = |s: &Session| match node(s, id).kind { + NodeKind::Repeat(r) => match r.kind { + RepeatKind::Grid { grid_type, .. } => grid_type, + other => panic!("not a grid: {other:?}"), + }, + other => panic!("not a repeat: {other:?}"), + }; + s.execute("object.repeat.options", &json!({"brickOffset": 0.25})).unwrap(); + assert_eq!(grid_type(&s), TileType::Grid); + s.execute("object.repeat.options", &json!({"gridType": "brickByRow"})).unwrap(); + s.execute("object.repeat.options", &json!({"brickOffset": 0.25})).unwrap(); + assert_eq!(grid_type(&s), TileType::BrickByRow { offset: 0.25 }); + + // Repeat Options checks every param, whichever kind the selected repeat is. + s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 50})).unwrap(); + let kind = |s: &Session| match node(s, id).kind { + NodeKind::Repeat(r) => r.kind, + other => panic!("not a repeat: {other:?}"), + }; + let radial = kind(&s); + refused( + &mut s, + vec![ + ("object.repeat.options", json!({"instances": "4"}), number("instances", json!("4"))), + ("object.repeat.options", json!({"radius": "50 pt"}), number("radius", json!("50 pt"))), + ("object.repeat.options", json!({"startAngle": true}), number("startAngle", json!(true))), + ("object.repeat.options", json!({"endAngle": "x"}), number("endAngle", json!("x"))), + ("object.repeat.options", json!({"hSpacing": "1"}), number("hSpacing", json!("1"))), + ("object.repeat.options", json!({"vSpacing": [1]}), number("vSpacing", json!([1]))), + ("object.repeat.options", json!({"rows": "2"}), number("rows", json!("2"))), + ("object.repeat.options", json!({"cols": [2]}), number("cols", json!([2]))), + ("object.repeat.options", json!({"brickOffset": "0.5"}), number("brickOffset", json!("0.5"))), + ("object.repeat.options", json!({"angle": "10"}), number("angle", json!("10"))), + ("object.repeat.options", json!({"reverseOverlap": "true"}), switch("reverseOverlap")), + ("object.repeat.options", json!({"flipRows": 1}), switch("flipRows")), + ("object.repeat.options", json!({"flipCols": "no"}), switch("flipCols")), + ("object.repeat.options", json!({"gridType": 3}), string("gridType")), + ("object.repeat.options", json!({"center": [1]}), "center must be [x, y] with finite coordinates".into()), + ("object.repeat.options", json!({"center": "0, 0"}), "center must be [x, y] with finite coordinates".into()), + ], + ); + let all_null = json!({"instances": null, "radius": null, "startAngle": null, "endAngle": null, "reverseOverlap": null, "center": null}); + s.execute("object.repeat.options", &all_null).unwrap(); + assert_eq!(kind(&s), radial, "null params keep the options"); + s.execute("object.repeat.options", &json!({"instances": 6, "reverseOverlap": true, "center": [10, 20]})).unwrap(); + assert!(matches!(kind(&s), RepeatKind::Radial { instances: 6, reverse_overlap: true, .. })); +} + +/// Make, Edit Pattern, Save a Copy, Pattern Options and Delete Pattern refuse a param of the wrong +/// type (and Pattern Options an `overlap` side it doesn't know) with an error naming it, and change +/// nothing. Null counts as a param left out, which keeps its default. +#[test] +fn pattern_commands_refuse_a_param_of_the_wrong_type() { + let mut s = session(); + let r = make_dots(&mut s); + sel(&mut s, &[r]); + let options = |p: Value| { + let mut p = p; + p["name"] = json!("Dots"); + p + }; + refused( + &mut s, + vec![ + ("object.pattern.make", json!({"name": 5}), string("name")), + ("object.pattern.make", json!({"tileType": 2}), string("tileType")), + ("object.pattern.make", json!({"brickOffset": "0.5"}), number("brickOffset", json!("0.5"))), + ("object.pattern.make", json!({"width": "20"}), number("width", json!("20"))), + ("object.pattern.make", json!({"height": true}), number("height", json!(true))), + ("object.pattern.make", json!({"edit": "false"}), switch("edit")), + ("object.pattern.edit", json!({"name": 5}), string("name")), + ("pattern.options", json!({"name": ["Dots"]}), string("name")), + ("pattern.options", options(json!({"newName": 5})), string("newName")), + ("pattern.options", options(json!({"tileType": []})), string("tileType")), + ("pattern.options", options(json!({"width": "20"})), number("width", json!("20"))), + ("pattern.options", options(json!({"hSpacing": "1"})), number("hSpacing", json!("1"))), + ("pattern.options", options(json!({"vSpacing": true})), number("vSpacing", json!(true))), + ("pattern.options", options(json!({"copies": "5"})), number("copies", json!("5"))), + ("pattern.options", options(json!({"dimCopies": "50"})), number("dimCopies", json!("50"))), + ("pattern.options", options(json!({"sizeTileToArt": "true"})), switch("sizeTileToArt")), + ("pattern.options", options(json!({"showTileEdge": 1})), switch("showTileEdge")), + ("pattern.options", options(json!({"showSwatchBounds": "no"})), switch("showSwatchBounds")), + ("pattern.options", options(json!({"overlap": "right"})), "`overlap` must be an object, not \"right\"".into()), + ("pattern.options", options(json!({"overlap": {"h": "center"}})), "`overlap.h` must be one of left, right".into()), + ("pattern.options", options(json!({"overlap": {"v": 1}})), "`overlap.v` must be one of top, bottom".into()), + ("pattern.delete", json!({"name": 5}), string("name")), + ], + ); + // Null params keep the defaults: a new pattern named "New Pattern 1", a grid tile the size of + // the art, edited. + let made = s + .execute("object.pattern.make", &json!({"name": null, "tileType": null, "brickOffset": null, "width": null, "height": null, "edit": null})) + .unwrap(); + assert_eq!(made["name"], "New Pattern 1"); + let def = doc(&s).pattern("New Pattern 1").unwrap().clone(); + assert_eq!(def.tile_type, TileType::Grid); + assert!((def.tile.width() - 10.0).abs() < 1e-6, "{:?}", def.tile); + assert_eq!(doc(&s).pattern_edit.as_ref().map(|e| e.pattern.as_str()), Some("New Pattern 1")); + // In editing mode: Save a Copy refuses a name that isn't a string, and Pattern Options with + // null params keeps the edited pattern's options. + refused(&mut s, vec![("object.pattern.saveCopy", json!({"name": 5}), string("name"))]); + s.execute("pattern.options", &json!({"name": null, "overlap": {"h": null, "v": null}, "copies": null, "sizeTileToArt": null})).unwrap(); + assert!(doc(&s).pattern("New Pattern 1").unwrap() == &def); + s.execute("pattern.options", &json!({"overlap": {"h": "right"}})).unwrap(); + assert!(doc(&s).pattern("New Pattern 1").unwrap().overlap.right_in_front); + assert_eq!(s.execute("object.pattern.saveCopy", &json!({"name": null})).unwrap()["name"], "New Pattern 1 copy"); + s.execute("object.pattern.done", &json!({})).unwrap(); + assert!(s.execute("pattern.delete", &json!({"name": null})).is_err(), "no name given"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 58d235b4c..4c49ca675 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3081,6 +3081,14 @@ the old name. A rename gives the new name to the pattern's swatches that had the old name keeps it. A blank name gives "New Pattern N" in `object.pattern.make` and "`` copy" in `object.pattern.saveCopy`, and `pattern.options` keeps the name. +The pattern commands (`object.pattern.make`, `object.pattern.edit`, `object.pattern.saveCopy`, `pattern.options`, +`pattern.delete`) and the Repeat commands (`object.repeat.radial`, `object.repeat.grid`, `object.repeat.mirror`, +`object.repeat.options`) reject a param of the wrong type, such as `"instances": "8"`, `"edit": "false"` or +`"tileType": 2`, and change nothing. `pattern.options` also rejects an `overlap` that is not an object, and an +`overlap.h` other than `left` or `right` or an `overlap.v` other than `top` or `bottom`. `object.repeat.options` rejects +a `center` that is not `[x, y]`, and `object.repeat.radial` rejects a negative `radius`. Omitting a param or giving null +keeps its default. + ```json {"name":"run_command","arguments":{"command":"pattern.options","params":{"showSwatchBounds":true}}} {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"patternTileEdgeColor","value":"#ff4f4f"}}} From 3487e1ad80abad0d0662d953dd556d162926eb9b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 15:17:59 -0800 Subject: [PATCH 10/21] Type: vertical-only settings keep horizontal ligatures whole Shaping split a cluster wherever two characters' styles differed, so setting Standard Vertical Roman Alignment or a tate-chu-yoko offset on the f of "fi" in horizontal type broke the ligature. Horizontal type does not read those settings, so they no longer split a cluster there. Vertical type still shapes such characters apart. --- crates/text/src/shape.rs | 22 +++++++++++++++++++-- crates/text/src/tests/ligature_carets.rs | 25 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index b913c7cc2..ae44ce433 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -796,6 +796,23 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: source.get(i).filter(|(range, _)| range.contains(&byte)).map(|_| span.start + i) } +/// Can one glyph draw characters in styles `a` and `b`? Only when the styles are equal, except that +/// horizontal type (`vertical` false) does not read Standard Vertical Roman Alignment or the +/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. +fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { + if a == b { + return true; + } + !vertical + && *a + == CharStyle { + standard_vertical_roman: a.standard_vertical_roman, + tcy_up_down: a.tcy_up_down, + tcy_left_right: a.tcy_left_right, + ..b.clone() + } +} + /// Do text runs in styles `a` and `b` shape alike, so that they are shaped as one piece of text? /// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the /// character style's name, leading, baseline shift, rotation, Character Alignment, Character @@ -1275,9 +1292,10 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe cluster_starts.dedup(); // A glyph is drawn in one style: where a cluster would take in characters of runs in different // styles, the runs are shaped apart, as runs that do not shape alike are. - // Except at harakat styled apart only in paint ([`crate::harakat`]). + // Except at harakat styled apart only in paint ([`crate::harakat`]), and, in horizontal type, where + // the runs differ only in settings that vertical type alone reads. let inside_clusters: Vec = boundaries() - .filter(|&b| cluster_starts.binary_search(&b).is_err() && run_at(b - 1).1 != run_at(b).1) + .filter(|&b| cluster_starts.binary_search(&b).is_err() && !drawn_alike(run_at(b - 1).1, run_at(b).1, feats.vertical)) .filter(|b| mark_boundaries.binary_search(b).is_err()) .collect(); if !inside_clusters.is_empty() { diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index f669b07f4..a93d1a9cd 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -629,6 +629,31 @@ fn an_upright_ligature_takes_its_caret_down_its_cell() { assert!((at(900) - 0.5).abs() < 1e-9, "{}", at(900)); } +/// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets, so +/// setting one of them on the f of "fi" keeps the ligature. Vertical type reads them per glyph, so +/// there the f is shaped apart from the i. +#[test] +fn vertical_only_settings_keep_a_horizontal_ligature_whole() { + let db = db_with(vertical_ligature_caret_font(280)); + let marks: [fn(&mut CharStyle); 3] = [|s| s.standard_vertical_roman = true, |s| s.tcy_up_down = 10.0, |s| s.tcy_left_right = 10.0]; + for (i, mark) in marks.into_iter().enumerate() { + for vertical in [false, true] { + let mut t = point("fi", VERTICAL_LIGATURE_CARET_FAMILY); + t.vertical = vertical; + let plain = t.runs[0].style.clone(); + let mut marked = plain.clone(); + mark(&mut marked); + t.runs = vec![ + vectorcraft_doc::TextRun { text: "f".into(), style: marked, inline: None }, + vectorcraft_doc::TextRun { text: "i".into(), style: plain, inline: None }, + ]; + let l = layout(&db, &t); + let joined = l.glyphs.iter().any(|g| g.len == 2); + assert_eq!(joined, !vertical, "setting {i}, vertical {vertical}: {:?}", l.glyphs); + } + } +} + /// On a path, an upright ligature in vertical type stands with its top toward the path's start, and /// its cell runs from 0.12 em before the pen to 0.88 em after it, with or without the font's vertical /// metrics. The inner stop lies at the middle of that cell, and the first character's selection ends From 6732dbab1e6d53bfddbe0bbdf82da5e728299f66 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 15:21:18 -0800 Subject: [PATCH 11/21] Preferences: Reset Preferences leaves the open document's units alone Reset Preferences put Points in Units > General, and OK sent it to prefs.set, which also set the open document's units as an undo step. OK after Reset now resets the General preference and leaves the document's units as they are. Choosing a General unit after Reset, in the dialog or with ui.dialog.set, still sets the document's units. prefs.set takes documentUnits (default true); false makes unitsGeneral set only the preference. Docs: params text, docs/mcp.md. Part of #6435 --- crates/engine/src/cmd/prefscmds.rs | 7 +- crates/engine/src/tests/units.rs | 13 ++++ crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/prefs_dialog.rs | 19 ++++- crates/ui-egui/src/prefs_dialog/tests.rs | 96 ++++++++++++++++++++++++ docs/mcp.md | 3 +- 6 files changed, 136 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index ef8b156b6..45b723e98 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -5,7 +5,7 @@ use serde_json::{Map, Value, json}; -use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, str_param}; +use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, str_param, switch_or}; use crate::Prefs; use crate::units::Measure; use vectorcraft_doc::{GridPrefs, Unit}; @@ -763,7 +763,7 @@ pub fn specs() -> Vec { "Set Preference", [], None, - "{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list; lengths in pt or strings with a unit; unitsGeneral also sets the active document's units, one undo step; the eyedropper group takes a partial object, as eyedropper.setOptions; a new recoveryFolder must be a folder that can be listed and written in, and a new pluginsFolder a folder that can be listed: otherwise an error, and nothing changes; an empty value is not checked)", + "{key, value} or {values: {key: value, …}, documentUnits?: bool=true} set preferences (validated; see prefs.list; lengths in pt or strings with a unit; unitsGeneral also sets the active document's units, one undo step, unless documentUnits is false; the eyedropper group takes a partial object, as eyedropper.setOptions; a new recoveryFolder must be a folder that can be listed and written in, and a new pluginsFolder a folder that can be listed: otherwise an error, and nothing changes; an empty value is not checked)", always, set ), @@ -789,6 +789,7 @@ fn set(s: &mut Session, p: &Value) -> Result { if values.is_empty() { return Err(bad("prefs.set", "give {key, value} or {values}")); } + let document_units = switch_or(p, "documentUnits", true, "prefs.set")?; let mut next = s.prefs.clone(); next.set_values(&values).map_err(|e| bad("prefs.set", e))?; if let Some(e) = s.prefs.keep_folders(&mut next).into_iter().next() { @@ -797,7 +798,7 @@ fn set(s: &mut Session, p: &Value) -> Result { s.prefs.check_folders(&values).map_err(EngineError::Other)?; s.apply_prefs(next); // Units ▸ General is also the open document's units (as Document Setup sets them). - if values.contains_key("unitsGeneral") && s.active().is_some() { + if document_units && values.contains_key("unitsGeneral") && s.active().is_some() { s.set_document_units(s.default_units())?; } let all = s.prefs.to_json(); diff --git a/crates/engine/src/tests/units.rs b/crates/engine/src/tests/units.rs index 1608c86c6..e29365ec9 100644 --- a/crates/engine/src/tests/units.rs +++ b/crates/engine/src/tests/units.rs @@ -36,6 +36,19 @@ fn preferences_general_sets_the_open_documents_units_as_one_undo_step() { assert_eq!(s.prefs.units_general, "millimeters", "undo is the document's; the preference stays"); } +#[test] +fn preferences_general_with_document_units_false_sets_only_the_preference() { + let mut s = session(); + s.execute("document.setUnits", &json!({"units": "Millimeters"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("prefs.set", &json!({"values": {"unitsGeneral": "inches"}, "documentUnits": false})).unwrap(); + assert_eq!((units(&s), s.prefs.units_general.as_str()), (Unit::Millimeters, "inches")); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + let e = s.execute("prefs.set", &json!({"key": "unitsGeneral", "value": "points", "documentUnits": "no"})).unwrap_err(); + assert!(e.to_string().contains("`documentUnits` must be true or false"), "{e}"); + assert_eq!((units(&s), s.prefs.units_general.as_str()), (Unit::Millimeters, "inches"), "nothing changes on error"); +} + #[test] fn new_documents_start_in_the_preference_and_document_setup_only_changes_the_document() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 38ccc908d..92c9d714c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -504,6 +504,9 @@ pub fn set_field(d: &mut Dialog, field: String, value: Value) { if d.kind == layer_options::KIND { layer_options::note_set(d, &field, &value); } + if d.kind == "preferences" { + crate::prefs_dialog::note_set(d, &field, &value); + } let dxf = (d.kind == dxf_options::KIND).then(|| field.clone()); d.fields.insert(field, value); if let Some(field) = dxf { diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index fad2d45e7..d3ac65c95 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -19,6 +19,9 @@ pub const UI_FIELDS: &[(&str, &str, &str)] = &[ ("__taskBar", "General", "Enable Contextual Task Bar"), ]; +/// The Units ▸ General value Reset Preferences put in the dialog, while it is still the field's value. +const RESET_UNITS: &str = "__resetUnits"; + /// Whether User Interface shows VectorCraft Title Bar on Every Desktop (`appTitleBar`): Linux and the /// BSDs, where the desktop may draw the window's frame. macOS and Windows never read it. const APP_TITLE_BAR_SHOWN: bool = cfg!(not(any(target_arch = "wasm32", target_os = "macos", target_os = "windows"))); @@ -194,12 +197,21 @@ fn found_paint(ui: &mut egui::Ui, d: &mut Dialog, mark: Option<(egui::layers::Sh } } +/// `field` of the Preferences dialog `d` is about to be `value` (a frame's edit or `ui.dialog.set`): +/// choosing another General unit after Reset Preferences makes OK set the document's units again. +pub(crate) fn note_set(d: &mut Dialog, field: &str, value: &Value) { + if field == "unitsGeneral" && d.fields.get(RESET_UNITS).is_some_and(|v| v != value) { + d.fields.remove(RESET_UNITS); + } +} + /// OK: validate and apply every preference in the dialog (nothing changes on error). pub fn confirm(app: &mut VectorcraftApp) -> Result { let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; let values: Map = d.fields.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect(); let touch_ws = values.get("touchWorkspace").and_then(Value::as_bool); - app.run("prefs.set", json!({ "values": values }))?; + let document_units = d.fields.get(RESET_UNITS).is_none_or(|v| values.get("unitsGeneral") != Some(v)); + app.run("prefs.set", json!({ "values": values, "documentUnits": document_units }))?; if touch_ws.is_some() { crate::workspaces::sync_touch_workspace(app, app.touch_screen); } @@ -418,6 +430,11 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { for (k, v) in Prefs::default().to_json().as_object().cloned().unwrap_or_default() { d.fields.insert(k, v); } + // OK resets the Units ▸ General preference and leaves the open document's units alone. + d.fields.insert(RESET_UNITS.into(), d.fields.get("unitsGeneral").cloned().unwrap_or(Value::Null)); + } + if let Some(v) = d.fields.get("unitsGeneral").cloned() { + note_set(&mut d, "unitsGeneral", &v); } // The setting a search went to stays highlighted until another category is shown. let found = d.str("__found"); diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 93b84f488..ccb7ce320 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -273,6 +273,102 @@ fn preferences_ok_changes_the_guide_lock_only_with_the_option() { assert!(!a.session.guides_locked(), "the option went off"); } +/// Click the Preferences dialog's button `label` with the pointer. +fn click_button(app: &mut VectorcraftApp, label: &str) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp, events: Vec| { + // A window takes a sizing frame and fades in: each frame is a quarter second on. + let time = ctx.input(|i| i.time) + 0.25; + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 900.0))); + let raw = egui::RawInput { screen_rect, events, time: Some(time), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + fn walk(s: &egui::Shape, v: &mut Vec<(String, egui::Rect)>) { + match s { + egui::Shape::Text(t) => v.push((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()))), + egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)), + _ => {} + } + } + let mut v = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); + v + }; + frame(app, vec![]); + let texts = frame(app, vec![]); + let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}")); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, vec![egui::Event::PointerMoved(at)]); + frame(app, vec![press(true)]); + frame(app, vec![press(false)]); +} + +/// Reset Preferences then OK resets the preferences, General included, and leaves the open +/// document's units, with no undo step in the document. +#[test] +fn reset_preferences_then_ok_leaves_the_documents_units() { + let mut a = app(); + a.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + a.run("document.setUnits", json!({"units": "Millimeters"})).unwrap(); + a.run("prefs.set", json!({"key": "keyboardIncrement", "value": 5})).unwrap(); + let undo = a.session.active().unwrap().history.undo.len(); + open(&mut a, Some("General")); + click_button(&mut a, "Reset Preferences"); + let d = a.ui.dialog.as_ref().expect("Reset leaves the dialog open"); + assert_eq!(d.fields["keyboardIncrement"], Prefs::default().to_json()["keyboardIncrement"], "the working copy holds the defaults"); + assert_eq!(d.fields["unitsGeneral"], json!("points")); + confirm(&mut a).unwrap(); + assert_eq!(a.session.prefs.keyboard_increment, Prefs::default().keyboard_increment); + assert_eq!(a.session.prefs.units_general, Prefs::default().units_general); + let st = a.session.active().unwrap(); + assert_eq!(st.doc.units, vectorcraft_doc::Unit::Millimeters); + assert_eq!(st.history.undo.len(), undo, "no undo step"); +} + +/// After Reset Preferences, choosing a General unit in the dialog still sets the document's units +/// on OK, Points included once another unit was chosen in between. With no document open, Reset +/// Preferences resets General to its default. +#[test] +fn reset_preferences_keeps_an_edit_of_general_units() { + let mut a = app(); + a.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + a.run("document.setUnits", json!({"units": "Millimeters"})).unwrap(); + open(&mut a, Some("General")); + click_button(&mut a, "Reset Preferences"); + a.ui.dialog.as_mut().unwrap().fields.insert("unitsGeneral".into(), json!("inches")); + confirm(&mut a).unwrap(); + assert_eq!(a.session.active().unwrap().doc.units, vectorcraft_doc::Unit::Inches); + assert_eq!(a.session.prefs.units_general, "inches"); + + // Between frames, as `ui.dialog.set` sets fields. + a.run("document.setUnits", json!({"units": "Millimeters"})).unwrap(); + open(&mut a, Some("General")); + click_button(&mut a, "Reset Preferences"); + for u in ["inches", "points"] { + crate::dialogs::set_field(a.ui.dialog.as_mut().unwrap(), "unitsGeneral".into(), json!(u)); + } + confirm(&mut a).unwrap(); + assert_eq!(a.session.active().unwrap().doc.units, vectorcraft_doc::Unit::Points); + + // Through a frame of the dialog. + a.run("document.setUnits", json!({"units": "Millimeters"})).unwrap(); + open(&mut a, Some("General")); + click_button(&mut a, "Reset Preferences"); + a.ui.dialog.as_mut().unwrap().fields.insert("unitsGeneral".into(), json!("inches")); + click_button(&mut a, "General"); + a.ui.dialog.as_mut().unwrap().fields.insert("unitsGeneral".into(), json!("points")); + confirm(&mut a).unwrap(); + assert_eq!(a.session.active().unwrap().doc.units, vectorcraft_doc::Unit::Points); + + let mut b = app(); + b.run("prefs.set", json!({"key": "unitsGeneral", "value": "Millimeters"})).unwrap(); + open(&mut b, Some("General")); + click_button(&mut b, "Reset Preferences"); + confirm(&mut b).unwrap(); + assert_eq!(b.session.prefs.units_general, Prefs::default().units_general); +} + #[test] fn old_pref_files_keep_their_brightness() { let mut a = app(); diff --git a/docs/mcp.md b/docs/mcp.md index 58d235b4c..3700a8f29 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4478,7 +4478,8 @@ four: General (rulers, positions and sizes, the Info panel, dialog distances, ca (weights, dashes), Type (font size, leading, baseline shift) and East Asian Type (no field uses it yet). General is the active document's units (`document.inspect` → `units`): `document.setUnits {units}` (Document Setup) sets it for that document, and `prefs.set {key: "unitsGeneral", value}` sets it for the active document too (one undo step) and is the units -`file.new` starts in when it gets no `units`. Stroke, Type and East Asian Type are the preferences `unitsStroke`, +`file.new` starts in when it gets no `units`. With `documentUnits: false`, `prefs.set` sets only the preference and +leaves the document's units as they are; Reset Preferences followed by OK in the Preferences dialog does this. Stroke, Type and East Asian Type are the preferences `unitsStroke`, `unitsType` and `unitsAsianType`. Only `unitsAsianType` also takes `q` (Q, `Q`) and `ha` (Ha, `Ha`), each a quarter of a millimetre (0.709 pt); the other preferences, `document.setUnits` and the rulers do not list them, and it is Points by default. A length typed with a unit From d65c762ab4b6e3698058cf82ac917410f56c2a70 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 23:23:39 +0000 Subject: [PATCH 12/21] sonar: MD5 in PDF encryption is the format's, not a choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records why rust:S4790 (`crates/pdf/src/encrypt.rs`) stays, like wordcraft#6187 did for SHA-1 in ODT. MD5 is the key derivation of the PDF standard security handler revisions 2-4 (ISO 32000-1 §7.6.3.3), which PDF 1.3-1.6 encryption uses. It's needed to read those files and to write the compatibility the user picks. The default, 256-bit AES (revision 6), hashes with SHA-2. --- sonar-project.properties | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sonar-project.properties b/sonar-project.properties index dc74b9bd0..287b7eb9c 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,2 +1,10 @@ sonar.projectKey=storytold_vectorcraft sonar.organization=storytold + +# S4790 (weak hash): MD5 is the key derivation of the PDF standard security handler revisions 2-4 +# (ISO 32000-1 §7.6.3.3), which PDF 1.3-1.6 files use. It is needed to read those files and to write +# the compatibility the user picks, not chosen for new security: the default is 256-bit AES +# (revision 6), which hashes with SHA-2. +sonar.issue.ignore.multicriteria=pdf_md5 +sonar.issue.ignore.multicriteria.pdf_md5.ruleKey=rust:S4790 +sonar.issue.ignore.multicriteria.pdf_md5.resourceKey=crates/pdf/src/encrypt.rs From 74450ca52bf77364606502a7f27c1fc2349f3e00 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 15:29:30 -0800 Subject: [PATCH 13/21] Actions: test Batch folder names that differ only in case or are empty Batch with Override Export compares output names without case, so `a.svg` and `sub/A.svg` get the Export for Screens folders `a` and `A-2`. A source whose name without its extension is empty (` .svg`) gets the folder `file`, and a later `file.svg` gets `file-2`. Two tests cover these cases. --- crates/engine/src/cmd/fileio/action_batch.rs | 59 ++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index caa08cfda..74d9185ea 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -1018,6 +1018,65 @@ mod tests { } } + /// Source file names that differ only in case (`a.svg` and `sub/A.svg`) get Export for Screens + /// folders of their own: names are compared without case, so the second folder is numbered, + /// since Windows and macOS would make `a` and `A` one folder. + #[test] + fn batch_override_export_numbers_folders_that_differ_only_in_case() { + let src = tmp("screens-case-src"); + std::fs::create_dir_all(src.join("sub")).unwrap(); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src.join("sub"), "A.svg", "#0f0"); + let dest = tmp("screens-case-out"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "includeSubdirs": true, + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["A-2", "a"], "{r}"); + for (folder, fill) in [("a", "#ff0000"), ("A-2", "#00ff00")] { + assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}"); + let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap(); + assert!(svg.contains(fill), "{folder}: {svg}"); + } + } + + /// A source file with nothing left of its name without its extension (` .svg`) gets the folder + /// `file`, numbered before a source file named `file.svg` takes it. + #[test] + fn batch_override_export_names_the_folder_of_an_empty_name_file() { + let src = tmp("screens-empty-src"); + write_svg(&src, " .svg", "#f00"); + write_svg(&src, "file.svg", "#0f0"); + let dest = tmp("screens-empty-out"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["file", "file-2"], "{r}"); + for (folder, fill) in [("file", "#ff0000"), ("file-2", "#00ff00")] { + assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}"); + let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap(); + assert!(svg.contains(fill), "{folder}: {svg}"); + } + } + /// A source file whose name without its extension ends in a period (`a..svg`) gets the folder /// name without it, numbered after the folder of `a.svg`: Windows drops the period from a folder /// name, so `a.` and `a` would be one folder there. From ab274489215e1d88e483d2bfe346c506c9e56eb9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 15:34:06 -0800 Subject: [PATCH 14/21] Docs: spot gradients with RGB or Lab stops keep the ink in a CMYK document Two PDF export passages in docs/target-app-parity.md said a gradient that mixes a spot color with RGB or Lab stops is always written without the spot ink, or as an image when Perceptual. A CMYK document, or an export that converts colors to a CMYK destination profile, writes those stops in CMYK first, so the gradient is a DeviceN shading that keeps the ink; an RGB destination profile keeps them in RGB. The passages now say so, as docs/mcp.md and the paint.editGradient params text already do. A new test checks the Classic case in an RGB document, a CMYK document, with a CMYK destination and with an RGB destination. --- crates/engine/src/tests/linked_stops.rs | 35 +++++++++++++++++++++++++ docs/target-app-parity.md | 4 +-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests/linked_stops.rs b/crates/engine/src/tests/linked_stops.rs index 951a9ec31..7f70d24ff 100644 --- a/crates/engine/src/tests/linked_stops.rs +++ b/crates/engine/src/tests/linked_stops.rs @@ -349,6 +349,41 @@ fn perceptual_spot_gradients_with_rgb_stops_in_a_cmyk_document_export_as_device_ assert!(r.warnings.iter().any(|w| w.contains("spot")), "RGB destination: {:?}", r.warnings); } +#[test] +fn classic_spot_gradients_with_rgb_stops_in_a_cmyk_document_export_as_device_n_shadings() { + let (mut s, _) = setup(); + run(&mut s, "paint.editGradient", json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": "#00ff00"}]})); + let (pdf, warnings) = pdf_of(&s); + assert!(!pdf.contains("/DeviceN") && warnings.iter().any(|w| w.contains("spot")), "an RGB document drops the ink: {warnings:?}"); + // A CMYK document, or a conversion to a CMYK destination, writes the RGB stop in CMYK: a + // DeviceN shading that keeps the ink. + let mut cmyk = doc(&s).clone(); + cmyk.color_mode = vectorcraft_doc::ColorMode::Cmyk; + let plain = vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }; + let mut converted = plain.clone(); + converted.settings.output = vectorcraft_pdf::OutputSettings { + conversion: vectorcraft_pdf::ColorConversion::Destination, + destination: vectorcraft_pdf::DOCUMENT_CMYK.into(), + ..Default::default() + }; + for (what, d, opts) in [("CMYK document", &cmyk, &plain), ("CMYK destination", doc(&s), &converted)] { + let r = vectorcraft_pdf::export_with_report(d, opts).unwrap(); + let pdf = String::from_utf8_lossy(&r.bytes); + assert!(pdf.contains("/ShadingType 2") && pdf.contains("/Ink]/DeviceCMYK"), "{what}: a DeviceN shading with Ink"); + assert!(!r.warnings.iter().any(|w| w.contains("spot")), "{what}: {:?}", r.warnings); + } + // A conversion to an RGB destination keeps the RGB stop in RGB, in a CMYK document too. + let mut to_rgb = plain.clone(); + to_rgb.settings.output = vectorcraft_pdf::OutputSettings { + conversion: vectorcraft_pdf::ColorConversion::Destination, + destination: vectorcraft_color::cms::SRGB.into(), + ..Default::default() + }; + let r = vectorcraft_pdf::export_with_report(&cmyk, &to_rgb).unwrap(); + assert!(!String::from_utf8_lossy(&r.bytes).contains("/DeviceN"), "RGB destination: no DeviceN shading"); + assert!(r.warnings.iter().any(|w| w.contains("spot")), "RGB destination: {:?}", r.warnings); +} + #[test] fn spot_gradients_drawn_as_images_warn() { let (mut s, _) = setup(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6bf45e9c8..7a74d9ea8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1346,7 +1346,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`). A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them. The stops of an axial or radial shading in a DeviceN space that mixes one spot ink with process plates (a spot ink over a constant cyan, for example) link to the ink's swatch at its tint and keep the process plates, and PDF and EPS (Level 3) export write those stops in a DeviceN space of the ink and those plates, so the spot plate is not lost (#3235, `import_color::a_device_n_gradient_of_a_spot_ink_over_a_process_plate_keeps_both_inks`). Solid fills that mix a spot ink with process plates, and stops that mix several spot inks, still import as RGB, with the spot inks kept as swatches. A `.ai` or EPS opened from its editing data keeps both swatches when a pattern's name, or a named color's, is also another swatch's: a pattern's swatch named as a default swatch, a named color or a palette color becomes "Name 2", and a named color named as a gradient or pattern swatch of the palette becomes "Name 2", with the fills, strokes and gradient stops linked to it following it (`ai::tests::a_pattern_swatch_named_as_another_swatch_is_numbered`, `ai::tests::a_color_named_as_a_palette_gradient_or_pattern_is_numbered`). - PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning. A Perceptual gradient of spot inks is written as a Separation or DeviceN shading of the stops on its curve; a dithered gradient fill, and a Perceptual gradient with RGB or Lab stops other than white or more than 4,096 stops on its curve, are written as images in process colors, with a warning. A Lab spot color in such a shading is named by the same Separation space, with the Lab alternate, as its flat fills (`engine/src/tests/linked_stops.rs`, `engine/src/tests/labspots.rs`). + PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning. A CMYK document, or an export that converts colors to a CMYK destination profile, writes RGB and Lab stops in CMYK first (unless the export converts colors to an RGB destination profile), so there such a gradient, Classic or Perceptual, is written as a DeviceN shading that keeps the spot ink. A Perceptual gradient of spot inks is written as a Separation or DeviceN shading of the stops on its curve; a dithered gradient fill, and a Perceptual gradient with RGB or Lab stops other than white or more than 4,096 stops on its curve, are written as images in process colors, with a warning. A Lab spot color in such a shading is named by the same Separation space, with the Lab alternate, as its flat fills (`engine/src/tests/linked_stops.rs`, `engine/src/tests/labspots.rs`). PDF export writes a spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4) as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients, colorized images and Lab alternates; Registration still prints on every plate (`pdf/src/tests/output.rs`). - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). Preferences › File Handling › Data Recovery › Folder has a Choose… button on the desktop that picks the folder Data Recovery keeps its copies in ([Illustrator reference, Set Data Recovery preferences](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf)); a picked folder that Data Recovery cannot create its folders in or list is refused with a status message, and the field keeps its value. OK applies the same check to a folder typed into the field (VectorCraft's own rule): when it fails, the dialog stays open, the status bar says why and no preference changes; an empty field (the default folder) and the folder already saved are not checked. Preferences › Plug-ins & Scratch Disks is where additional plug-ins folders are set ([Illustrator preferences help](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html)); its Additional Plug-ins Folder has a Choose… button on the desktop as well, VectorCraft's own, matching Data Recovery's, and VectorCraft's own rule refuses a picked or typed folder whose contents can't be listed. Additional Fonts Folder and Templates Folder stay typed only. @@ -1692,7 +1692,7 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi `pdf::tests::dithered_perceptual_gradients_export_as_images_with_noise_on_the_fill` and the Gradient panel's `the_method_menu_makes_the_gradient_perceptual`. Scores and hours unchanged. -PDF export writes a Perceptual linear or radial gradient whose stops are tints of one spot color (and paper white) as a Separation shading in that ink instead of an image, so the ink keeps its plate ([swatches](https://helpx.adobe.com/illustrator/using/using-creating-swatches.html); [separations](https://helpx.adobe.com/illustrator/using/printing-color-separations.html)). Its tints follow the curve's stops, each the tint with the OKLab lightness of the color there (VectorCraft's own rule). A Perceptual gradient that mixes spot colors with each other or with CMYK or gray stops is written as a DeviceN shading of its inks: at each stop on the curve, the inks of the two stops on either side are mixed at the weight where the two colors mixed in display RGB have the OKLab lightness of the color there, or at the stop's mix position between the two colors when that mix's lightness turns back on the way from one color to the other, as from red to blue (VectorCraft's own rule). Dithered fills, freeform gradients, Perceptual gradients that mix a spot color with RGB or Lab stops, and Perceptual gradients whose curve has more than 4,096 stops once expanded (VectorCraft's own limit) are still images, and the export warns that their spot colors are written in process colors. Tests: `color::gradient::tests::ink_tints_follow_the_perceptual_curve`, `ink_mixes_follow_the_perceptual_curve` and `ink_mixes_use_the_mix_position_when_the_lightness_turns_back`, `engine::tests::linked_stops::perceptual_spot_gradients_export_as_separation_shadings`, `perceptual_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings` and `spot_gradients_drawn_as_images_warn`. +PDF export writes a Perceptual linear or radial gradient whose stops are tints of one spot color (and paper white) as a Separation shading in that ink instead of an image, so the ink keeps its plate ([swatches](https://helpx.adobe.com/illustrator/using/using-creating-swatches.html); [separations](https://helpx.adobe.com/illustrator/using/printing-color-separations.html)). Its tints follow the curve's stops, each the tint with the OKLab lightness of the color there (VectorCraft's own rule). A Perceptual gradient that mixes spot colors with each other or with CMYK or gray stops is written as a DeviceN shading of its inks: at each stop on the curve, the inks of the two stops on either side are mixed at the weight where the two colors mixed in display RGB have the OKLab lightness of the color there, or at the stop's mix position between the two colors when that mix's lightness turns back on the way from one color to the other, as from red to blue (VectorCraft's own rule). Dithered fills, freeform gradients, Perceptual gradients that mix a spot color with RGB or Lab stops, and Perceptual gradients whose curve has more than 4,096 stops once expanded (VectorCraft's own limit) are still images, and the export warns that their spot colors are written in process colors. In a CMYK document, or an export that converts colors to a CMYK destination profile, RGB and Lab stops are written in CMYK, so a Perceptual gradient that mixes a spot color with them is a DeviceN shading; an export that converts colors to an RGB destination profile keeps them in RGB. Tests: `color::gradient::tests::ink_tints_follow_the_perceptual_curve`, `ink_mixes_follow_the_perceptual_curve` and `ink_mixes_use_the_mix_position_when_the_lightness_turns_back`, `engine::tests::linked_stops::perceptual_spot_gradients_export_as_separation_shadings`, `perceptual_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings`, `perceptual_spot_gradients_with_rgb_stops_in_a_cmyk_document_export_as_device_n_shadings` and `spot_gradients_drawn_as_images_warn`. In these Separation and DeviceN shadings, a white stop linked to a swatch that is not a spot color (a global White swatch, for example) is paper white, 0% of the ink, as an unlinked white stop is, so a gradient of one spot ink and such a stop stays a Separation shading; a stop linked to a white spot color stays a stop of that ink (VectorCraft's own rule). Test: `engine::tests::linked_stops::white_stops_linked_to_process_swatches_are_paper_in_spot_gradients`. From fc34817d776dd48b502fa075fa02299457740edb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 15:37:42 -0800 Subject: [PATCH 15/21] Perspective grid: docs say when Snap to Grid moves an edge onto a gridline docs/mcp.md and docs/target-app-parity.md now state that a move in perspective snaps an edge of the joint bounds only when it ends within a quarter cell of a gridline (PerspectiveGrid::snap_offset), and that a move perpendicular to the plane does not snap (perspective.move skips snap_fix when perpendicular is true). --- docs/mcp.md | 5 +++-- docs/target-app-parity.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index bc2048393..a5282ec96 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7477,8 +7477,9 @@ bounding-box handles scale it in plane space (`perspective.transform`; Shift kee centre), and `press_key` `5` during a drag switches the move to perpendicular to the plane (press again to switch back). Holding Shift during an in-plane move constrains each object's displacement to an axis or 45° diagonal of its own plane. `perspective.move` accepts `constrain: true` (default false) and adjusts Snap to Grid along the constrained -direction on the first object's plane and depth. With Snap to Grid, every object on the first object's plane moves -by the same plane offset, whatever its depth, and the edge of their joint bounds nearer a gridline lands on it. +direction on the first object's plane and depth. With Snap to Grid, an in-plane move moves every object on the first +object's plane by the same plane offset, whatever its depth. On each axis, the edge of their joint bounds nearer a +gridline lands on it when that edge ends within a quarter cell of the gridline. A perpendicular move does not snap. `press_key` Escape during a move, a resize or a plane widget drag cancels it and puts the objects and the grid back. The arrow keys nudge a selection in perspective by the keyboard increment (`perspective.nudge`; Shift ×10, Alt copies). Object › Transform › Transform Again repeats the last perspective move or scale in plane space. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 91d0a9854..8a4ebbc72 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1535,7 +1535,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur An Alt-drag of objects in perspective with the Perspective Selection tool, and an Alt-drag of a plane widget with that tool or the Perspective Grid tool, copy the objects on the first move, and the moves after it move those copies (#1429). With the Perspective Selection tool, Ctrl+Alt-drag (Command+Option-drag on macOS) also copies objects on their perspective plane; Alt/Option-drag continues to do so. With the Perspective Selection tool, holding Shift while moving or copying constrains each object's displacement to the nearest axis or 45° diagonal of its own plane; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. Snap to Grid follows the constrained line on the first object's plane and depth. - With Snap to Grid on, a move of objects on one perspective plane at different depths moves them all by the same offset on the plane, so they keep their spacing, and the edge of their joint bounds nearer a gridline lands on it. Regression: `snap_moves_objects_at_different_depths_by_one_plane_offset` in `crates/engine/src/tests/persp_select.rs`. + With Snap to Grid on, a move of objects on one perspective plane at different depths moves them all by the same offset on the plane, so they keep their spacing. On each axis, the edge of their joint bounds nearer a gridline lands on it when that edge ends within a quarter cell of the gridline. A move perpendicular to the plane does not snap. Regression: `snap_moves_objects_at_different_depths_by_one_plane_offset` in `crates/engine/src/tests/persp_select.rs`. When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. Laying out type keeps each font face's shaping plans, at most 32 per face, so type objects of the same font, script, direction, language and features share one plan: the layouts of a document of 5,000 short point type objects take 59 ms instead of 73 ms in a release build, and the glyphs, bounds and baselines do not change. Laying out type looks up the font face of each style run once and uses it for the run's glyphs and for the line metrics of a paragraph that starts in that run: the layouts of a document of 5,000 short point type objects take a median of 215 million CPU cycles instead of 236 million in a release build (11 alternating runs of each build on Windows 11, measured while other builds were running), and the glyphs, bounds and baselines do not change. From b71d5ebdfb3bc44121d292fbdd23fddfd098aaa8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 15:59:25 -0800 Subject: [PATCH 16/21] Symbols: separations, proofing and color conversion reach a dynamic instance's own colors visit_node_colors visits the fill and stroke paints a dynamic symbol instance keeps for objects of its art (symbol_overrides). Before, print separations, Separations Preview and document color conversion left those colors as they were, so an instance's own fill printed and previewed unseparated. --- crates/doc/src/inks.rs | 46 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/inks.rs b/crates/doc/src/inks.rs index 696b27413..e10c424ee 100644 --- a/crates/doc/src/inks.rs +++ b/crates/doc/src/inks.rs @@ -110,8 +110,9 @@ fn visit_paint(p: &mut Paint, f: &mut ColorVisitor) { } /// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, an -/// image's colorize color, not opacity masks) with its swatch link and tint. Mesh points have no -/// link: changes to it are dropped. +/// image's colorize color, the fills and strokes a dynamic symbol instance keeps for objects of its +/// art, not opacity masks) with its swatch link and tint. Mesh points have no link: changes to it +/// are dropped. pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) { for it in &mut n.appearance.items { match it { @@ -119,8 +120,13 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) { AppearanceItem::Stroke(l) => visit_paint(&mut l.paint, f), } } - if let Some(p) = n.extras.0.as_deref_mut().and_then(|e| e.colorize.as_deref_mut()) { - visit_paint(p, f); + if let Some(e) = n.extras.0.as_deref_mut() { + if let Some(p) = e.colorize.as_deref_mut() { + visit_paint(p, f); + } + for p in e.symbol_overrides.iter_mut().flat_map(|o| [&mut o.fill, &mut o.stroke]).flatten() { + visit_paint(p, f); + } } match &mut n.kind { NodeKind::Text(t) => { @@ -144,7 +150,8 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) { } /// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh -/// points) given its swatch link and tint, keeping the links. +/// points, a dynamic symbol instance's own fills and strokes) given its swatch link and tint, +/// keeping the links. pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Link) -> Color) { visit_node_colors(n, &mut |c, swatch, tint| *c = f(c, swatch.as_deref().map(|s| (s, *tint)))); } @@ -158,3 +165,32 @@ pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Link) - map_node_colors(Arc::make_mut(&mut s.art), f); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{NodeId, SymbolOverride}; + use vectorcraft_geom::Affine; + + #[test] + fn a_dynamic_instances_own_fills_and_strokes_are_visited() { + let red = Color::rgb(1.0, 0.0, 0.0); + let blue = Color::rgb(0.0, 0.0, 1.0); + let spot = Paint::Solid { color: red, swatch: Some("Spot".into()), tint: 0.5 }; + let mut inst = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::IDENTITY }); + inst.extras_mut().symbol_overrides = vec![ + SymbolOverride { path: vec![0], fill: Some(spot), stroke: Some(Paint::solid(red)), ..Default::default() }, + SymbolOverride { path: vec![1], opacity: Some(0.5), ..Default::default() }, + ]; + let mut links = vec![]; + map_node_colors(&mut inst, &mut |_, link| { + links.push(link.map(|(s, t)| (s.to_string(), t))); + blue + }); + assert_eq!(links, vec![Some(("Spot".to_string(), 0.5)), None], "each override paint is visited once, with its link"); + let o = &inst.extras().symbol_overrides; + assert_eq!(o[0].fill, Some(Paint::Solid { color: blue, swatch: Some("Spot".into()), tint: 0.5 }), "the link is kept"); + assert_eq!(o[0].stroke, Some(Paint::solid(blue))); + assert_eq!((o[1].fill.as_ref(), o[1].stroke.as_ref()), (None, None), "an override without paint gets none"); + } +} From ea612dbf19cb6b24b4881ac5b774298e071b548a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 16:00:07 -0800 Subject: [PATCH 17/21] Type: use pathops' number and length helpers; refuse a number with trailing text text.setStyle now reads `size` and `tracking` with `number_given` and `length_given` from pathops.rs instead of its own copies. Both helpers read a string only when all of it is a number, or for `length_given` a length with a unit (`"5 mm"`). Before, `"20abc"` read as 20, so `text.setStyle` applied a 20 pt size and Offset Path a 10 pt offset for `"10abc"`. Such a string is now refused with "`size` must be a number, not \"20abc\"" and the document is left as it was. --- crates/engine/src/cmd/pathops.rs | 30 ++++++++++++++++++++++------- crates/engine/src/cmd/typecmd.rs | 21 +------------------- crates/engine/src/tests/pathops.rs | 2 ++ crates/engine/src/tests/textedit.rs | 2 ++ 4 files changed, 28 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index a8306d25c..82de6fa93 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -221,25 +221,41 @@ pub(crate) fn len_param(p: &Value, key: &str) -> Option { } } -/// The number `p` gives `cmd` for `key`, as [`num_param`] reads it, or `None` when `p` leaves it out -/// or gives null. Any other value, such as `true` or `"wide"`, is an error: "`{key}` must be a -/// number, not {v}". +/// The number `p` gives `cmd` for `key`, or `None` when `p` leaves it out or gives null. A string +/// counts only when all of it is a number (`"20"`). Any other value, such as `true`, `"wide"` or +/// `"20abc"`, is an error: "`{key}` must be a number, not {v}". pub(crate) fn number_given(p: &Value, key: &str, cmd: &str) -> Result> { match p.get(key) { None | Some(Value::Null) => Ok(None), - Some(v) => num_param(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))), + Some(v) => match v { + Value::String(s) => whole_number(s), + _ => num_param(p, key), + } + .map(Some) + .ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))), } } -/// The distance `p` gives `cmd` for `key`, as [`len_param`] reads it (`"5 mm"` included), or `None` -/// when `p` leaves it out or gives null. Any other value is an error, as [`number_given`] gives it. +/// The distance `p` gives `cmd` for `key`, or `None` when `p` leaves it out or gives null. A string +/// counts only when all of it is a length that [`vectorcraft_doc::Unit::parse`] reads (`"5 mm"`) or a +/// number. Any other value, such as `"20abc"`, is an error, as [`number_given`] gives it. pub(crate) fn length_given(p: &Value, key: &str, cmd: &str) -> Result> { match p.get(key) { None | Some(Value::Null) => Ok(None), - Some(v) => len_param(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))), + Some(v) => match v { + Value::String(s) => vectorcraft_doc::Unit::Points.parse(s).filter(|v| v.is_finite()).or_else(|| whole_number(s)), + _ => num_param(p, key), + } + .map(Some) + .ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))), } } +/// `s` as a finite number when all of it, spaces around it aside, is one. +fn whole_number(s: &str) -> Option { + s.trim().parse::().ok().filter(|v| v.is_finite()) +} + fn ids_json(ids: &[NodeId]) -> Value { json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 42b35d33d..0ddd14594 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAl use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; -use super::pathops::{len_param, num_param, shape_node}; +use super::pathops::{len_param, length_given, number_given, shape_node}; use super::{ CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok, str_param, switch_or, targets, text_param, @@ -810,25 +810,6 @@ pub(crate) fn justify_param(v: &str) -> Option { }) } -/// The number `p` gives `cmd` for `key`, as [`num_param`] reads it (`"20"` included), or `None` when -/// `p` leaves it out or gives null. Any other value, such as `true` or `"wide"`, is an error: -/// "`{key}` must be a number, not {v}". -fn number_given(p: &Value, key: &str, cmd: &str) -> Result> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(v) => num_param(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))), - } -} - -/// The distance `p` gives `cmd` for `key`, as [`len_param`] reads it (`"5 mm"` included), or `None` -/// when `p` leaves it out or gives null. Any other value is an error, as [`number_given`] gives it. -fn length_given(p: &Value, key: &str, cmd: &str) -> Result> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(v) => len_param(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))), - } -} - fn set_style(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setStyle"; let font = text_param(p, "font", C)?.map(str::to_string); diff --git a/crates/engine/src/tests/pathops.rs b/crates/engine/src/tests/pathops.rs index 57094355c..72aa559ae 100644 --- a/crates/engine/src/tests/pathops.rs +++ b/crates/engine/src/tests/pathops.rs @@ -863,6 +863,8 @@ fn path_commands_refuse_a_param_of_the_wrong_type() { for (cmd, p, want) in [ ("object.path.offsetPath", json!({"offset": true}), number("offset", "true")), ("object.path.offsetPath", json!({"offset": "wide"}), number("offset", "\"wide\"")), + ("object.path.offsetPath", json!({"offset": "10abc"}), number("offset", "\"10abc\"")), + ("object.path.offsetPath", json!({"offset": 5, "miterLimit": "4x"}), number("miterLimit", "\"4x\"")), ("object.path.offsetPath", json!({"offset": 5, "joins": 3}), "`joins` must be a string".to_string()), ("object.path.offsetPath", json!({"offset": 5, "joins": "pointy"}), "`joins` must be one of miter, round, bevel".to_string()), ("object.path.offsetPath", json!({"offset": 5, "miterLimit": [4]}), number("miterLimit", "[4]")), diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 425d545df..e584e3e32 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -505,6 +505,8 @@ fn character_ruby_and_path_handle_refuse_a_param_of_the_wrong_type() { [ ("text.setStyle", json!({"size": true}), "size"), ("text.setStyle", json!({"size": "big"}), "size"), + ("text.setStyle", json!({"size": "20abc"}), "size"), + ("text.setStyle", json!({"tracking": "20abc"}), "tracking"), ("text.setStyle", json!({"font": "Inter", "size": [12]}), "size"), ("text.setStyle", json!({"tracking": "wide"}), "tracking"), ("text.setStyle", json!({"size": 30, "tracking": false}), "tracking"), From 17f149ae2b73625377dbd538b6e6579a9adcd969 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 16:00:25 -0800 Subject: [PATCH 18/21] Perspective grid: docs say which edge Snap to Grid lands on a constrained move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A constrained move keeps only the snap correction along its direction: a horizontal or vertical move lands the edge on its own axis, and a 45° move lands the edge on the axis whose nonzero correction is smaller. The per-axis rule now applies to unconstrained moves only. --- docs/mcp.md | 6 ++++-- docs/target-app-parity.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index a5282ec96..eb773f1f5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7478,8 +7478,10 @@ centre), and `press_key` `5` during a drag switches the move to perpendicular to back). Holding Shift during an in-plane move constrains each object's displacement to an axis or 45° diagonal of its own plane. `perspective.move` accepts `constrain: true` (default false) and adjusts Snap to Grid along the constrained direction on the first object's plane and depth. With Snap to Grid, an in-plane move moves every object on the first -object's plane by the same plane offset, whatever its depth. On each axis, the edge of their joint bounds nearer a -gridline lands on it when that edge ends within a quarter cell of the gridline. A perpendicular move does not snap. +object's plane by the same plane offset, whatever its depth. Without `constrain`, on each axis, the edge of their joint +bounds nearer a gridline lands on it when that edge ends within a quarter cell of the gridline. With `constrain`, a +horizontal or vertical move lands only the edge on its own axis, and a 45° move lands the edge on the axis whose nonzero +correction is smaller. A perpendicular move does not snap. `press_key` Escape during a move, a resize or a plane widget drag cancels it and puts the objects and the grid back. The arrow keys nudge a selection in perspective by the keyboard increment (`perspective.nudge`; Shift ×10, Alt copies). Object › Transform › Transform Again repeats the last perspective move or scale in plane space. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8a4ebbc72..c072c441e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1535,7 +1535,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur An Alt-drag of objects in perspective with the Perspective Selection tool, and an Alt-drag of a plane widget with that tool or the Perspective Grid tool, copy the objects on the first move, and the moves after it move those copies (#1429). With the Perspective Selection tool, Ctrl+Alt-drag (Command+Option-drag on macOS) also copies objects on their perspective plane; Alt/Option-drag continues to do so. With the Perspective Selection tool, holding Shift while moving or copying constrains each object's displacement to the nearest axis or 45° diagonal of its own plane; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. Snap to Grid follows the constrained line on the first object's plane and depth. - With Snap to Grid on, a move of objects on one perspective plane at different depths moves them all by the same offset on the plane, so they keep their spacing. On each axis, the edge of their joint bounds nearer a gridline lands on it when that edge ends within a quarter cell of the gridline. A move perpendicular to the plane does not snap. Regression: `snap_moves_objects_at_different_depths_by_one_plane_offset` in `crates/engine/src/tests/persp_select.rs`. + With Snap to Grid on, a move of objects on one perspective plane at different depths moves them all by the same offset on the plane, so they keep their spacing. Without Shift, on each axis, the edge of their joint bounds nearer a gridline lands on it when that edge ends within a quarter cell of the gridline. With Shift, a horizontal or vertical move lands only the edge on its own axis, and a 45° move lands the edge on the axis whose nonzero correction is smaller. A move perpendicular to the plane does not snap. Regression: `snap_moves_objects_at_different_depths_by_one_plane_offset` in `crates/engine/src/tests/persp_select.rs`. When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. Laying out type keeps each font face's shaping plans, at most 32 per face, so type objects of the same font, script, direction, language and features share one plan: the layouts of a document of 5,000 short point type objects take 59 ms instead of 73 ms in a release build, and the glyphs, bounds and baselines do not change. Laying out type looks up the font face of each style run once and uses it for the run's glyphs and for the line metrics of a paragraph that starts in that run: the layouts of a document of 5,000 short point type objects take a median of 215 million CPU cycles instead of 236 million in a release build (11 alternating runs of each build on Windows 11, measured while other builds were running), and the glyphs, bounds and baselines do not change. From 4574ab047d3558c1efe5e4617348c5fc4550d823 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 16:07:25 -0800 Subject: [PATCH 19/21] Preferences: Reset Preferences asks first Part of #6435. Reset Preferences in the Preferences dialog now opens a question over the dialog (`resetPreferences`). Cancel or Escape goes back to Preferences as it was, with its edits, category and search kept. OK goes back with every preference at its default value; OK in Preferences applies them and Cancel there discards them, as before. `prefs.reset` still resets at once. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/dialogs/confirm.rs | 25 ++++++ crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/prefs_dialog.rs | 16 ++-- crates/ui-egui/src/prefs_dialog/tests.rs | 105 +++++++++++++++++++++++ docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 33 files changed, 201 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 38ccc908d..a9e51251f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -322,6 +322,7 @@ registry! { BrushDelete: [brush_delete::KIND] => brush_delete::SPEC, Message: [confirm::MESSAGE] => confirm::MESSAGE_SPEC, Replace: [confirm::REPLACE] => confirm::REPLACE_SPEC, + ResetPreferences: [confirm::RESET_PREFS] => confirm::RESET_PREFS_SPEC, NewSwatch: [new_swatch::KIND] => new_swatch::SPEC, NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC, GraphData: [graph_data::KIND] => graph_data::SPEC, diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs index ab79ccf5d..06e96e45f 100644 --- a/crates/ui-egui/src/dialogs/confirm.rs +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -63,6 +63,31 @@ pub(crate) fn ask_replace(app: &mut VectorcraftApp, path: &str, typed: &str) { app.ui.dialog = Some(Dialog::new(REPLACE, fields)); } +/// The dialog kind of the question Reset Preferences asks over the Preferences dialog +/// ([`ask_reset_prefs`]). Fields: `message`, `detail`. +pub const RESET_PREFS: &str = "resetPreferences"; + +pub(super) const RESET_PREFS_SPEC: DialogSpec = DialogSpec { confirm: reset_prefs, ..SPEC }; + +/// Ask whether to reset the preferences, over the Preferences dialog open now. Cancel and Escape +/// show Preferences again as it was; OK shows it again with every preference at its default value +/// ([`crate::prefs_dialog::reset_fields`]). Nothing is applied until OK in Preferences. +pub(crate) fn ask_reset_prefs(app: &mut VectorcraftApp) { + let under = app.ui.dialog.take().and_then(|d| serde_json::to_value(d).ok()); + let message = tl!("Reset all preferences to their defaults?"); + let detail = tl!("The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences."); + let fields = json!({"message": message, "detail": detail, super::PARENT: under}); + app.ui.dialog = Some(Dialog::new(RESET_PREFS, fields)); +} + +/// OK on [`ask_reset_prefs`]: Preferences again, its fields at their defaults. +fn reset_prefs(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut prefs = super::parent(d).filter(|p| p.kind == "preferences").ok_or("the dialog it was opened over is no longer open")?; + crate::prefs_dialog::reset_fields(&mut prefs); + app.ui.dialog = Some(prefs); + Ok(Value::Null) +} + /// Most messages kept for later while a dialog is open ([`tell`]). const MAX_WAITING: usize = 16; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 354d811a3..757e664ce 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2766,6 +2766,8 @@ digits Malayalam Малаялам Responsive Адаптивен Restore Възстановяване Restore every preference to its default (applied on OK) Възстановяване на всички настройки по подразбиране (прилага се при ОПК) + Reset all preferences to their defaults? Да се нулират ли всички настройки до стойностите по подразбиране? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Стойностите по подразбиране се прилагат, когато щракнете върху ОК в Настройки. Отказ там запазва текущите ви настройки. Restore Recovered Documents Възстановяване на възстановените документи Restored documents open unsaved: save them to keep them. Възстановените документи се отварят незаписани: запазете ги, за да ги съхраните. Restrict printing, editing and other tasks Ограничаване на печата, редактирането и други задачи diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d1fc0ea54..115f221a4 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4091,6 +4091,8 @@ font-technology Variable Varijabilni Preferences Postavke Previous Prethodno Restore every preference to its default (applied on OK) Vrati svaku postavku na zadanu vrijednost (primjenjuje se na U redu) + Reset all preferences to their defaults? Vratiti sve postavke na zadane vrijednosti? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Zadane vrijednosti primjenjuju se kada kliknete U redu u Postavkama. Otkaži tamo zadržava vaše trenutne postavke. Smaller ↔ Larger Manje ↔ veće Smart Guides (View → Smart Guides) Pametne vodilice (Prikaz → Pametne vodilice) Couldn't save recovery data: {e} Nije moguće sačuvati podatke za oporavak: {e} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9e77ecb32..58b9087a4 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3511,6 +3511,8 @@ digits Malayalam Malaiàlam Responsive Adaptatiu Restore Restaura Restore every preference to its default (applied on OK) Restaura cada preferència al seu valor predeterminat (s’aplica en prémer D’acord) + Reset all preferences to their defaults? Voleu restablir totes les preferències als valors predeterminats? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Els valors predeterminats s’apliquen quan feu clic a D’acord a Preferències. Cancel·la allà conserva les preferències actuals. Restore Recovered Documents Restaura els documents recuperats Restored documents open unsaved: save them to keep them. Els documents restaurats s’obren sense desar: desa’ls per conservar-los. Restrict printing, editing and other tasks Restringeix la impressió, l’edició i altres tasques diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index de1723b90..f3ed3d7d4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4143,6 +4143,8 @@ font-technology Variable Variabilní Preferences Předvolby Previous Předchozí Restore every preference to its default (applied on OK) Obnovit všechny předvolby na výchozí hodnoty (použije se po OK) + Reset all preferences to their defaults? Obnovit všechny předvolby na výchozí hodnoty? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Výchozí hodnoty se použijí po klepnutí na OK v Předvolbách. Zrušit tam zachová vaše současné předvolby. Smaller ↔ Larger Menší ↔ Větší Smart Guides (View → Smart Guides) Chytrá vodítka (Zobrazení → Chytrá vodítka) Couldn't save recovery data: {e} Data pro obnovení nelze uložit: {e} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 77687493e..305fd5df0 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3857,6 +3857,8 @@ font-technology Variable Variable Preferences Præferencer Previous Forrige Restore every preference to its default (applied on OK) Gendan alle præferencer til standard (anvendes ved OK) + Reset all preferences to their defaults? Nulstil alle præferencer til standard? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Standardværdierne anvendes, når du klikker på OK i Præferencer. Annullér der beholder dine nuværende præferencer. Smaller ↔ Larger Mindre ↔ Større Smart Guides (View → Smart Guides) Smarte hjælpelinjer (Vis → Smarte hjælpelinjer) Couldn't save recovery data: {e} Kunne ikke gemme gendannelsesdata: {e} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7e2619386..50c0e6455 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2799,6 +2799,8 @@ digits Malayalam Malayalam Responsive Responsiv Restore Wiederherstellen Restore every preference to its default (applied on OK) Alle Voreinstellungen auf ihre Standardwerte zurücksetzen (wird bei OK angewendet) + Reset all preferences to their defaults? Alle Voreinstellungen auf ihre Standardwerte zurücksetzen? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Die Standardwerte werden angewendet, wenn Sie in den Voreinstellungen auf OK klicken. Abbrechen dort behält Ihre aktuellen Voreinstellungen. Restore Recovered Documents Wiederhergestellte Dokumente öffnen Restored documents open unsaved: save them to keep them. Wiederhergestellte Dokumente werden ungespeichert geöffnet: Speichern Sie sie, um sie zu behalten. Restrict printing, editing and other tasks Drucken, Bearbeiten und andere Aufgaben einschränken diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a95d84be3..4f5057ac6 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3421,6 +3421,8 @@ digits Malayalam Μαλαγιάλαμ Reshape Type Area Αναδιαμόρφωση περιοχής κειμένου Responsive Αποκρινόμενο Restore every preference to its default (applied on OK) Επαναφορά όλων των προτιμήσεων στις προεπιλογές τους (εφαρμογή με το Εντάξει) + Reset all preferences to their defaults? Επαναφορά όλων των προτιμήσεων στις προεπιλογές τους; + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Οι προεπιλογές εφαρμόζονται όταν κάνετε κλικ στο Εντάξει στις Προτιμήσεις. Το Ακύρωση εκεί διατηρεί τις τρέχουσες προτιμήσεις σας. Restore Recovered Documents Επαναφορά ανακτημένων εγγράφων Restored documents open unsaved: save them to keep them. Τα ανακτημένα έγγραφα ανοίγουν χωρίς να είναι αποθηκευμένα: αποθηκεύστε τα για να τα διατηρήσετε. Restrict printing, editing and other tasks Περιορισμός εκτύπωσης, επεξεργασίας και άλλων ενεργειών diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e0903d19d..464ecb24e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2841,6 +2841,8 @@ digits Malayalam Malayálam Responsive Adaptable Restore Restaurar Restore every preference to its default (applied on OK) Restaurar todas las preferencias a sus valores predeterminados (se aplica al Aceptar) + Reset all preferences to their defaults? ¿Restablecer todas las preferencias a sus valores predeterminados? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Los valores predeterminados se aplican al hacer clic en Aceptar en Preferencias. Cancelar ahí conserva sus preferencias actuales. Restore Recovered Documents Restaurar documentos recuperados Restored documents open unsaved: save them to keep them. Los documentos restaurados se abren sin guardar: guárdalos para conservarlos. Restrict printing, editing and other tasks Restringir la impresión, la edición y otras tareas diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b593200aa..8122790c1 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4062,6 +4062,8 @@ font-technology Variable Muuttuvat Preferences Asetukset Previous Edellinen Restore every preference to its default (applied on OK) Palauta kaikki asetukset oletuksiin (käytetään, kun valitaan OK) + Reset all preferences to their defaults? Palautetaanko kaikki asetukset oletuksiin? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Oletukset otetaan käyttöön, kun valitset Asetuksissa OK. Peruuta siellä säilyttää nykyiset asetuksesi. Smaller ↔ Larger Pienempi ↔ Suurempi Smart Guides (View → Smart Guides) Älykkäät apuviivat (Näytä → Älykkäät apuviivat) Couldn't save recovery data: {e} Palautustietoja ei voitu tallentaa: {e} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6dd4e37a4..12a8c415d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2833,6 +2833,8 @@ digits Malayalam Malayalam Responsive Adaptatif Restore Restaurer Restore every preference to its default (applied on OK) Rétablir toutes les préférences à leur valeur par défaut (appliqué avec OK) + Reset all preferences to their defaults? Rétablir toutes les préférences à leur valeur par défaut ? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Les valeurs par défaut sont appliquées lorsque vous cliquez sur OK dans Préférences. Annuler y conserve vos préférences actuelles. Restore Recovered Documents Restaurer les documents récupérés Restored documents open unsaved: save them to keep them. Les documents restaurés s’ouvrent sans être enregistrés : enregistrez-les pour les conserver. Restrict printing, editing and other tasks Restreindre l’impression, la modification et d’autres tâches diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3397e7f34..f0251d048 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3820,6 +3820,8 @@ antiAlias Sharp शार्प Responsive उत्तरदायी Restore पुनर्स्थापित करें Restore every preference to its default (applied on OK) प्रत्येक वरीयता को उसके डिफ़ॉल्ट में पुनर्स्थापित करें ( OK पर लागू) + Reset all preferences to their defaults? सभी प्रेफ़रेंसेज़ को उनके डिफ़ॉल्ट पर रीसेट करें? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. डिफ़ॉल्ट तब लागू होते हैं जब आप प्रेफ़रेंसेज़ में ठीक है पर क्लिक करते हैं। वहाँ रद्द करें आपकी मौजूदा प्रेफ़रेंसेज़ बनाए रखता है। Restore Recovered Documents पुनर्स्थापना डॉक्यूमेंट Restored documents open unsaved: save them to keep them. रिकवर किए डॉक्यूमेंट बिना सेव किए ओपन होते हैं। रखने के लिए उन्हें सेव करें। Restrict printing, editing and other tasks प्रिंटिंग, एडिटिंग और बाकी कामों पर पाबंदी लगाएँ diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 4b813215e..510696ea8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3397,6 +3397,8 @@ mark-style Japanese Japán Responsive Reszponzív Restore Visszaállít Restore every preference to its default (applied on OK) Minden beállítás visszaállítása az alapértékre (az OK gombbal lép életbe) + Reset all preferences to their defaults? Visszaállítja az összes beállítást az alapértékre? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Az alapértékek akkor lépnek életbe, amikor a Beállításokban az OK gombra kattint. Az ottani Mégse megtartja a jelenlegi beállításokat. Restore Recovered Documents Helyreállított dokumentumok visszaállítása Restored documents open unsaved: save them to keep them. A visszaállított dokumentumok mentetlenül nyílnak meg: mentsd őket, ha meg akarod tartani. Restrict printing, editing and other tasks Nyomtatás, szerkesztés és más műveletek korlátozása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 33d229c85..ae6f37497 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3857,6 +3857,8 @@ font-technology Variable Variabel Preferences Preferensi Previous Sebelumnya Restore every preference to its default (applied on OK) Kembalikan semua preferensi ke defaultnya (diterapkan saat OK) + Reset all preferences to their defaults? Atur ulang semua preferensi ke defaultnya? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Nilai default diterapkan saat Anda mengklik OK di Preferensi. Batal di sana mempertahankan preferensi Anda saat ini. Smaller ↔ Larger Lebih kecil ↔ Lebih besar Smart Guides (View → Smart Guides) Panduan pintar (Tampilan → Panduan pintar) Couldn't save recovery data: {e} Tidak dapat menyimpan data pemulihan: {e} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b5fd752e9..faeb2d9bb 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2875,6 +2875,8 @@ digits Malayalam Malayalam Responsive Adattivo Restore Ripristina Restore every preference to its default (applied on OK) Ripristina tutte le preferenze ai valori predefiniti (applicato con OK) + Reset all preferences to their defaults? Ripristinare tutte le preferenze ai valori predefiniti? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. I valori predefiniti vengono applicati quando fai clic su OK in Preferenze. Annulla lì mantiene le preferenze attuali. Restore Recovered Documents Ripristina documenti recuperati Restored documents open unsaved: save them to keep them. I documenti ripristinati si aprono senza essere salvati: salvali per conservarli. Restrict printing, editing and other tasks Limita la stampa, la modifica e altre operazioni diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 56fc8a590..aa3057052 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2859,6 +2859,8 @@ mark-style Japanese 日本式 Restore 復元 Restore Recovered Documents 復帰したドキュメントを復元 Restore every preference to its default (applied on OK) すべての環境設定を初期値に戻す (OK で適用) + Reset all preferences to their defaults? すべての環境設定を初期値に戻しますか? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. 初期値は環境設定で OK をクリックすると適用されます。そこでキャンセルすると現在の環境設定が保たれます。 Restored documents open unsaved: save them to keep them. 復元したドキュメントは未保存の状態で開きます。保存して残してください。 Restrict printing, editing and other tasks 印刷、編集などの操作を制限 Reverse Gradient グラデーションを反転 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 388dd4a01..421bb6f0c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3648,6 +3648,8 @@ font-technology Variable ცვლადი Preferences პარამეტრები Previous წინა Restore every preference to its default (applied on OK) ყველა პარამეტრის ნაგულისხმევ მნიშვნელობაზე დაბრუნება (გამოიყენება OK-ზე დაჭერისას) + Reset all preferences to their defaults? დავაბრუნოთ ყველა პარამეტრი ნაგულისხმევ მნიშვნელობებზე? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. ნაგულისხმევი მნიშვნელობები გამოიყენება, როცა პარამეტრებში OK-ს დააჭერთ. იქ გაუქმება ინარჩუნებს თქვენს მიმდინარე პარამეტრებს. Smaller ↔ Larger უფრო პატარა ↔ უფრო დიდი Smart Guides (View → Smart Guides) ჭკვიანი დამხმარე ხაზები (ხედი → ჭკვიანი დამხმარე ხაზები) Couldn't save recovery data: {e} აღდგენის მონაცემები ვერ შეინახა: {e} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 03930932a..025d9b7b8 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2848,6 +2848,8 @@ mark-style Japanese 일본식 Restore 복원 Restore Recovered Documents 복구된 문서 복원 Restore every preference to its default (applied on OK) 모든 환경 설정을 기본값으로 복원(확인 시 적용) + Reset all preferences to their defaults? 모든 환경 설정을 기본값으로 재설정하시겠습니까? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. 기본값은 환경 설정에서 확인을 클릭하면 적용됩니다. 그곳에서 취소를 누르면 현재 환경 설정이 유지됩니다. Restored documents open unsaved: save them to keep them. 복원된 문서는 저장되지 않은 상태로 열립니다. 보존하려면 저장하세요. Restrict printing, editing and other tasks 인쇄, 편집 등의 작업 제한 Reverse Gradient 그레이디언트 반전 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c5151622d..e67a2e3cb 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3871,6 +3871,8 @@ font-technology Variable Variabel Preferences Voorkeuren Previous Vorige Restore every preference to its default (applied on OK) Elke voorkeur terugzetten naar de standaard (toegepast bij OK) + Reset all preferences to their defaults? Alle voorkeuren terugzetten naar de standaardwaarden? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. De standaardwaarden worden toegepast wanneer u in Voorkeuren op OK klikt. Annuleren daar behoudt uw huidige voorkeuren. Smaller ↔ Larger Kleiner ↔ Groter Smart Guides (View → Smart Guides) Slimme hulplijnen (Beeld → Slimme hulplijnen) Couldn't save recovery data: {e} Kon herstelgegevens niet opslaan: {e} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 58016a4bd..ca590ac7f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3983,6 +3983,8 @@ font-technology Variable Zmienne Preferences Preferencje Previous Wstecz Restore every preference to its default (applied on OK) Przywróć domyślne wartości wszystkich preferencji (zastosowane po OK) + Reset all preferences to their defaults? Przywrócić domyślne wartości wszystkich preferencji? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Wartości domyślne zostaną zastosowane po kliknięciu OK w Preferencjach. Anuluj w tym oknie zachowuje bieżące preferencje. Smaller ↔ Larger Mniejszy ↔ większy Smart Guides (View → Smart Guides) Inteligentne linie pomocnicze (Widok → Inteligentne linie pomocnicze) Couldn't save recovery data: {e} Nie udało się zapisać danych odzyskiwania: {e} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b0d940693..553a33615 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2377,6 +2377,8 @@ digits Malayalam Malaiala Restore Restaurar Restore Recovered Documents Restaurar Documentos Recuperados Restore every preference to its default (applied on OK) Restaurar toda preferência ao padrão (aplicado em OK) + Reset all preferences to their defaults? Redefinir todas as preferências para o padrão? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Os padrões são aplicados quando você clica em OK em Preferências. Cancelar ali mantém suas preferências atuais. Restored documents open unsaved: save them to keep them. Os documentos restaurados abrem sem salvar: salve-os para mantê-los. Restrict printing, editing and other tasks Restringir impressão, edição e outras tarefas Reverse Front to Back Inverter da Frente para Trás diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a00325e8b..7a225c6e7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2870,6 +2870,8 @@ digits Malayalam Малаялам Responsive Адаптивный Restore Восстановить Restore every preference to its default (applied on OK) Восстановить все настройки по умолчанию (применяется при нажатии ОК) + Reset all preferences to their defaults? Сбросить все настройки до значений по умолчанию? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Значения по умолчанию применяются, когда вы нажимаете ОК в окне «Настройки». Отмена там сохраняет текущие настройки. Restore Recovered Documents Восстановить восстановленные документы Restored documents open unsaved: save them to keep them. Восстановленные документы открываются несохранёнными: сохраните их, чтобы оставить. Restrict printing, editing and other tasks Ограничить печать, редактирование и другие задачи diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index f2b24e7d0..4054a6592 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2777,6 +2777,8 @@ digits Malayalam Malayalam Responsive Responsiv Restore Återställ Restore every preference to its default (applied on OK) Återställ alla inställningar till standard (verkställs vid OK) + Reset all preferences to their defaults? Återställa alla inställningar till standard? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Standardvärdena verkställs när du klickar på OK i Inställningar. Avbryt där behåller dina nuvarande inställningar. Restore Recovered Documents Återställ återskapade dokument Restored documents open unsaved: save them to keep them. Återställda dokument öppnas osparade: spara dem för att behålla dem. Restrict printing, editing and other tasks Begränsa utskrift, redigering och andra åtgärder diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 76d73994b..3efaf6704 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2646,6 +2646,8 @@ digits Malayalam Malayalam Responsive duyarlı Restore Geri yükle Restore every preference to its default (applied on OK) Her tercihi varsayılana geri yükleyin (Tamam'a uygulanır) + Reset all preferences to their defaults? Tüm tercihler varsayılanlarına sıfırlansın mı? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Varsayılanlar, Tercihler'de Tamam'a tıkladığınızda uygulanır. Oradaki İptal, mevcut tercihlerinizi korur. Restore Recovered Documents Kurtarılan Belgeleri Geri Yükle Restored documents open unsaved: save them to keep them. Geri yüklenen belgeler kaydedilmeden açılır: saklamak için kaydedin. Restrict printing, editing and other tasks Yazdırmayı, düzenlemeyi ve diğer görevleri kısıtlayın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4652ce4ca..60026742a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2867,6 +2867,8 @@ digits Malayalam Малаялам Responsive Адаптивний Restore Відновити Restore every preference to its default (applied on OK) Відновити всі параметри за промовчанням (застосовується при натисканні ОК) + Reset all preferences to their defaults? Скинути всі налаштування до значень за промовчанням? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Значення за промовчанням застосовуються, коли ви натискаєте ОК у вікні «Налаштування». Скасувати там зберігає поточні налаштування. Restore Recovered Documents Відновити відновлені документи Restored documents open unsaved: save them to keep them. Відновлені документи відкриваються незбереженими: збережіть їх, щоб залишити. Restrict printing, editing and other tasks Обмежити друк, редагування та інші завдання diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4c9dac833..e605011c3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3158,6 +3158,8 @@ mark-style Japanese Kiểu Nhật Responsive Đáp ứng Restore Khôi phục Restore every preference to its default (applied on OK) Khôi phục tất cả tùy chọn về mặc định (áp dụng khi nhấn OK) + Reset all preferences to their defaults? Đặt lại tất cả tùy chọn về mặc định? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. Giá trị mặc định được áp dụng khi bạn nhấn OK trong Tùy chọn. Hủy ở đó giữ nguyên các tùy chọn hiện tại của bạn. Restore Recovered Documents Khôi phục tài liệu đã phục hồi Restored documents open unsaved: save them to keep them. Tài liệu đã khôi phục mở ở trạng thái chưa lưu: hãy lưu để giữ lại. Restrict printing, editing and other tasks Hạn chế in, chỉnh sửa và các tác vụ khác diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c62b17668..297712d9c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2740,6 +2740,8 @@ digits Malayalam 马拉雅拉姆数字 Responsive 响应式 Restore 恢复 Restore every preference to its default (applied on OK) 把所有首选项恢复为默认值 (点击确定后应用) + Reset all preferences to their defaults? 将所有首选项重置为默认值? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. 在首选项中点击确定后才会应用默认值。在那里点击取消会保留当前首选项。 Restore Recovered Documents 恢复已恢复的文档 Restored documents open unsaved: save them to keep them. 恢复的文档处于未存储状态:请存储以保留。 Restrict printing, editing and other tasks 限制打印、编辑及其他操作 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0b3a21250..e59c2708b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3797,6 +3797,8 @@ digits Malayalam 馬來亞拉姆數字 Responsive 回應式 Restore 還原 Restore every preference to its default (applied on OK) 將所有偏好設定還原為預設值 (按下確定後套用) + Reset all preferences to their defaults? 要將所有偏好設定重設為預設值嗎? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. 在偏好設定中按下確定後才會套用預設值。在那裡按下取消會保留目前的偏好設定。 Restore Recovered Documents 還原找回的文件 Restored documents open unsaved: save them to keep them. 還原的文件會以未儲存狀態開啟:請儲存以保留。 Restrict printing, editing and other tasks 限制列印、編輯及其他操作 diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index fad2d45e7..e08d244dd 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -414,11 +414,6 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); }); }); - if reset { - for (k, v) in Prefs::default().to_json().as_object().cloned().unwrap_or_default() { - d.fields.insert(k, v); - } - } // The setting a search went to stays highlighted until another category is shown. let found = d.str("__found"); if !found.is_empty() && settings().find(|s| s.key == found).is_none_or(|s| s.category != d.str("__category")) { @@ -430,6 +425,17 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { app.ui.dialog = None; } else if ok && let Err(e) = confirm(app) { app.status(e); + } else if reset && !ok { + crate::dialogs::confirm::ask_reset_prefs(app); + } +} + +/// Reset Preferences, once its question is confirmed: every preference in the Preferences dialog +/// `d` takes its default value. The category, the search and the UI-only rows stay as they were; +/// nothing is applied until OK. +pub(crate) fn reset_fields(d: &mut Dialog) { + for (k, v) in Prefs::default().to_json().as_object().cloned().unwrap_or_default() { + d.fields.insert(k, v); } } diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 93b84f488..7a113a1fe 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -690,6 +690,111 @@ fn escape_clears_the_search_then_closes_the_dialog() { assert!(a.ui.dialog.is_none(), "Escape in the empty field closes the dialog"); } +/// One frame of the open dialog as the app draws it, keys first, a quarter second after the last +/// (a window takes a sizing frame and fades in). → The texts it painted, with their rectangles. +fn dialog_frame(a: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { + let time = ctx.input(|i| i.time) + 0.25; + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 900.0))); + let raw = egui::RawInput { screen_rect, events, time: Some(time), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + crate::shortcuts::handle(a, ui.ctx()); + crate::dialogs::show(a, ui.ctx()); + }); + out.textures_delta.clear(); + fn walk(s: &egui::Shape, v: &mut Vec<(String, egui::Rect)>) { + match s { + egui::Shape::Text(t) => v.push((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()))), + egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)), + _ => {} + } + } + let mut v = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); + v +} + +/// Click the text `label` in the open dialog with the pointer. +fn click_text(a: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { + dialog_frame(a, ctx, vec![]); + let texts = dialog_frame(a, ctx, vec![]); + let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}")); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + dialog_frame(a, ctx, vec![egui::Event::PointerMoved(at)]); + dialog_frame(a, ctx, vec![press(true)]); + dialog_frame(a, ctx, vec![press(false)]); +} + +/// Preferences with an edited Keyboard Increment, the Units category and a search, over a stored +/// Keyboard Increment of 5. +fn edited_preferences() -> VectorcraftApp { + let mut a = app(); + a.run("prefs.set", json!({"key": "keyboardIncrement", "value": 5})).unwrap(); + open(&mut a, Some("Units")); + let d = a.ui.dialog.as_mut().unwrap(); + d.fields.insert("keyboardIncrement".into(), json!(7)); + d.fields.insert("__search".into(), json!("grid")); + a +} + +/// Reset Preferences asks first, over Preferences. Escape, or Cancel, shows Preferences again as it +/// was: its edits, category and search kept, and nothing applied. +#[test] +fn reset_preferences_asks_first_and_cancel_returns_to_preferences() { + let mut a = edited_preferences(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for way_back in ["Escape", "Cancel"] { + click_text(&mut a, &ctx, "Reset Preferences"); + let d = a.ui.dialog.clone().expect("a question is open"); + assert_eq!(d.kind, crate::dialogs::confirm::RESET_PREFS, "Reset Preferences asks first"); + let under = crate::dialogs::parent(&d).expect("the question is over Preferences"); + assert_eq!((under.kind.as_str(), under.fields["keyboardIncrement"].clone()), ("preferences", json!(7)), "Preferences is unchanged"); + dialog_frame(&mut a, &ctx, vec![]); + let shown: Vec = dialog_frame(&mut a, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + assert!(shown.iter().any(|t| t == "Reset all preferences to their defaults?"), "{shown:?}"); + if way_back == "Escape" { + dialog_frame(&mut a, &ctx, key_press(egui::Key::Escape, egui::Modifiers::NONE)); + } else { + click_text(&mut a, &ctx, "Cancel"); + } + let d = a.ui.dialog.as_ref().unwrap_or_else(|| panic!("{way_back}: Preferences is open again")); + assert_eq!(d.kind, "preferences", "{way_back}"); + assert_eq!(d.fields["keyboardIncrement"], json!(7), "{way_back}: the edit is kept"); + assert_eq!((d.str("__category").as_str(), d.str("__search").as_str()), ("Units", "grid"), "{way_back}: category and search kept"); + assert_eq!(a.session.prefs.keyboard_increment, 5.0, "{way_back}: nothing applied"); + } +} + +/// OK on the question shows Preferences again with every preference at its default, its category +/// and search kept; nothing is applied until OK in Preferences, and Preferences' Cancel keeps the +/// stored preferences. +#[test] +fn confirming_reset_fills_preferences_and_applies_nothing_until_ok() { + let mut a = edited_preferences(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let default = Prefs::default().to_json()["keyboardIncrement"].clone(); + click_text(&mut a, &ctx, "Reset Preferences"); + click_text(&mut a, &ctx, "OK"); + let d = a.ui.dialog.as_ref().expect("Preferences is open again"); + assert_eq!(d.kind, "preferences"); + assert_eq!(d.fields["keyboardIncrement"], default, "the working copy holds the defaults"); + assert_eq!((d.str("__category").as_str(), d.str("__search").as_str()), ("Units", "grid")); + assert_eq!(a.session.prefs.keyboard_increment, 5.0, "nothing applied yet"); + click_text(&mut a, &ctx, "Cancel"); + assert!(a.ui.dialog.is_none()); + assert_eq!(a.session.prefs.keyboard_increment, 5.0, "Cancel in Preferences keeps the preferences"); + + // `ui.dialog.confirm` answers the question as OK does; OK in Preferences then applies the defaults. + let mut a = edited_preferences(); + click_text(&mut a, &ctx, "Reset Preferences"); + crate::dialogs::confirm(&mut a).unwrap(); + assert_eq!(a.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("preferences")); + crate::dialogs::confirm(&mut a).unwrap(); + assert!(a.ui.dialog.is_none()); + assert_eq!(a.session.prefs.keyboard_increment, Prefs::default().keyboard_increment); +} + /// Units › Numbers Without Units Are Points (#394): the preference reaches the fields when it /// changes, and its checkbox is dimmed unless a unit is Picas. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index bc2048393..d4b7d610f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4515,7 +4515,10 @@ a string with a unit. Unit names: `Points`, `Picas`, `Inches`, `Millimeters`, `C settings whose label, section or category contains it, ignoring case, in the interface language or in English, or by "No settings found". Choosing a setting sets `__category` to its category, sets `__found` to its key (the row is highlighted until another category is shown) and clears `__search`. Cmd/Ctrl+F focuses the field again; Escape in the -field with text clears it, and Escape in the empty field closes the dialog. +field with text clears it, and Escape in the empty field closes the dialog. Its Reset Preferences button opens a +`resetPreferences` question (fields `message`, `detail`) over Preferences: `ui.dialog.cancel` shows Preferences again +as it was, and `ui.dialog.confirm` shows it again with every preference at its default value, applied by OK in +Preferences. `prefs.reset` resets the preferences at once, without the question. ```json {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"unitsGeneral","value":"millimeters"}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6bf45e9c8..f2c56c69b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -658,6 +658,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Reordering document tabs (G7, #5663):** a document tab dragged along the tab strip moves its document among the others ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)); a line marks the gap it goes in. The current document stays current, each document keeps its own zoom, centre and artboard, the views Window › New Window opened move with their document, and Window › Next/Previous Document follow the new order. `document.move` (`{from?, to}`) does the same from MCP, the control channel and recordings. `crates/ui-egui/src/tests/tab_reorder.rs`, `crates/engine/src/tests/document_move.rs`. - **Preferences › User Interface › Open Documents As Tabs (G7, #394):** checked by default, documents open under tabs in one document window ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/workspace-overview.html)). With the box cleared, each document opened or created afterwards opens in a floating window of its own, at the next cascade place, as Float in Window floats it; documents already open stay where they are, and checking the box again leaves floating documents in their windows. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Preferences search:** a Search Preferences field above the category list has the keyboard focus when the dialog opens, and Cmd/Ctrl+F gives it back; typing lists the settings it finds, and choosing one shows its category, as the [help page](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html) describes. VectorCraft's own rules: the field matches a setting's label, section or category, ignoring case, in the interface language or in English; the list replaces the categories while the field has text and shows every match, or "No settings found"; the chosen setting's row is highlighted and scrolled into view; Escape in the field with text clears it, and Escape in the empty field closes the dialog. The dialog's `__search` field (`ui.dialog.set`) holds the text. Tests: `crates/ui-egui/src/prefs_dialog/tests.rs`. +- **Reset Preferences asks first (#6435):** the Preferences dialog's Reset Preferences button opens a question over the dialog (`resetPreferences`). Cancel or Escape goes back to Preferences unchanged, with its category, search and edits kept; OK goes back with every preference at its default value, which OK in Preferences applies and Cancel there discards. The question and its wording are VectorCraft's own. Tests: `crates/ui-egui/src/prefs_dialog/tests.rs`. - **Window › New Window (G7, #4116):** New Window (`window.newWindow`) opens another view of the current document ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html) describes showing one document in several windows at once), with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own; an edit in one view shows in every view. Tile, Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as they arrange a document, and Next and Previous Document step through the views. Closing a view leaves the document open; closing its last view closes the document, asking to save it first. The view's number after the name in its tab ("Name:2", while the document has more than one view) is VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. From 3fc083d732625e6c6f054edf536e136881e83f17 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 16:09:01 -0800 Subject: [PATCH 20/21] Perspective grid: Snap to Grid reads a flat first object at the depth it attaches to When the first object of a perspective move was not yet in perspective and a later one was attached deeper on the same plane, Snap to Grid and Shift read the shared offset at the later object's depth and left the first object out of the joint bounds. The first object then moved by that offset instead of the one under the pointer at its own depth. The move now reads Snap to Grid and Shift with the objects that are not in perspective attached to the active plane, as the move attaches them, so the result matches attaching them first and then moving. --- .../engine/src/cmd/distortcmds/perspective.rs | 26 +++++++++++-- crates/engine/src/tests/persp_select.rs | 39 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs index 8c3525449..62b72a8e0 100644 --- a/crates/engine/src/cmd/distortcmds/perspective.rs +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -263,6 +263,22 @@ fn record_again(s: &mut Session, again: Value) -> Result<()> { Ok(()) } +/// `d` with each of `ids` that is not in perspective attached to `fallback` as [`persp_edit`] attaches +/// it; `d` itself when every one is in perspective already, there is no `fallback`, or one of them +/// cannot attach. +fn with_fallback<'a>(d: &'a Document, ids: &[NodeId], fallback: Option) -> std::borrow::Cow<'a, Document> { + let mut g = grid_of(d); + let loose: Vec = ids.iter().copied().filter(|id| g.attachment_of(d, *id).is_none()).collect(); + let Some(plane) = fallback.filter(|_| !loose.is_empty()) else { return std::borrow::Cow::Borrowed(d) }; + let mut out = d.clone(); + for id in loose { + if attach_in(&mut out, &mut g, id, plane, false, None).is_err() { + return std::borrow::Cow::Borrowed(d); + } + } + std::borrow::Cow::Owned(out) +} + fn ids_json(ids: &[NodeId]) -> Value { json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) } @@ -285,9 +301,11 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { let constrain = switch_or(p, "constrain", false, C)?; // `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false. check_switches(p, &["snap"], C)?; + // Shift and Snap to Grid read the objects as they are once those not in perspective are attached. + let doc = if perp { std::borrow::Cow::Borrowed(&*s.doc()?.doc) } else { with_fallback(&s.doc()?.doc, &ids, fallback) }; // Shift: the constrained end point, read on the first object's plane and depth. let snap_to = if constrain && !perp { - let doc = &s.doc()?.doc; + let doc = &*doc; let g = grid_of(doc); g.plane_bounds(doc, &ids) .and_then(|(plane, depth, _)| { @@ -305,10 +323,10 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { let fix = if perp { None } else { - let doc = &s.doc()?.doc; - crate::cmd::perspgrid::snap_fix(doc, &ids, p, (from, snap_to)) - .and_then(|(plane, depth, correction)| Some((plane, grid_of(doc).plane_delta(plane, depth, from, to)?, correction))) + crate::cmd::perspgrid::snap_fix(&doc, &ids, p, (from, snap_to)) + .and_then(|(plane, depth, correction)| Some((plane, grid_of(&doc).plane_delta(plane, depth, from, to)?, correction))) }; + drop(doc); let snapped = move |pl: Plane, raw: vectorcraft_geom::Vec2| { let raw = fix.filter(|f| f.0 == pl).map_or(raw, |f| f.1); let (direction, dv) = diff --git a/crates/engine/src/tests/persp_select.rs b/crates/engine/src/tests/persp_select.rs index 6a6022fdb..0ce9b0bf0 100644 --- a/crates/engine/src/tests/persp_select.rs +++ b/crates/engine/src/tests/persp_select.rs @@ -262,6 +262,45 @@ fn snap_moves_objects_at_different_depths_by_one_plane_offset() { assert!(on_line(moved.y0) || on_line(moved.y1), "{moved:?}"); } +/// The anchors of `flat` and `far` after a snapped move of both on the left plane, where `flat` +/// starts flat (attached by the move, or first with `attach_first`) and `far` sits 50 pt deeper. +fn move_with_a_flat_first_object(attach_first: bool, constrain: bool) -> Vec { + let (mut s, _) = persp_session(); + let rect = |s: &mut Session, x: f64| { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 380, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap()) + }; + s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 50})).unwrap(); + let far = rect(&mut s, 150.0); + s.execute("perspective.attach", &json!({"ids": [far.0], "plane": "left"})).unwrap(); + s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 0})).unwrap(); + let flat = rect(&mut s, 240.0); + if attach_first { + s.execute("perspective.attach", &json!({"ids": [flat.0], "plane": "left"})).unwrap(); + } + s.execute("perspective.grid.snap", &json!({"on": true})).unwrap(); + let h = grid(&s).homography_at(Plane::Left, 0.0).unwrap(); + let (from, to) = (h.apply(Point::new(100.0, 100.0)).unwrap(), h.apply(Point::new(137.3, 121.7)).unwrap()); + s.execute( + "perspective.move", + &json!({"ids": [flat.0, far.0], "plane": "left", "from": [from.x, from.y], "to": [to.x, to.y], "constrain": constrain}), + ) + .unwrap(); + assert_eq!(attached(&s, flat).map(|a| a.0), Some(Plane::Left)); + let doc = &s.doc().unwrap().doc; + [flat, far].iter().flat_map(|id| doc.node(*id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect::>()).collect() +} + +#[test] +fn snap_reads_a_flat_first_object_as_attached_by_the_move() { + for constrain in [false, true] { + let (moved, attached_first) = (move_with_a_flat_first_object(false, constrain), move_with_a_flat_first_object(true, constrain)); + assert_eq!(moved.len(), attached_first.len()); + for (a, b) in moved.iter().zip(&attached_first) { + assert!(a.distance(*b) < 1e-6, "constrain {constrain}: {moved:?} vs {attached_first:?}"); + } + } +} + #[test] fn constrained_move_uses_each_objects_plane() { let (mut s, right) = persp_session(); From 40382324111bcdaf6a2b8f588b6928fa5c3036c6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 16:28:09 -0800 Subject: [PATCH 21/21] Perspective grid: docs say a constrained move snaps only within a quarter cell A constrained move keeps only the snap correction along its direction, and that correction exists only when an edge ends within a quarter cell of a gridline. When no edge on those axes is that close, the move does not snap. --- docs/mcp.md | 7 ++++--- docs/target-app-parity.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index eb773f1f5..a96b57d8a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7479,9 +7479,10 @@ back). Holding Shift during an in-plane move constrains each object's displaceme own plane. `perspective.move` accepts `constrain: true` (default false) and adjusts Snap to Grid along the constrained direction on the first object's plane and depth. With Snap to Grid, an in-plane move moves every object on the first object's plane by the same plane offset, whatever its depth. Without `constrain`, on each axis, the edge of their joint -bounds nearer a gridline lands on it when that edge ends within a quarter cell of the gridline. With `constrain`, a -horizontal or vertical move lands only the edge on its own axis, and a 45° move lands the edge on the axis whose nonzero -correction is smaller. A perpendicular move does not snap. +bounds nearer a gridline lands on it when that edge ends within a quarter cell of the gridline. With `constrain`, an +edge also lands only when it ends within a quarter cell of a gridline: a horizontal or vertical move can land only an +edge on its own axis, and a 45° move lands the edge on the axis whose nonzero correction is smaller. When no edge on +those axes is that close, the move does not snap. A perpendicular move does not snap. `press_key` Escape during a move, a resize or a plane widget drag cancels it and puts the objects and the grid back. The arrow keys nudge a selection in perspective by the keyboard increment (`perspective.nudge`; Shift ×10, Alt copies). Object › Transform › Transform Again repeats the last perspective move or scale in plane space. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c072c441e..ae134578c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1535,7 +1535,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur An Alt-drag of objects in perspective with the Perspective Selection tool, and an Alt-drag of a plane widget with that tool or the Perspective Grid tool, copy the objects on the first move, and the moves after it move those copies (#1429). With the Perspective Selection tool, Ctrl+Alt-drag (Command+Option-drag on macOS) also copies objects on their perspective plane; Alt/Option-drag continues to do so. With the Perspective Selection tool, holding Shift while moving or copying constrains each object's displacement to the nearest axis or 45° diagonal of its own plane; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. Snap to Grid follows the constrained line on the first object's plane and depth. - With Snap to Grid on, a move of objects on one perspective plane at different depths moves them all by the same offset on the plane, so they keep their spacing. Without Shift, on each axis, the edge of their joint bounds nearer a gridline lands on it when that edge ends within a quarter cell of the gridline. With Shift, a horizontal or vertical move lands only the edge on its own axis, and a 45° move lands the edge on the axis whose nonzero correction is smaller. A move perpendicular to the plane does not snap. Regression: `snap_moves_objects_at_different_depths_by_one_plane_offset` in `crates/engine/src/tests/persp_select.rs`. + With Snap to Grid on, a move of objects on one perspective plane at different depths moves them all by the same offset on the plane, so they keep their spacing. Without Shift, on each axis, the edge of their joint bounds nearer a gridline lands on it when that edge ends within a quarter cell of the gridline. With Shift, an edge also lands only when it ends within a quarter cell of a gridline: a horizontal or vertical move can land only an edge on its own axis, and a 45° move lands the edge on the axis whose nonzero correction is smaller. When no edge on those axes is that close, the move does not snap. A move perpendicular to the plane does not snap. Regression: `snap_moves_objects_at_different_depths_by_one_plane_offset` in `crates/engine/src/tests/persp_select.rs`. When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. Laying out type keeps each font face's shaping plans, at most 32 per face, so type objects of the same font, script, direction, language and features share one plan: the layouts of a document of 5,000 short point type objects take 59 ms instead of 73 ms in a release build, and the glyphs, bounds and baselines do not change. Laying out type looks up the font face of each style run once and uses it for the run's glyphs and for the line metrics of a paragraph that starts in that run: the layouts of a document of 5,000 short point type objects take a median of 215 million CPU cycles instead of 236 million in a release build (11 alternating runs of each build on Windows 11, measured while other builds were running), and the glyphs, bounds and baselines do not change.