From 24155e1f75f84b94699326b83af1c4ec3d25ee29 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 14:30:10 -0800 Subject: [PATCH 01/10] 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 bf09404c33cfab9ee26b4a7101ff781ea319ff14 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 14:56:13 -0800 Subject: [PATCH 02/10] 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 fc34817d776dd48b502fa075fa02299457740edb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 15:37:42 -0800 Subject: [PATCH 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 1d411748762df1530ec8a4c9f602cd16c6dd2338 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 15:39:13 -0800 Subject: [PATCH 09/10] Reshape: a drag stretches an open path from the dragged point to its two ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Reshape drag on an open path moved only the anchors within half the diagonal of the path's bounding box, measured along the path. On a zigzag, a wave or a spiral that is one or two anchors, so dragging an end moved the end and left the rest of the path where it was (#2723). path.reshape now stretches an open path from the grabbed point to both ends. The end anchors stay in place. Each anchor between an end and the grabbed point takes u² (3 - 2u) of the drag, where u is its distance from that end along the control polygon divided by the grabbed point's distance from that end. A handle takes the share on the tangent line of that curve at its anchor, so the handles of a smooth anchor stay in line with it. A handle that is long beside the span takes a share below 0 or above 1. This changes the default for every caller that leaves radius out on an open path. A closed path still moves the anchors within half the diagonal of its bounding box, and a call that gives radius uses that radius on open and closed paths. - A negative radius is refused ("`radius` must be 0 or more"). It gave anchors beyond the radius a share above 1. - A drag of no length on an anchor no longer records an undo step. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/path.rs | 88 ++++++++++++++- crates/engine/src/tests.rs | 204 +++++++++++++++++++++++++++++++++- crates/tools/src/extra.rs | 4 +- docs/mcp.md | 16 +++ docs/target-app-parity.md | 1 + 5 files changed, 305 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 2b529a84b..9b753a395 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -131,7 +131,7 @@ pub fn specs() -> Vec { "Reshape", [], None, - "{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius, measured along the path (default: half the subpath's size)", + "{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy). An open path stretches from the grabbed point to its two ends, which stay in place: each anchor between takes a share of the drag that falls smoothly from 1 at the grabbed point to 0 at the end, by its distance along the path's control polygon. On a closed path, or when radius is given, nearby anchors follow with a smooth falloff over radius, measured along the path (0 or more; default: half the diagonal of the subpath's bounding box)", has_doc, reshape ), @@ -1410,6 +1410,57 @@ fn open_run(mut anchors: Vec) -> Option { Some(SubPath::new(anchors, false)) } +/// The share of a Reshape drag that each anchor of an open subpath takes, as `[in handle, anchor, +/// out handle]`. The grabbed anchor takes all of it and the two end anchors take none. For an +/// anchor between the grabbed one and an end, `u` is its distance from that end along the control +/// polygon (anchor, out handle, the next in handle, the next anchor) divided by the grabbed +/// anchor's distance from that end, and its share is `u² (3 - 2u)`. A handle takes the share on +/// the tangent line of that curve at its anchor, so the two handles of a smooth anchor stay in +/// line with it. That share is below 0 or above 1 for a handle long enough beside the span. +fn stretch_weights(anchors: &[Anchor], grabbed: usize) -> Vec<[f64; 3]> { + let last = anchors.len().saturating_sub(1); + // Where each anchor's in handle, point and out handle lie along the control polygon. The in + // handle of the first anchor and the out handle of the last are not part of an open path. + let mut pos: Vec<[f64; 3]> = Vec::with_capacity(anchors.len()); + let mut run = 0.0; + for (i, a) in anchors.iter().enumerate() { + if let Some(before) = i.checked_sub(1).and_then(|b| anchors.get(b)) { + run += before.h_out.distance(a.h_in); + } + let c_in = run; + if i > 0 { + run += a.h_in.distance(a.p); + } + let c = run; + if i < last { + run += a.p.distance(a.h_out); + } + pos.push([c_in, c, run]); + } + let at = pos.get(grabbed).map_or(0.0, |c| c[1]); + let length = pos.last().map_or(0.0, |c| c[1]); + pos.iter() + .enumerate() + .map(|(i, &[c_in, c, c_out])| { + if i == grabbed { + return [1.0; 3]; + } + let end = if i < grabbed { 0.0 } else { length }; + let span = (at - end).abs(); + if i == 0 || i == last || span <= 0.0 || !span.is_finite() { + return [0.0; 3]; + } + let along = |c: f64| (c - end).abs() / span; + let u = along(c); + let w = u * u * (3.0 - 2.0 * u); + let slope = 6.0 * u * (1.0 - u); + let shares = [w + slope * (along(c_in) - u), w, w + slope * (along(c_out) - u)]; + // A coordinate that isn't finite moves nothing. + if shares.iter().all(|s| s.is_finite()) { shares } else { [0.0; 3] } + }) + .collect() +} + fn reshape(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?; let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?); @@ -1417,14 +1468,42 @@ fn reshape(s: &mut Session, p: &Value) -> Result { let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let tol = f64_or(p, "tol", 3.0).max(0.0); let radius = p.get("radius").and_then(Value::as_f64); + if radius.is_some_and(|r| r < 0.0) { + return Err(bad("path.reshape", "`radius` must be 0 or more")); + } + // The anchor within `tol` of the grab point, if there is one. + let near = |sp: &SubPath| match sp.anchors.iter().enumerate().map(|(i, a)| (i, a.p.distance(at))).min_by(|a, b| a.1.total_cmp(&b.1)) { + Some((i, dist)) if dist <= tol => Some(i), + _ => None, + }; + // A drag of no length on an anchor moves nothing, so it is not an edit. + if delta == Vec2::ZERO { + let st = s.doc()?; + let node = st.doc.node(id).ok_or(EngineError::NoNode(id))?; + let path = node.path_data().ok_or_else(|| EngineError::Other(format!("object {id} is not a path")))?; + let (si, ..) = path.nearest(at).ok_or_else(|| EngineError::Other("empty path".into()))?; + if path.subpaths.get(si).is_some_and(|sp| near(sp).is_some()) { + return ok(); + } + } s.edit("Reshape", |d, _| { let path = path_mut(d, id)?; let (si, seg, t, _, _) = path.nearest(at).ok_or_else(|| EngineError::Other("empty path".into()))?; let sp = &mut path.subpaths[si]; - let grabbed = match sp.anchors.iter().enumerate().map(|(i, a)| (i, a.p.distance(at))).min_by(|a, b| a.1.total_cmp(&b.1)) { - Some((i, dist)) if dist <= tol => i, - _ => sp.insert_anchor(seg, t), + let grabbed = match near(sp) { + Some(i) => i, + None => sp.insert_anchor(seg, t), }; + let closed = sp.closed; + if radius.is_none() && !closed { + let weights = stretch_weights(&sp.anchors, grabbed); + for (a, [w_in, w, w_out]) in sp.anchors.iter_mut().zip(weights) { + a.p += delta * w; + a.h_in += delta * w_in; + a.h_out += delta * w_out; + } + return Ok(()); + } // Each anchor's distance along the path from the grabbed one, the shorter way round a // closed path: the lengths are measured once, as a path can have many anchors. let pos = sp.anchor_arc_positions(); @@ -1437,7 +1516,6 @@ fn reshape(s: &mut Session, p: &Value) -> Result { (b.width().hypot(b.height()) / 2.0).max(1.0) }); let from = pos.get(grabbed).copied().unwrap_or(0.0); - let closed = sp.closed; for (i, a) in sp.anchors.iter_mut().enumerate() { let w = if i == grabbed { 1.0 diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f6e32d719..36380778f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3243,7 +3243,7 @@ fn reshape_falloff_follows_arc_length_on_open_paths() { .as_u64() .unwrap(), ); - s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 0.0, "dx": 0.0, "dy": 100.0, "radius": 250.0})).unwrap(); let pts: Vec<_> = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect(); assert!((pts[3].y - 100.0).abs() < 1e-6, "grabbed end moves full delta: {pts:?}"); assert!(pts[0].y.abs() < 1e-6, "far end stays put: {pts:?}"); @@ -3251,6 +3251,208 @@ fn reshape_falloff_follows_arc_length_on_open_paths() { assert!(pts[1].y < pts[2].y, "nearer anchors move more: {pts:?}"); } +/// An open path for the Reshape tests. +fn reshape_open_path(s: &mut Session, anchors: Value) -> NodeId { + NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()["id"].as_u64().unwrap()) +} + +/// The anchors of the path's first subpath. +fn reshape_anchors(s: &Session, id: NodeId) -> Vec { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.clone() +} + +/// The share of the drag an anchor takes at `u` of the way from an end to the grabbed point. +fn reshape_share(u: f64) -> f64 { + u * u * (3.0 - 2.0 * u) +} + +/// Six anchors 100 apart along a zigzag 300 wide and 80 high. +fn reshape_zigzag() -> Value { + json!([{"x": 0, "y": 0}, {"x": 60, "y": 80}, {"x": 120, "y": 0}, {"x": 180, "y": 80}, {"x": 240, "y": 0}, {"x": 300, "y": 80}]) +} + +#[test] +fn reshape_of_an_end_stretches_an_open_path_to_its_other_end() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_zigzag()); + let before = reshape_anchors(&s, id); + s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 80.0, "dx": 20.0, "dy": 50.0})).unwrap(); + let after = reshape_anchors(&s, id); + assert_eq!(after.len(), 6); + let moved: Vec = after.iter().zip(&before).map(|(a, b)| a.p - b.p).collect(); + assert_eq!(moved[0], vectorcraft_geom::Vec2::ZERO, "the other end stays in place: {moved:?}"); + for (i, m) in moved.iter().enumerate() { + let share = reshape_share(i as f64 / 5.0); + assert!((m.x - 20.0 * share).abs() < 1e-9 && (m.y - 50.0 * share).abs() < 1e-9, "anchor {i} takes {share} of the drag: {moved:?}"); + } + assert!(moved.windows(2).all(|w| w[0].y < w[1].y), "each anchor moves less than the one after it: {moved:?}"); + // The path has no handles, and gets none. + assert!(after.iter().all(|a| a.h_in == a.p && a.h_out == a.p), "{after:?}"); +} + +#[test] +fn reshape_of_a_middle_anchor_leaves_both_ends_of_an_open_path_in_place() { + let mut s = session(); + let id = reshape_open_path(&mut s, json!([{"x": 0, "y": 0}, {"x": 30, "y": 0}, {"x": 200, "y": 0}, {"x": 230, "y": 0}, {"x": 400, "y": 0}])); + s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); + assert_eq!(y.len(), 5, "the grab was on an anchor, so none is added"); + assert_eq!((y[0], y[2], y[4]), (0.0, 100.0, 0.0), "{y:?}"); + // Each side is measured from its own end: 30 of 200 on the left, 170 of 200 on the right. + assert!((y[1] - 100.0 * reshape_share(0.15)).abs() < 1e-9, "{y:?}"); + assert!((y[3] - 100.0 * reshape_share(0.85)).abs() < 1e-9, "{y:?}"); +} + +#[test] +fn reshape_keeps_the_handles_of_a_smooth_anchor_in_line() { + let mut s = session(); + let id = reshape_open_path( + &mut s, + json!([ + {"x": 0, "y": 0, "out": [40, 0]}, + {"x": 100, "y": 0, "in": [60, 0], "out": [140, 0], "smooth": true}, + {"x": 200, "y": 0, "in": [160, 0]} + ]), + ); + s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + let a = reshape_anchors(&s, id); + // The control polygon is 200 long and the middle anchor is halfway: it takes half the drag, + // and its handles, 40 either side, take 0.2 and 0.8 of it. + assert!((a[1].p.y - 50.0).abs() < 1e-9 && (a[1].h_in.y - 20.0).abs() < 1e-9 && (a[1].h_out.y - 80.0).abs() < 1e-9, "{a:?}"); + let (to_in, to_out) = (a[1].h_in - a[1].p, a[1].h_out - a[1].p); + assert!(to_in.cross(to_out).abs() < 1e-9 && to_in.dot(to_out) < 0.0, "the handles stay opposite each other: {a:?}"); + // The end that stays keeps its handle, and the grabbed end's handle moves with it. + assert_eq!((a[0].p, a[0].h_out), (Point::new(0.0, 0.0), Point::new(40.0, 0.0))); + assert_eq!((a[2].p, a[2].h_in), (Point::new(200.0, 100.0), Point::new(160.0, 100.0))); +} + +/// A `radius` still bounds the reach on an open path, as it did before the path stretched to its ends. +#[test] +fn reshape_with_a_radius_moves_only_the_anchors_within_it() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_zigzag()); + s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 80.0, "dx": 0.0, "dy": 50.0, "radius": 150.0})).unwrap(); + let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); + assert_eq!(&y[..4], &[0.0, 80.0, 0.0, 80.0], "anchors 200 or more along the path stay: {y:?}"); + assert!(y[4] > 0.0 && y[4] < 50.0 && y[5] == 130.0, "{y:?}"); +} + +#[test] +fn reshape_measures_an_open_path_along_its_control_polygon() { + let mut s = session(); + // The first segment's control polygon is 60 + 100 + 60 long, its chord 100 and its arc 150 to + // 160; the second segment is a line 100 long. + let id = reshape_open_path(&mut s, json!([{"x": 0, "y": 0, "out": [0, 60]}, {"x": 100, "y": 0, "in": [100, 60]}, {"x": 200, "y": 0}])); + s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + let a = reshape_anchors(&s, id); + let u: f64 = 220.0 / 320.0; + assert!((a[1].p.y - 100.0 * reshape_share(u)).abs() < 1e-9, "{a:?}"); + // The in handle lies 160 along the polygon, and takes the share on the tangent line at its anchor. + let handle = reshape_share(u) + 6.0 * u * (1.0 - u) * (160.0 / 320.0 - u); + assert!((a[1].h_in.y - 60.0 - 100.0 * handle).abs() < 1e-9, "{a:?}"); + // The end that stays keeps its handle. + assert_eq!((a[0].p, a[0].h_out), (Point::new(0.0, 0.0), Point::new(0.0, 60.0))); +} + +#[test] +fn reshape_of_a_point_on_a_segment_stretches_an_open_path_from_the_new_anchor() { + let mut s = session(); + let id = reshape_open_path(&mut s, json!([{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 200, "y": 0}, {"x": 300, "y": 0}])); + s.execute("path.reshape", &json!({"id": id.0, "x": 250.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + let p: Vec = reshape_anchors(&s, id).iter().map(|a| a.p).collect(); + assert_eq!(p.len(), 5, "the grab adds an anchor: {p:?}"); + assert_eq!((p[0], p[3], p[4]), (Point::new(0.0, 0.0), Point::new(250.0, 100.0), Point::new(300.0, 0.0)), "{p:?}"); + assert!((p[1].y - 100.0 * reshape_share(0.4)).abs() < 1e-9 && (p[2].y - 100.0 * reshape_share(0.8)).abs() < 1e-9, "{p:?}"); +} + +#[test] +fn reshape_of_an_end_of_a_two_anchor_path_moves_only_that_end() { + let mut s = session(); + let id = reshape_open_path(&mut s, json!([{"x": 0, "y": 0, "out": [30, 0]}, {"x": 100, "y": 0, "in": [70, 0]}])); + s.execute("path.reshape", &json!({"id": id.0, "x": 100.0, "y": 0.0, "dx": 10.0, "dy": 50.0})).unwrap(); + let a = reshape_anchors(&s, id); + assert_eq!((a[0].p, a[0].h_out), (Point::new(0.0, 0.0), Point::new(30.0, 0.0))); + assert_eq!((a[1].p, a[1].h_in), (Point::new(110.0, 50.0), Point::new(80.0, 50.0))); +} + +#[test] +fn reshape_keeps_smooth_anchors_on_both_sides_of_the_grabbed_one_in_line() { + let mut s = session(); + // Smooth anchors with handles of unequal length, on a curve, before and after the grabbed anchor. + let id = reshape_open_path( + &mut s, + json!([ + {"x": 0, "y": 0}, + {"x": 100, "y": 50, "in": [80, 40], "out": [160, 80], "smooth": true}, + {"x": 250, "y": 0, "in": [220, 0], "out": [290, 0], "smooth": true}, + {"x": 400, "y": 60, "in": [340, 90], "out": [420, 50], "smooth": true}, + {"x": 500, "y": 0} + ]), + ); + let before = reshape_anchors(&s, id); + s.execute("path.reshape", &json!({"id": id.0, "x": 250.0, "y": 0.0, "dx": 35.0, "dy": -80.0})).unwrap(); + let a = reshape_anchors(&s, id); + let drag = vectorcraft_geom::Vec2::new(35.0, -80.0); + assert_eq!((a[2].p - before[2].p, a[2].h_in - before[2].h_in, a[2].h_out - before[2].h_out), (drag, drag, drag)); + for i in [1, 3] { + let (to_in, to_out) = (a[i].h_in - a[i].p, a[i].h_out - a[i].p); + assert!(to_in.cross(to_out).abs() < 1e-9 && to_in.dot(to_out) < 0.0, "anchor {i}'s handles stay opposite each other: {a:?}"); + let moved = a[i].p - before[i].p; + assert!( + moved.cross(drag).abs() < 1e-9 && moved.hypot() > 1.0 && moved.hypot() < drag.hypot() - 1.0, + "anchor {i} takes part of the drag: {a:?}" + ); + // The handle on the grabbed anchor's side moves farther than its anchor, and the other one less. + let (h_in, h_out) = ((a[i].h_in - before[i].h_in).hypot(), (a[i].h_out - before[i].h_out).hypot()); + let (nearer, farther) = if i == 1 { (h_out, h_in) } else { (h_in, h_out) }; + assert!(nearer > moved.hypot() + 1.0 && farther < moved.hypot() - 1.0, "anchor {i}: {a:?}"); + } + assert_eq!((a[0].p, a[4].p), (before[0].p, before[4].p)); +} + +#[test] +fn reshape_moves_a_long_handle_against_the_drag_to_keep_its_anchor_smooth() { + let mut s = session(); + let id = + reshape_open_path(&mut s, json!([{"x": 0, "y": 0}, {"x": 100, "y": 0, "in": [10, 0], "out": [190, 0], "smooth": true}, {"x": 400, "y": 0}])); + s.execute("path.reshape", &json!({"id": id.0, "x": 400.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + let a = reshape_anchors(&s, id); + // The anchor is a quarter of the way along and takes 0.15625 of the drag. The tangent line + // there has slope 1.125, and each handle lies 0.225 of the way from the anchor: the in handle + // takes 0.15625 - 0.253125, a share below 0, and the out handle 0.15625 + 0.253125. + assert!((a[1].p.y - 15.625).abs() < 1e-9 && (a[1].h_in.y + 9.6875).abs() < 1e-9 && (a[1].h_out.y - 40.9375).abs() < 1e-9, "{a:?}"); + let (to_in, to_out) = (a[1].h_in - a[1].p, a[1].h_out - a[1].p); + assert!(to_in.cross(to_out).abs() < 1e-9 && to_in.dot(to_out) < 0.0, "the handles stay opposite each other: {a:?}"); +} + +#[test] +fn reshape_refuses_a_negative_radius() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_zigzag()); + let before = reshape_anchors(&s, id); + let steps = s.doc().unwrap().history.undo.len(); + let e = s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 80.0, "dx": 0.0, "dy": 50.0, "radius": -50.0})).unwrap_err(); + assert!(e.to_string().contains("`radius` must be 0 or more"), "{e}"); + assert_eq!(reshape_anchors(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); +} + +#[test] +fn reshape_by_no_distance_on_an_anchor_records_no_undo_step() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_zigzag()); + let before = reshape_anchors(&s, id); + let steps = s.doc().unwrap().history.undo.len(); + for params in [json!({"id": id.0, "x": 300.0, "y": 80.0}), json!({"id": id.0, "x": 120.0, "y": 0.0, "dx": 0.0, "dy": 0.0, "radius": 40.0})] { + s.execute("path.reshape", ¶ms).unwrap(); + } + assert_eq!(reshape_anchors(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // A drag that moves the anchor is still one step. + s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 80.0, "dx": 0.0, "dy": 1.0})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); +} + #[test] fn graph_tool_drag_creates_a_graph_and_asks_for_data() { let mut s = session(); diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index 123c4b1ef..6b496869e 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -4,8 +4,8 @@ //! (Shift holds their turn; click without dragging opens the Flare Tool Options), then a click //! sets the end point of the rings; Space held during either drag moves the flare. The rest comes //! from the tool's options (the Flare Tool Options, which last). Reshape: drag a point of a -//! selected path; the engine's `path.reshape` adds an anchor there if needed and moves the -//! neighbourhood smoothly. +//! selected path; the engine's `path.reshape` adds an anchor there if needed and stretches an open +//! path from that point to its two ends, or moves the neighborhood of the point on a closed path. use serde_json::{Value, json}; use vectorcraft_geom::{Point, Vec2}; diff --git a/docs/mcp.md b/docs/mcp.md index bc2048393..bb06699d4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3607,6 +3607,22 @@ the canvas (coordinates out of range)". is not a whole number of 0 or more, such as 1.5, -1 or "1" ("missing integer `subpath`"); `path.split` refuses such an `anchor` or `segment` in the same way. The document does not change and no undo step is recorded. +`path.reshape {id, x, y, dx, dy, tol?, radius?}` (a Reshape tool drag) grabs the path at `x`, `y`, adding an anchor +there unless one lies within `tol` (3 by default), and moves that point by `dx`, `dy`. +[Adjust path segments](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/adjust-path-segments.html) +has the Reshape tool stretch part of a path from the dragged point without losing the path's general form. How far +the stretch reaches and the share of the drag each point takes are VectorCraft's own rule. An open path stretches from +the grabbed point to its two ends: the two end anchors stay in place, and each anchor between an end and the grabbed +point takes `u² (3 - 2u)` of the drag, where `u` is its distance from that end along the path's control polygon +(anchor, out handle, the next in handle, the next anchor) divided by the grabbed point's distance from that end. A +handle takes the share on the tangent line of that curve at its anchor, so the handles of a smooth anchor stay in line +with it; a handle that is long beside the distance from the end to the grabbed point takes a share below 0 or above 1, +and moves against the drag or farther than the grabbed point. On a closed path, or when `radius` is given, the anchors +within `radius` of the grabbed point along the path follow with a smooth falloff and the others stay; `radius` +defaults to half the diagonal of the subpath's bounding box, and a negative `radius` is refused ("`radius` must be 0 +or more"). A drag of no length on an anchor (`dx` and `dy` 0 or left out) leaves the path as it is and records no undo +step. + These commands refuse a string or another value that is not a number with `` `dy` must be a number, not "10" ``, naming the key; the document does not change and no undo step is recorded: `t`, `dx` and `dy` in `path.reshapeSegment` and `object.blend.spine.reshapeSegment`; `x`, `y` and `t` in `path.curvatureEdit`; `x` and `y` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6bf45e9c8..66ae2c0af 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -927,6 +927,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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. Eraser Tool Options has Angle and Roundness besides Size, as [Erase paths using the Eraser tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html) describes: the Eraser's tip is an ellipse Size across, its long axis at Angle and its short axis Roundness percent of the long one, and a drag with a tip that is not round shows the outline of the area it erases (`path.eraseRegion` `angle` and `roundness`). - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). + A Reshape drag stretches part of a path from the dragged point without losing the path's general form, as [Adjust path segments](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/adjust-path-segments.html) has it. On an open path the stretch reaches both ends: the end anchors stay in place, each anchor between moves less the nearer it lies to an end, and the handles of a smooth anchor stay in line (`path.reshape`; the reach and the shares are VectorCraft's own rule, in `docs/mcp.md`). On a closed path the anchors within half the diagonal of the subpath's bounding box, measured along the path, follow the dragged point. Clicks do not set focal points. Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). Transform Each opens on the scale, move, rotate, reflect, Random, reference point, Transform Objects and Preview it was last confirmed with (kept with the UI state; Copy never is), and its Copy button, left of Cancel, transforms a copy of each object as the [Scale multiple objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-multiple-objects.html) help page has it; the Copy checkbox is gone (#1732; the remembered values follow a maintainer comment on the issue, not the help page). Its Scale section has the Relative and Absolute radio buttons the same page describes: percentages of each object's size, or the width and height it becomes (`object.transformEach` `relative`, which Transform Again repeats); Absolute measures each object's own box, and the scales act along its axes about that box's reference point, so a turned shape keeps its shape and objects of different sizes come out alike; the fields show the width and height the selected objects share and read "Mixed" when they differ or a side is 0 (a line's), each object measured by its own box, rotated with it, as the Transform panel and the Control bar show it (sizes they show alike count as the same), and a size typed into them sets every object to it (a side left Mixed keeps its size), our choices where the page leaves them open; a live Repeat scales its spacing by the geometric mean of the two factors, so its Absolute size is approximate. The link beside the two scales keeps them in proportion, as the shape dialogs' Width and Height link does (`__constrain`, kept with the dialog's values; where it sits is our choice, the public pages don't establish it). The panel lists Scale, Move and Rotate, then the Options in two columns (Transform Objects, Transform Patterns, Scale Strokes & Effects, Scale Corners; Reflect X, Reflect Y, Random), the Reference Point, and Preview at the left of the button row, level with Copy, Cancel and OK. Transform Again uses the Copy, Transform Patterns, Scale Strokes & Effects and Scale Corners choices of the transform it repeats; after a bounding-box handle drag on area type, it resizes the type area again and the type keeps its size (`object.transformAgain`). With anchors selected with Direct Selection, Move, Rotate, Scale, Reflect, Shear and Transform Again (the commands, their dialogs and the four tools' drags) transform only those anchors, their handles with them, about the centre of the box around them unless a point was clicked; a copy still copies the whole object (#1408; behaviour reference: [adjust path segments](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html); the default reference point and the copy are our choices). The Move, Rotate, Scale, Reflect and Shear dialogs have a Preview checkbox, off when they open: while it is on the canvas follows the values, Cancel rolls it back and OK is still one undo step; Up/Down step the Rotate and Shear angles and the Scale percentages by 1, Shift by 10 (#1412). The five dialogs and Transform Each have Transform Objects beside Transform Patterns: off, the art stays and only its pattern tiles transform (`objects` on the transform commands), and Transform Again repeats the choice (#1448; behaviour reference: [scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html); the checkboxes are always enabled and the Scale page's pair is applied to all of them, our choices). Reflect's axis is Horizontal, Vertical or an angle (#1460; [reflect objects along an axis](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/reflect-objects-along-an-axis.html)). From 40382324111bcdaf6a2b8f588b6928fa5c3036c6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 16:28:09 -0800 Subject: [PATCH 10/10] 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.