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"); + } +} 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 c5f664c68..ad9495cf0 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -350,25 +350,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)), @@ -437,12 +434,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 4957f88fa..2973b7113 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -462,6 +462,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/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/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/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/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..0ddd14594 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -8,10 +8,10 @@ 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, + 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, @@ -812,9 +812,9 @@ pub(crate) fn justify_param(v: &str) -> Option { 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 +823,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 +1282,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.rs b/crates/engine/src/tests.rs index 3b2240756..d07a3a980 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3244,7 +3244,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:?}"); @@ -3252,6 +3252,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/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/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(); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 885ce09e1..e584e3e32 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -467,6 +467,84 @@ 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!({"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"), + ] + .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/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/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 92c9d714c..edea838ec 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 38be0d651..cbb4f6826 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2768,6 +2768,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 54924a365..f0c95f7d5 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4093,6 +4093,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 c06987daf..ff247a87a 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 3c6c12416..90663b2d5 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 213897b13..5f27992e3 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3859,6 +3859,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 fcb3939ff..1d8c97a88 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 4b1b05f03..c40bcadc7 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3424,6 +3424,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 6fd604043..2306ddcc4 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 4cc9f920d..04f73a053 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 81b54808e..9dafd1352 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 650035320..154d08fb7 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 099f38f0e..9615debfe 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 c0438e60c..1cda2ae38 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3859,6 +3859,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 b2c251411..27194ca24 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 1de740267..b18fe9578 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 f533f649f..6e2f26683 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 57487bbde..bed01fb46 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 7dd845a98..0ba3df3ff 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3873,6 +3873,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 06b540977..9e7bb2574 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 cbd855bda..15dc67bde 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 b65db8778..65514e543 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 40ccd5776..9c473688c 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 a16ff1fa9..ba5c6fd27 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 7ea8dc620..3f8f96a6e 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 47462bf63..f984ece60 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 438a52bc8..088f96098 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 37027bd8c..48a6fa290 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3800,6 +3800,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 d3ac65c95..e024bf408 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -426,13 +426,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); - } - // OK resets the Units ▸ General preference and leaves the open document's units alone. - d.fields.insert(RESET_UNITS.into(), d.fields.get("unitsGeneral").cloned().unwrap_or(Value::Null)); - } if let Some(v) = d.fields.get("unitsGeneral").cloned() { note_set(&mut d, "unitsGeneral", &v); } @@ -447,9 +440,23 @@ 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, which leaves the open document's units alone unless Units › +/// General is changed after the reset. +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); + } + // OK resets the Units ▸ General preference and leaves the open document's units alone. + d.fields.insert(RESET_UNITS.into(), d.fields.get("unitsGeneral").cloned().unwrap_or(Value::Null)); +} + fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, cat: &str) { let t = Tokens::get(ui.ctx()); let mut section = ""; diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index ccb7ce320..ec3481d94 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -304,6 +304,14 @@ fn click_button(app: &mut VectorcraftApp, label: &str) { frame(app, vec![press(false)]); } +/// Click Reset Preferences and answer its question with OK: Preferences again, its working copy +/// at the defaults. +fn reset_preferences(app: &mut VectorcraftApp) { + click_button(app, "Reset Preferences"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::confirm::RESET_PREFS)); + crate::dialogs::confirm(app).unwrap(); +} + /// Reset Preferences then OK resets the preferences, General included, and leaves the open /// document's units, with no undo step in the document. #[test] @@ -314,7 +322,7 @@ fn reset_preferences_then_ok_leaves_the_documents_units() { a.run("prefs.set", json!({"key": "keyboardIncrement", "value": 5})).unwrap(); let undo = a.session.active().unwrap().history.undo.len(); open(&mut a, Some("General")); - click_button(&mut a, "Reset Preferences"); + reset_preferences(&mut a); let d = a.ui.dialog.as_ref().expect("Reset leaves the dialog open"); assert_eq!(d.fields["keyboardIncrement"], Prefs::default().to_json()["keyboardIncrement"], "the working copy holds the defaults"); assert_eq!(d.fields["unitsGeneral"], json!("points")); @@ -335,7 +343,7 @@ fn reset_preferences_keeps_an_edit_of_general_units() { a.run("file.new", json!({"width": 400, "height": 400})).unwrap(); a.run("document.setUnits", json!({"units": "Millimeters"})).unwrap(); open(&mut a, Some("General")); - click_button(&mut a, "Reset Preferences"); + reset_preferences(&mut a); a.ui.dialog.as_mut().unwrap().fields.insert("unitsGeneral".into(), json!("inches")); confirm(&mut a).unwrap(); assert_eq!(a.session.active().unwrap().doc.units, vectorcraft_doc::Unit::Inches); @@ -344,7 +352,7 @@ fn reset_preferences_keeps_an_edit_of_general_units() { // Between frames, as `ui.dialog.set` sets fields. a.run("document.setUnits", json!({"units": "Millimeters"})).unwrap(); open(&mut a, Some("General")); - click_button(&mut a, "Reset Preferences"); + reset_preferences(&mut a); for u in ["inches", "points"] { crate::dialogs::set_field(a.ui.dialog.as_mut().unwrap(), "unitsGeneral".into(), json!(u)); } @@ -354,7 +362,7 @@ fn reset_preferences_keeps_an_edit_of_general_units() { // Through a frame of the dialog. a.run("document.setUnits", json!({"units": "Millimeters"})).unwrap(); open(&mut a, Some("General")); - click_button(&mut a, "Reset Preferences"); + reset_preferences(&mut a); a.ui.dialog.as_mut().unwrap().fields.insert("unitsGeneral".into(), json!("inches")); click_button(&mut a, "General"); a.ui.dialog.as_mut().unwrap().fields.insert("unitsGeneral".into(), json!("points")); @@ -364,7 +372,7 @@ fn reset_preferences_keeps_an_edit_of_general_units() { let mut b = app(); b.run("prefs.set", json!({"key": "unitsGeneral", "value": "Millimeters"})).unwrap(); open(&mut b, Some("General")); - click_button(&mut b, "Reset Preferences"); + reset_preferences(&mut b); confirm(&mut b).unwrap(); assert_eq!(b.session.prefs.units_general, Prefs::default().units_general); } @@ -786,6 +794,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 d32f3d524..a02a9d796 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1371,7 +1371,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` @@ -1383,6 +1383,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 @@ -2650,7 +2659,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` @@ -3665,6 +3675,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` @@ -4574,7 +4600,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"}}} @@ -7536,8 +7565,12 @@ 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. 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`, 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/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/target-app-parity.md b/docs/target-app-parity.md index b92ae0071..e0eb209f8 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`. @@ -929,6 +930,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)). @@ -1537,7 +1539,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. 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. diff --git a/docs/three-d.md b/docs/three-d.md index bfb626c5a..e6dc279da 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