From ef6d14dc2240931ec02dc508fd027c7a04b62167 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 19:28:17 -0800 Subject: [PATCH] Dimension tool: both ends of a dimension follow the art it measures A linear dimension clicked from an anchor to a point that is not an anchor (a point on a segment, or a point a Smart Guide gave) followed the art at the anchor end only, so scaling the art turned it into a long slanted dimension. - A point on a segment is stored by its curve parameter on that segment, and stays on it when the segment's anchors or handles are edited. - Any other point is stored against anchors (and, for a path whose anchors lie on one line, handles) of the path under it, or of the path at the other end, and moves, scales, turns and reflects with that path. Beside a path whose anchors and handles all lie on one line, such as a straight line, it keeps its distance along and across the path: a reflection moves it as a half turn does, and a scale that is not uniform moves it as a uniform scale does. - When linked art is reflected, a linear dimension's line goes to the reflected side, except for art whose anchors and handles lie on one line. - Deleting the art, changing its anchor count or whether it is closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular leaves the dimension where it was, no longer linked, instead of removing it. Undo links it again. Docs: docs/dimensions.md, docs/target-app-parity.md. --- crates/doc/src/dimension.rs | 314 ++++++++++++++++++- crates/engine/src/cmd/dimension.rs | 35 ++- crates/engine/src/cmd/expand.rs | 2 +- crates/engine/tests/it/dimensions.rs | 430 ++++++++++++++++++++++++++- crates/tools/src/dimension.rs | 92 +++++- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/dimensions.md | 21 +- docs/target-app-parity.md | 1 + 34 files changed, 896 insertions(+), 26 deletions(-) diff --git a/crates/doc/src/dimension.rs b/crates/doc/src/dimension.rs index 301e1beb8..89df89974 100644 --- a/crates/doc/src/dimension.rs +++ b/crates/doc/src/dimension.rs @@ -1,7 +1,7 @@ //! Persistent dimension recipes. Children are ordinary paths and type for all renderers/exporters. use crate::{Document, NodeId, Unit}; use serde::{Deserialize, Serialize}; -use vectorcraft_geom::{Affine, Point, Vec2}; +use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2}; /// What a dimension measures. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -306,6 +306,12 @@ pub enum DimensionSource { a: Option, b: Option, vertex: Option, + /// The first point when it is not an anchor, held against a path's anchors. + #[serde(default, rename = "freeA", skip_serializing_if = "Option::is_none")] + free_a: Option, + /// The second point when it is not an anchor. + #[serde(default, rename = "freeB", skip_serializing_if = "Option::is_none")] + free_b: Option, }, } @@ -315,7 +321,11 @@ impl DimensionSource { Self::Segment { edge } | Self::Arc { edge } => vec![edge.node], Self::Angle { first, second } => vec![first.node, second.node], Self::Circle { node } => vec![*node], - Self::Points { a, b, vertex } => [a, b, vertex].into_iter().filter_map(|p| p.as_ref().map(|r| r.node)).collect(), + Self::Points { a, b, vertex, free_a, free_b } => [a, b, vertex] + .into_iter() + .filter_map(|p| p.as_ref().map(|r| r.node)) + .chain([free_a, free_b].into_iter().filter_map(|p| p.as_ref().map(FreePoint::node))) + .collect(), } } /// Anchor indexes cannot safely follow a topology replacement. Ordinary moves and handle @@ -332,6 +342,34 @@ impl DimensionSource { old.is_some() && old != counts(after) }) } + /// The map that took the measured art from `before` to `after`, when every path it measures + /// went through one [`art_transform`] that reflects it (a negative determinant). None when a + /// measured path is unchanged, or when two of them went through different maps. + pub fn reflection(&self, before: &Document, after: &Document) -> Option { + let mut first: Option = None; + for id in self.nodes() { + let (old, new) = (before.node(id)?, after.node(id)?); + if std::ptr::eq(old, new) || old.path_data() == new.path_data() { + return None; + } + let xf = fitted(before, after, id, |xf| xf.determinant() < 0.0)?; + match first { + None => first = Some(xf), + Some(f) => { + // The linear parts and the translations, each against its own size. + let (a, b) = (f.as_coeffs(), xf.as_coeffs()); + let differ = |range: std::ops::Range, tolerance: f64| { + let size = a[range.clone()].iter().map(|c| c.abs()).fold(1.0, f64::max); + a[range.clone()].iter().zip(&b[range]).any(|(p, q)| (p - q).abs() > tolerance * size) + }; + if differ(0..4, 1e-9) || differ(4..6, 1e-9) { + return None; + } + } + } + } + first + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -348,6 +386,235 @@ impl PointRef { } } +/// The point of an anchor that an [`AnchorWeight`] names: the anchor, or one of its handles. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AnchorPart { + #[default] + Point, + In, + Out, +} + +impl AnchorPart { + fn of(self, a: &Anchor) -> Point { + match self { + Self::Point => a.p, + Self::In => a.h_in, + Self::Out => a.h_out, + } + } +} + +/// One anchor's share of a [`FreePoint`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnchorWeight { + pub subpath: usize, + pub anchor: usize, + /// The anchor itself, or its in or out handle. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub part: AnchorPart, + pub weight: f64, +} + +/// A measured point that is not an anchor (a point on a segment, or a point a Smart Guide gave), +/// linked to one path so that it moves with it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum FreePoint { + /// A point on a segment at curve parameter `t`, 0 at the segment's first anchor and 1 at its + /// last. It stays on the segment when the segment's anchors or handles are edited. + OnSegment { edge: SegmentRef, t: f64 }, + /// A weighted sum of two or three anchors or handles of one path, the weights adding up to 1, + /// plus `across` times the vector from the first anchor to the second turned a quarter turn, + /// from (x, y) to (-y, x), so that a vector pointing right in the y-down document space + /// turns to point down (two anchors only). When the + /// path is moved, scaled, rotated, reflected or sheared, a point held by three moves with + /// it. A point held by two and `across`, beside a straight path, moves, turns and scales + /// uniformly with it, and a reflection or a non-uniform scale moves it as a half turn or a + /// uniform scale would. + Anchors { + node: NodeId, + anchors: Vec, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + across: f64, + }, +} + +/// A point of a path: (subpath, anchor, part, place). +type PathPoint = (usize, usize, AnchorPart, Point); + +/// Every anchor of `path` in order, each followed by its in and out handles when `handles` is set. +fn path_points(path: &PathData, handles: bool) -> impl Iterator + Clone + '_ { + let parts: &'static [AnchorPart] = if handles { &[AnchorPart::Point, AnchorPart::In, AnchorPart::Out] } else { &[AnchorPart::Point] }; + path.subpaths + .iter() + .enumerate() + .flat_map(move |(si, sp)| sp.anchors.iter().enumerate().flat_map(move |(ai, a)| parts.iter().map(move |&part| (si, ai, part, part.of(a))))) +} + +/// Three points that span `points`: the first, the one farthest from it, and the one farthest +/// from the line through those two. None when every point is in one place. +fn spanning(points: impl Iterator + Clone) -> Option<[PathPoint; 3]> { + let first = points.clone().next()?; + let o = first.3; + let second = points.clone().max_by(|x, y| x.3.distance(o).total_cmp(&y.3.distance(o)))?; + let u = second.3 - o; + let span = u.hypot(); + if !span.is_finite() || span <= 1e-6 { + return None; + } + let third = points.max_by(|x, y| u.cross(x.3 - o).abs().total_cmp(&u.cross(y.3 - o).abs()))?; + Some([first, second, third]) +} + +/// The map that takes the anchors and handles of path `node` in `before` to where they are in +/// `after`, when one affine map does that for every one of them: the path was moved, scaled, +/// rotated, reflected or sheared as a whole. None when its anchors changed in number, moved in +/// another way, or lie on one line with all their handles, as on a straight line: a reflection +/// across a line through such a path puts its points where a turn can put them too. +pub fn art_transform(before: &Document, after: &Document, node: NodeId) -> Option { + fitted(before, after, node, |_| true) +} + +/// [`art_transform`] when the map passes `wanted`. The map is found from three points, and +/// `wanted` is asked before every other point is checked against it. +fn fitted(before: &Document, after: &Document, node: NodeId, wanted: impl Fn(&Affine) -> bool) -> Option { + let (old, new) = (before.node(node)?.path_data()?, after.node(node)?.path_data()?); + if old.subpaths.len() != new.subpaths.len() || old.subpaths.iter().zip(&new.subpaths).any(|(o, n)| o.anchors.len() != n.anchors.len()) { + return None; + } + let [o, f, g] = spanning(path_points(old, true))?; + let place = |q: PathPoint| new.subpaths.get(q.0).and_then(|sp| sp.anchors.get(q.1)).map(|a| q.2.of(a)); + let (o2, f2, g2) = (place(o)?, place(f)?, place(g)?); + let (u, v) = (f.3 - o.3, g.3 - o.3); + let span = u.hypot(); + if u.cross(v).abs() <= 1e-9 * span * span { + return None; + } + let basis = |u: Vec2, v: Vec2| Affine::new([u.x, u.y, v.x, v.y, 0.0, 0.0]); + let linear = basis(f2 - o2, g2 - o2) * basis(u, v).inverse(); + let xf = Affine::translate(o2.to_vec2()) * linear * Affine::translate(-o.3.to_vec2()); + if !xf.as_coeffs().iter().all(|c| c.is_finite()) || !wanted(&xf) { + return None; + } + let tolerance = 1e-6 * span.max(1.0); + path_points(old, true).zip(path_points(new, true)).all(|(o, n)| (xf * o.3).distance(n.3) <= tolerance).then_some(xf) +} + +impl FreePoint { + /// Largest weight: a point far outside the anchors that hold it is not held. + const MAX_WEIGHT: f64 = 1e4; + + /// The path it is linked to. + pub fn node(&self) -> NodeId { + match self { + Self::OnSegment { edge, .. } => edge.node, + Self::Anchors { node, .. } => *node, + } + } + + /// `p` held against path `node`: three anchors that span the path; when all its anchors lie + /// on one line or in one place, as on an arch, three of its anchors and handles; when its + /// handles lie on that line too, or so nearly that the weights would be too large, two anchors + /// and `p`'s distance across that line. None when none of these holds `p`. + pub fn on(doc: &Document, node: NodeId, p: Point) -> Option { + let path = doc.node(node)?.path_data()?; + let weight = |(subpath, anchor, part, _): PathPoint, weight| AnchorWeight { subpath, anchor, part, weight }; + for handles in [false, true] { + let Some([o, f, g]) = spanning(path_points(path, handles)) else { continue }; + let (u, v) = (f.3 - o.3, g.3 - o.3); + let span = u.hypot(); + let det = u.cross(v); + if det.abs() > 1e-9 * span * span { + let s = (p - o.3).cross(v) / det; + let t = u.cross(p - o.3) / det; + let anchors = vec![weight(o, 1.0 - s - t), weight(f, s), weight(g, t)]; + // A nearly flat set of points gives weights too large to keep: the next fit is tried. + if let Some(held) = (Self::Anchors { node, anchors, across: 0.0 }).holding(doc, p, span) { + return Some(held); + } + } + } + // Every anchor and handle on one line, or nearly: along it from the first anchor, and across it. + let [o, f, _] = spanning(path_points(path, false))?; + let u = f.3 - o.3; + let span = u.hypot(); + let (t, across) = ((p - o.3).dot(u) / (span * span), u.cross(p - o.3) / (span * span)); + Self::Anchors { node, anchors: vec![weight(o, 1.0 - t), weight(f, t)], across }.holding(doc, p, span) + } + + /// `p` on the segment `edge`, at the curve parameter of the segment's point nearest to it. + /// None when the segment is gone or `p` is not on it. + pub fn on_segment(doc: &Document, edge: &SegmentRef, p: Point) -> Option { + use vectorcraft_geom::kurbo::ParamCurveNearest; + let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?; + if edge.segment >= sp.segment_count() { + return None; + } + let (a, b) = edge.endpoints(doc)?; + let t = sp.segment(edge.segment).nearest(p, 1e-12).t; + Self::OnSegment { edge: edge.clone(), t }.holding(doc, p, a.distance(b)) + } + + /// Itself when it is [`Self::valid`] and puts the point at `p`, within a tolerance that grows + /// with `span`, the size of the art that holds it. + fn holding(self, doc: &Document, p: Point, span: f64) -> Option { + let at = self.position(doc)?; + (at.distance(p) <= 1e-6 * span.max(1.0)).then_some(self) + } + + /// Whether its numbers can be used: a finite `t` from 0 to 1 on a segment; or two or three + /// weights that add up to 1, with `across` set only for two, each finite and at most + /// [`Self::MAX_WEIGHT`] across. + pub fn valid(&self) -> bool { + match self { + Self::OnSegment { t, .. } => (0.0..=1.0).contains(t), + Self::Anchors { anchors, across, .. } => { + (2..=3).contains(&anchors.len()) + && anchors.iter().all(|w| w.weight.is_finite() && w.weight.abs() <= Self::MAX_WEIGHT) + && (anchors.iter().map(|w| w.weight).sum::() - 1.0).abs() <= 1e-9 + && across.is_finite() + && across.abs() <= Self::MAX_WEIGHT + && (*across == 0.0 || anchors.len() == 2) + } + } + } + + /// Where the point is now. None when its segment or an anchor is gone or the point is not + /// [`Self::valid`]. + pub fn position(&self, doc: &Document) -> Option { + use vectorcraft_geom::kurbo::ParamCurve; + if !self.valid() { + return None; + } + let at = match self { + Self::OnSegment { edge, t } => { + let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?; + if edge.segment >= sp.segment_count() { + return None; + } + sp.segment(edge.segment).eval(*t) + } + Self::Anchors { node, anchors, across } => { + let path = doc.node(*node)?.path_data()?; + let place = |w: &AnchorWeight| path.subpaths.get(w.subpath).and_then(|sp| sp.anchors.get(w.anchor)).map(|a| w.part.of(a).to_vec2()); + let mut at = Vec2::ZERO; + for w in anchors { + at += place(w)? * w.weight; + } + if *across != 0.0 { + let u = place(anchors.get(1)?)? - place(anchors.first()?)?; + at += Vec2::new(-u.y, u.x) * *across; + } + at.to_point() + } + }; + (at.x.is_finite() && at.y.is_finite()).then_some(at) + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Dimension { @@ -441,6 +708,36 @@ impl Dimension { self.offset = offset; } } + /// A linear dimension whose art was reflected, its points now moved from `old`'s: its line goes + /// to the side of the points that `image` (where the reflection put `old`'s line) is on, as far + /// past the outermost point on that side as `old`'s line was. A line that lay between the two + /// points is left as it is. + pub fn mirror_line(&mut self, old: &Dimension, image: Point) { + if self.mode != DimensionMode::Linear || old.mode != DimensionMode::Linear { + return; + } + let gap = { + let n = old.normal(); + let across = |p: Point| p.to_vec2().dot(n); + let line = across(old.a) + old.offset; + let (low, high) = (across(old.a).min(across(old.b)), across(old.a).max(across(old.b))); + if line >= high { + line - high + } else if line <= low { + low - line + } else { + return; + } + }; + let n = self.normal(); + let across = |p: Point| p.to_vec2().dot(n); + let (low, high) = (across(self.a).min(across(self.b)), across(self.a).max(across(self.b))); + let at = if 2.0 * across(image) >= low + high { high + gap } else { low - gap }; + let offset = at - across(self.a); + if offset.is_finite() && offset.abs() <= 1e6 { + self.offset = offset; + } + } /// Whether a linear dimension's line lies strictly between its two points along the normal. fn line_between_points(&self) -> bool { let n = self.normal(); @@ -525,10 +822,15 @@ impl Dimension { } pub fn resolve(&mut self, doc: &Document) -> Result<(), String> { match &self.source { - Some(DimensionSource::Points { a, b, vertex }) => { - for (reference, point) in [(a, &mut self.a), (b, &mut self.b), (vertex, &mut self.vertex)] { - if let Some(r) = reference { - *point = r.position(doc).ok_or("Measured anchor no longer exists")?; + Some(DimensionSource::Points { a, b, vertex, free_a, free_b }) => { + for (reference, free, point) in [(a, free_a, &mut self.a), (b, free_b, &mut self.b), (vertex, &None, &mut self.vertex)] { + match (reference, free) { + // An end is an anchor or a free point, never both. + (Some(_), Some(_)) => return Err("Invalid measured point".into()), + (Some(r), None) => *point = r.position(doc).ok_or("Measured anchor no longer exists")?, + (None, Some(f)) if !f.valid() => return Err("Invalid measured point".into()), + (None, Some(f)) => *point = f.position(doc).ok_or("Measured anchor no longer exists")?, + (None, None) => {} } } self.length = if self.mode == DimensionMode::Linear { self.measured() } else { self.a.distance(self.b) }; diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index 01f1effe3..fa2137c9d 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Create Dimension", [], None, - "{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}}, orientation?: aligned|horizontal|vertical (linear only: the distance between the points, or only its horizontal or vertical part; default aligned), offset?: pt (the line's distance from the first point: along the left normal of a → b when aligned, downward when horizontal, leftward when vertical; negative goes the other way), options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits", + "{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}, freeA?, freeB?: a point that is not an anchor, {kind: onSegment, edge: {node,subpath,segment}, t: 0..1 (curve parameter)}|{kind: anchors, node, anchors: [{subpath,anchor,part?: point|in|out (the anchor, default, or its in or out handle),weight}] (2 or 3 points of one path whose weights add up to 1), across?: (2 anchors only: times the first-to-second anchor vector (x,y) turned to (-y,x), which points down when the vector points right)} (an end is an anchor or a free point, not both)}, orientation?: aligned|horizontal|vertical (linear only: the distance between the points, or only its horizontal or vertical part; default aligned), offset?: pt (the line's distance from the first point: along the left normal of a → b when aligned, downward when horizontal, leftward when vertical; negative goes the other way), options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits", has_doc, create ), @@ -231,9 +231,10 @@ fn apply_to_all(s: &mut Session, p: &Value) -> Result { fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> { let old: Vec = doc.node(id).and_then(Node::children).map(|ch| ch.iter().map(|n| n.id).collect()).unwrap_or_default(); let children = artwork(doc, &dim, &old)?; + let name = format!("Dimension · {}", dim.label().map_err(error)?); if let Some(n) = doc.node_mut(id) { n.kind = NodeKind::Group { children, clip: false }; - n.name = Some(format!("Dimension · {}", dim.label().map_err(error)?)); + n.name = Some(name); n.extras_mut().dimension = Some(Box::new(dim)); } Ok(()) @@ -272,7 +273,8 @@ impl EndedLink { /// The [`reached`] dimensions become plain groups, whose art does not redraw or follow what it /// measured. A command that replaces art calls this before it changes anything, since replacing -/// the art a dimension measures would remove the dimension, and then [`restore_unchanged`]. +/// the art a dimension measures would otherwise leave the dimension unlinked, and then +/// [`restore_unchanged`]. pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec { let mut ended = Vec::new(); for id in reached(doc, roots) { @@ -315,18 +317,37 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result { let mut next = old.clone(); let moved = next.moved.take(); if next.source.as_ref().is_some_and(|s| s.topology_changed(before, doc)) || next.resolve(doc).is_err() { - // A deleted/degenerate source has no valid measurement. Remove its annotation in - // this same undo step instead of keeping a silently wrong number. - doc.remove(id)?; + // The art it measured was deleted, or rebuilt with other anchors, or no longer fits + // (an ellipse that was a circle): it stays where it was, no longer linked, in this + // same undo step, rather than attaching to a different edge. + let mut unlinked = old.clone(); + unlinked.source = None; + unlinked.moved = None; + if let Some(v) = moved { + let mut shifted = unlinked.clone(); + (shifted.a, shifted.b, shifted.vertex) = (shifted.a + v, shifted.b + v, shifted.vertex + v); + if shifted.validate().is_ok() { + unlinked = shifted; + } + } + if replace(doc, id, unlinked).is_err() + && let Some(n) = doc.node_mut(id) + { + // Its recipe cannot be drawn again: its art stays as it is, as a plain group. + n.extras_mut().dimension = None; + } changed = true; continue; } next.keep_gap(&old); + if let Some(xf) = next.source.as_ref().and_then(|s| s.reflection(before, doc)) { + next.mirror_line(&old, xf * old.handle()); + } // Moved on its own (the art it measures stayed put): it keeps its new place. if let Some(v) = moved && (next.a, next.b, next.vertex) == (old.a, old.b, old.vertex) { - next.place(next.handle() + v); + next.place(old.handle() + v); if next.resolve(doc).is_err() { next = old.clone(); next.moved = None; diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 546b5a825..c5c0fbd65 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -242,7 +242,7 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result { let to = GradientTo::parse(p)?; let from = s.doc()?.history.undo.len(); let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); - // Before anything expands: outlining the art a dimension measures would remove the dimension. + // Before anything expands: outlining the art a dimension measures would leave the dimension unlinked. let before = s.doc()?.doc.clone(); let mut ended = vec![]; end_dimension_links(s, &mut ended)?; diff --git a/crates/engine/tests/it/dimensions.rs b/crates/engine/tests/it/dimensions.rs index bcb684b0a..934108905 100644 --- a/crates/engine/tests/it/dimensions.rs +++ b/crates/engine/tests/it/dimensions.rs @@ -59,9 +59,11 @@ fn dimension_length_scale_live_edit_undo_and_roundtrip() { run(&mut reopened, "object.scale", json!({"sx":50,"sy":100})); assert_eq!(text(&reopened, id), "10.00 in"); run(&mut reopened, "edit.clear", json!({})); - assert!(reopened.doc().unwrap().doc.node(NodeId(id)).is_none()); + assert_eq!(text(&reopened, id), "10.00 in", "deleting the art leaves the dimension"); + assert!(recipe(&reopened, id).source.is_none(), "no longer linked"); run(&mut reopened, "edit.undo", json!({})); assert_eq!(text(&reopened, id), "10.00 in"); + assert!(recipe(&reopened, id).source.is_some(), "undo links it again"); } /// Documents without dimensions skip the refresh, and lose the flag when the last goes. @@ -567,6 +569,425 @@ fn dimension_anchor_links_and_native_file_survive_source_moves() { assert_eq!(text(&s, id), "100 pt"); } +/// The anchors of path `id`'s first subpath. +fn anchors(s: &Session, id: u64) -> Vec { + let doc = &s.doc().unwrap().doc; + doc.node(NodeId(id)).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect() +} +fn near(p: vectorcraft_geom::Point, q: vectorcraft_geom::Point) -> bool { + p.distance(q) < 1e-6 +} +/// Clicks with the Dimension tool at each point in turn. +fn clicks(s: &mut Session, points: &[(f64, f64)]) -> u64 { + s.select_tool("dimension", ViewInfo::default()).unwrap(); + for &(x, y) in points { + ev(s, PointerKind::Move, x, y); + ev(s, PointerKind::Down, x, y); + ev(s, PointerKind::Up, x, y); + } + s.doc().unwrap().selection.objects[0].0 +} + +/// A dimension from an anchor to a point on a straight segment follows the art at both ends: the +/// segment end keeps its place along the segment when the art is scaled, moved or reflected. +#[test] +fn a_dimension_to_a_point_on_a_segment_follows_the_art_at_both_ends() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]); + let length = recipe(&s, id).length; + for (command, p) in [ + ("object.scale", json!({"sx":150,"sy":150})), + ("object.move", json!({"dx":40,"dy":-30})), + ("object.rotate", json!({"angle":30})), + ("object.reflect", json!({"axis":"vertical"})), + ] { + run(&mut s, "select.set", json!({"ids":[rect]})); + run(&mut s, command, p); + let (d, at) = (recipe(&s, id), anchors(&s, rect)); + assert!(near(d.a, at[0]), "{command}: {:?} {:?}", d.a, at[0]); + assert!(near(d.b, at[1].midpoint(at[2])), "{command}: {:?} {:?}", d.b, at[1].midpoint(at[2])); + assert!(d.source.is_some(), "{command}: still linked"); + } + assert!((recipe(&s, id).length - 1.5 * length).abs() < 1e-6); +} + +/// A point off the art is held against the anchors of the art at the other end, and moves with it. +#[test] +fn a_dimension_to_a_point_off_the_art_moves_with_the_art() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(200.0, 200.0), (330.0, 260.0), (260.0, 330.0)]); + let before = recipe(&s, id); + run(&mut s, "select.set", json!({"ids":[rect]})); + run(&mut s, "object.move", json!({"dx":40,"dy":-30})); + let d = recipe(&s, id); + let v = vectorcraft_geom::Vec2::new(40.0, -30.0); + assert!(near(d.a, before.a + v) && near(d.b, before.b + v), "{before:?} → {d:?}"); + assert!((d.length - before.length).abs() < 1e-6 && (d.offset - before.offset).abs() < 1e-6); + run(&mut s, "object.scale", json!({"sx":200,"sy":200})); + assert!((recipe(&s, id).length - 2.0 * before.length).abs() < 1e-6); +} + +/// When the art it measures is deleted or gets another number of anchors, a dimension stays where +/// it was, no longer linked: later edits of the art do not move it, and undo links it again. +#[test] +fn a_dimension_whose_art_changes_its_anchors_stays_where_it_was() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]); + let before = recipe(&s, id); + let label = text(&s, id); + run(&mut s, "path.insertAnchor", json!({"id":rect,"segment":3,"t":0.5})); + let d = recipe(&s, id); + assert!(d.source.is_none(), "no longer linked"); + assert_eq!((d.a, d.b, d.offset, text(&s, id)), (before.a, before.b, before.offset, label.clone())); + run(&mut s, "select.set", json!({"ids":[rect]})); + run(&mut s, "object.scale", json!({"sx":150,"sy":150})); + assert_eq!((recipe(&s, id).a, recipe(&s, id).b), (before.a, before.b), "does not follow the art"); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(recipe(&s, id).source, before.source, "undo links it again"); + run(&mut s, "select.set", json!({"ids":[rect]})); + run(&mut s, "edit.clear", json!({})); + assert!(s.doc().unwrap().doc.node(NodeId(rect)).is_none()); + assert_eq!((recipe(&s, id).source, text(&s, id)), (None, label)); +} + +/// A linked dimension whose art is reflected has its line on the reflected side: a line above the +/// top edge is below that edge's points after a flip top to bottom. +#[test] +fn a_linked_dimension_of_reflected_art_has_its_line_on_the_reflected_side() { + use vectorcraft_doc::dimension::Orientation; + for orientation in ["aligned", "horizontal"] { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let at = anchors(&s, rect); + let top = |p: vectorcraft_geom::Point| (p.y - 100.0).abs() < 1e-9; + let (i, j) = (at.iter().position(|p| top(*p)).unwrap(), at.iter().rposition(|p| top(*p)).unwrap()); + let anchor = |anchor: usize| json!({"node":rect,"subpath":0,"anchor":anchor}); + let src = json!({"kind":"points","a":anchor(i),"b":anchor(j)}); + // The line 30 pt above the top edge: the offset is along the left normal of a → b when + // aligned, downward when horizontal. + let offset = if orientation == "aligned" && at[i].x > at[j].x { 30.0 } else { -30.0 }; + let id = run(&mut s, "dimension.create", json!({"source":src,"orientation":orientation,"offset":offset}))["id"].as_u64().unwrap(); + let line_y = |s: &Session| recipe(s, id).line_ends().0.y; + assert!((line_y(&s) - 70.0).abs() < 1e-6, "{orientation}: {}", line_y(&s)); + run(&mut s, "select.set", json!({"ids":[rect]})); + run(&mut s, "object.reflect", json!({"axis":"horizontal"})); + let d = recipe(&s, id); + assert!(d.source.is_some()); + assert!(((d.a.y, d.b.y) == (200.0, 200.0)), "{orientation}: {d:?}"); + assert!((line_y(&s) - 230.0).abs() < 1e-6, "{orientation}: line at {}", line_y(&s)); + let want = if orientation == "aligned" { Orientation::Aligned } else { Orientation::Horizontal }; + assert_eq!(d.orientation, want); + run(&mut s, "edit.undo", json!({})); + assert!((line_y(&s) - 70.0).abs() < 1e-6); + } +} + +/// Reflected across the line through the two points it measures, an arch keeps those points +/// where they were, and the dimension's line goes to the other side with the arch. +#[test] +fn a_dimension_of_art_reflected_across_its_points_changes_sides() { + let mut s = session(); + let arch = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]); + let curve = run(&mut s, "path.create", json!({"anchors":arch,"closed":false}))["id"].as_u64().unwrap(); + let anchor = |anchor: u64| json!({"node":curve,"subpath":0,"anchor":anchor}); + let src = json!({"kind":"points","a":anchor(0),"b":anchor(1)}); + let id = run(&mut s, "dimension.create", json!({"source":src,"offset":-30}))["id"].as_u64().unwrap(); + assert!((recipe(&s, id).line_ends().0.y - 170.0).abs() < 1e-6); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.reflect", json!({"axis":"horizontal","origin":[0,200]})); + let d = recipe(&s, id); + assert_eq!((d.a, d.b), (vectorcraft_geom::Point::new(100.0, 200.0), vectorcraft_geom::Point::new(200.0, 200.0))); + assert!((d.line_ends().0.y - 230.0).abs() < 1e-6, "below with the arch: {:?}", d.line_ends()); + // Reflected together with the art, it ends in the same place. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "select.set", json!({"ids":[curve, id]})); + run(&mut s, "object.reflect", json!({"axis":"horizontal","origin":[0,200]})); + assert!((recipe(&s, id).line_ends().0.y - 230.0).abs() < 1e-6, "{:?}", recipe(&s, id).line_ends()); +} + +/// A dimension between two paths that are each reflected in their own way in one edit is not +/// mirrored by the map of one of them. +#[test] +fn a_dimension_between_paths_reflected_each_in_its_own_way_keeps_its_offset() { + let mut s = session(); + let low = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]); + let high = json!([{"x":300,"y":200,"out":[300,100]},{"x":400,"y":200,"in":[400,100]}]); + let first = run(&mut s, "path.create", json!({"anchors":low,"closed":false}))["id"].as_u64().unwrap(); + let second = run(&mut s, "path.create", json!({"anchors":high,"closed":false}))["id"].as_u64().unwrap(); + let src = json!({"kind":"points","a":{"node":first,"subpath":0,"anchor":0},"b":{"node":second,"subpath":0,"anchor":1}}); + let id = run(&mut s, "dimension.create", json!({"source":src,"offset":30}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[first, second]})); + run(&mut s, "object.transformEach", json!({"reflectY":true})); + let d = recipe(&s, id); + assert!(d.source.is_some() && (d.a.y - d.b.y).abs() > 1.0, "{d:?}"); + assert!((d.offset - 30.0).abs() < 1e-6, "{d:?}"); +} + +/// A recipe's free points are checked: weights that do not add up to 1, or an end that is both an +/// anchor and a free point, are refused without changing the document. +#[test] +fn dimension_free_points_are_checked() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let original = serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); + let w = |anchor: u64, weight: f64| json!({"subpath":0,"anchor":anchor,"weight":weight}); + let held = |anchors: Value, across: f64| json!({"kind":"anchors","node":rect,"anchors":anchors,"across":across}); + let on = |segment: u64, t: f64| json!({"kind":"onSegment","edge":{"node":rect,"subpath":0,"segment":segment},"t":t}); + let anchor = json!({"node":rect,"subpath":0,"anchor":0}); + for free in [ + held(json!([w(1, 0.5), w(2, 0.6)]), 0.0), + held(json!([w(1, 1.0)]), 0.0), + held(json!([w(1, 1e300), w(2, -1e300)]), 0.0), + held(json!([w(1, 0.5), w(99, 0.5)]), 0.0), + held(json!([w(1, 0.5), w(2, f64::MAX)]), 0.0), + held(json!([w(1, 0.5), w(2, 0.5)]), 1e300), + held(json!([w(1, 0.5), w(2, 0.25), w(3, 0.25)]), 0.5), + on(1, 1.5), + on(1, -0.1), + on(99, 0.5), + json!({"kind":"elsewhere","node":rect}), + ] { + let p = json!({"source":{"kind":"points","a":anchor,"freeB":free}}); + assert!(s.execute("dimension.create", &p).is_err(), "{p}"); + assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), original); + } + let both = json!({"source":{"kind":"points","a":anchor,"freeA":held(json!([w(1, 0.5), w(2, 0.5)]), 0.0),"b":anchor}}); + assert!(s.execute("dimension.create", &both).is_err()); + let at = anchors(&s, rect); + for (free, want) in [(held(json!([w(1, 0.5), w(2, 0.5)]), 0.0), at[1].midpoint(at[2])), (on(1, 0.5), at[1].midpoint(at[2]))] { + let ok = json!({"source":{"kind":"points","a":anchor,"freeB":free}}); + let id = run(&mut s, "dimension.create", ok)["id"].as_u64().unwrap(); + assert!(near(recipe(&s, id).b, want), "{:?}", recipe(&s, id).b); + } +} + +/// A point off a straight two-anchor line is held by its distance along and across the line: it +/// moves, turns and scales with the line. +#[test] +fn a_dimension_to_a_point_off_a_line_follows_the_line() { + let mut s = session(); + let line = run(&mut s, "shape.line", json!({"x1":100,"y1":100,"x2":200,"y2":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (150.0, 160.0), (60.0, 130.0)]); + let d = recipe(&s, id); + assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(line), NodeId(line)]), "{:?}", d.source); + run(&mut s, "dimension.update", json!({"ids":[id],"orientation":"aligned"})); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.rotate", json!({"angle":90,"origin":[100,100]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":200,"origin":[100,100]})); + run(&mut s, "object.move", json!({"dx":10,"dy":20})); + let at = anchors(&s, line); + let (o, u) = (at[0], at[1] - at[0]); + // 50 along the line and 60 across it before, now in the line's new frame at twice the size. + let want = o + u * 0.5 + vectorcraft_geom::Vec2::new(-u.y, u.x) * 0.6; + let d = recipe(&s, id); + assert!(near(d.a, o) && near(d.b, want), "{d:?} want {want:?}"); + assert!((d.length - 2.0 * (50.0f64.hypot(60.0))).abs() < 1e-6); +} + +/// A point on a straight segment stays on the segment when its handles are edited into a curve. +#[test] +fn a_point_on_a_segment_stays_on_it_when_the_segment_curves() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]); + let at = anchors(&s, rect); + assert!(near(at[1], vectorcraft_geom::Point::new(200.0, 100.0)) && near(at[2], vectorcraft_geom::Point::new(200.0, 200.0)), "{at:?}"); + let a = |p: vectorcraft_geom::Point| json!({"x":p.x,"y":p.y}); + let mut curved = vec![a(at[0]), a(at[1]), a(at[2]), a(at[3])]; + curved[1]["out"] = json!([240, 110]); + curved[2]["in"] = json!([240, 190]); + run(&mut s, "path.setAnchors", json!({"id":rect,"subpaths":[{"anchors":curved,"closed":true}]})); + let d = recipe(&s, id); + assert!(d.source.is_some(), "still linked"); + assert!(near(d.b, vectorcraft_geom::Point::new(230.0, 150.0)), "on the curve: {:?}", d.b); +} + +/// The line of a linked dimension of a curved two-anchor path goes to the reflected side when the +/// path is reflected: its handles tell a reflection from a turn. +#[test] +fn a_linked_dimension_of_a_reflected_curve_has_its_line_on_the_reflected_side() { + let mut s = session(); + let arch = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]); + let curve = run(&mut s, "path.create", json!({"anchors":arch,"closed":false}))["id"].as_u64().unwrap(); + let anchor = |anchor: u64| json!({"node":curve,"subpath":0,"anchor":anchor}); + let src = json!({"kind":"points","a":anchor(0),"b":anchor(1)}); + // The line 30 pt below the two points (the left normal of a → b points down). + let id = run(&mut s, "dimension.create", json!({"source":src,"offset":30}))["id"].as_u64().unwrap(); + assert!((recipe(&s, id).line_ends().0.y - 230.0).abs() < 1e-6); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.reflect", json!({"axis":"vertical"})); + let d = recipe(&s, id); + assert!((d.a.y, d.b.y) == (200.0, 200.0) && d.a.x > d.b.x, "{d:?}"); + assert!((d.line_ends().0.y - 230.0).abs() < 1e-6, "below the points: {:?}", d.line_ends()); + run(&mut s, "object.reflect", json!({"axis":"horizontal"})); + let d = recipe(&s, id); + assert!((d.line_ends().0.y - (d.a.y - 30.0)).abs() < 1e-6, "above the points: {d:?}"); +} + +/// A point off an arch, whose anchors lie on one line, is held against its anchors and handles: +/// it is reflected with the arch, to the same side as the dimension's line. +#[test] +fn a_point_off_an_arch_is_reflected_with_the_arch() { + let mut s = session(); + let arch = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]); + let curve = run(&mut s, "path.create", json!({"anchors":arch,"closed":false}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 200.0), (150.0, 120.0), (60.0, 120.0)]); + let d = recipe(&s, id); + assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(curve), NodeId(curve)]), "{:?}", d.source); + assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 120.0)), "{:?}", d.b); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.reflect", json!({"axis":"horizontal","origin":[0,200]})); + let d = recipe(&s, id); + assert!(near(d.a, vectorcraft_geom::Point::new(100.0, 200.0)), "{:?}", d.a); + assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 280.0)), "below with the arch: {:?}", d.b); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.reflect", json!({"axis":"vertical","origin":[150,0]})); + let d = recipe(&s, id); + assert!(near(d.a, vectorcraft_geom::Point::new(200.0, 200.0)), "{:?}", d.a); + assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 120.0)), "above with the arch: {:?}", d.b); +} + +/// A point beside a curve that is almost straight is still held against it. +#[test] +fn a_point_beside_an_almost_straight_curve_follows_it() { + let mut s = session(); + let flat = json!([{"x":100,"y":200,"out":[100,199.999]},{"x":200,"y":200}]); + let curve = run(&mut s, "path.create", json!({"anchors":flat,"closed":false}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 200.0), (150.0, 150.0), (60.0, 120.0)]); + let d = recipe(&s, id); + assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(curve), NodeId(curve)]), "{:?}", d.source); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.move", json!({"dx":40,"dy":-30})); + let d = recipe(&s, id); + assert!(near(d.b, vectorcraft_geom::Point::new(190.0, 120.0)), "{:?}", d.b); +} + +/// A point off a loop whose two anchors are in one place is held against the loop's handles. +#[test] +fn a_point_off_a_loop_with_its_anchors_in_one_place_follows_it() { + let mut s = session(); + let tear = json!([{"x":100,"y":200,"out":[60,120]},{"x":100,"y":200,"in":[140,120]}]); + let curve = run(&mut s, "path.create", json!({"anchors":tear,"closed":false}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 200.0), (100.0, 60.0), (30.0, 130.0)]); + let d = recipe(&s, id); + assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(curve), NodeId(curve)]), "{:?}", d.source); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.move", json!({"dx":40,"dy":-30})); + let d = recipe(&s, id); + assert!(near(d.b, vectorcraft_geom::Point::new(140.0, 30.0)), "{:?}", d.b); +} + +/// Beside a straight two-anchor line, a point is moved by a reflection as a half turn would move +/// it: the line's positions alone do not tell the two apart. +#[test] +fn a_point_beside_a_reflected_line_moves_as_a_half_turn_would() { + let mut s = session(); + let line = run(&mut s, "shape.line", json!({"x1":100,"y1":100,"x2":200,"y2":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (150.0, 160.0), (60.0, 130.0)]); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.reflect", json!({"axis":"vertical","origin":[150,0]})); + let d = recipe(&s, id); + assert!(d.source.is_some()); + assert!(near(d.a, vectorcraft_geom::Point::new(200.0, 100.0)), "{:?}", d.a); + assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 40.0)), "{:?}", d.b); +} + +/// Opening a closed path that a dimension measures leaves the dimension where it was, no longer +/// linked, and undo links it again. +#[test] +fn a_dimension_whose_art_is_opened_stays_where_it_was() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]); + let before = recipe(&s, id); + let label = text(&s, id); + let a = |p: vectorcraft_geom::Point| json!({"x":p.x,"y":p.y}); + let open: Vec = anchors(&s, rect).into_iter().map(a).collect(); + run(&mut s, "path.setAnchors", json!({"id":rect,"subpaths":[{"anchors":open,"closed":false}]})); + let d = recipe(&s, id); + assert!(d.source.is_none(), "no longer linked"); + assert_eq!((d.a, d.b, d.offset, text(&s, id)), (before.a, before.b, before.offset, label)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(recipe(&s, id).source, before.source, "undo links it again"); +} + +/// An angle whose arm becomes a curve, and an arc radius whose arc stops being circular, stay where +/// they were, no longer linked. +#[test] +fn angle_and_arc_dimensions_whose_art_no_longer_fits_stay_where_they_were() { + let mut s = session(); + let first = edge(&mut s); + let second = run(&mut s, "shape.line", json!({"x1":100,"y1":100,"x2":100,"y2":172}))["id"].as_u64().unwrap(); + let angle = json!({"mode":"angular","source":{"kind":"angle","first":{"node":first,"subpath":0,"segment":0},"second":{"node":second,"subpath":0,"segment":0}}}); + let id = run(&mut s, "dimension.create", angle)["id"].as_u64().unwrap(); + let label = text(&s, id); + let curved = json!([{"x":100,"y":100,"out":[140,130]},{"x":100,"y":172}]); + run(&mut s, "path.setAnchors", json!({"id":second,"subpaths":[{"anchors":curved,"closed":false}]})); + assert_eq!((recipe(&s, id).source, text(&s, id)), (None, label)); + let circle = run(&mut s, "shape.ellipse", json!({"x":300,"y":100,"width":144,"height":144}))["id"].as_u64().unwrap(); + let arc = json!({"mode":"radius","source":{"kind":"arc","edge":{"node":circle,"subpath":0,"segment":0}},"options":{"units":"in","precision":1}}); + let id = run(&mut s, "dimension.create", arc)["id"].as_u64().unwrap(); + assert_eq!(text(&s, id), "1.0 in"); + run(&mut s, "select.set", json!({"ids":[circle]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":100})); + assert_eq!((recipe(&s, id).source, text(&s, id)), (None, "1.0 in".to_string())); +} + +/// Saved and opened again, a dimension to points that are not anchors still follows its art. +#[test] +fn free_points_survive_a_native_file() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(200.0, 150.0), (330.0, 260.0), (260.0, 330.0)]); + let before = recipe(&s, id); + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let mut reopened = Session::new(); + reopened.add_document(doc, None); + assert_eq!(recipe(&reopened, id).source, before.source); + run(&mut reopened, "select.set", json!({"ids":[rect]})); + run(&mut reopened, "object.move", json!({"dx":40,"dy":-30})); + let d = recipe(&reopened, id); + let v = vectorcraft_geom::Vec2::new(40.0, -30.0); + assert!(near(d.a, before.a + v) && near(d.b, before.b + v), "{before:?} → {d:?}"); +} + +/// A linked dimension opened from a file with options it cannot be drawn with keeps its art, as a +/// plain group, when the art it measures is deleted. +#[test] +fn a_dimension_that_cannot_be_drawn_again_keeps_its_art() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 100.0), (150.0, 80.0)]); + let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let mut v: Value = serde_json::from_slice(&saved).unwrap(); + fn bad_precision(v: &mut Value) { + match v { + Value::Object(m) => { + if let Some(Value::Object(o)) = m.get_mut("dimension").and_then(|d| d.get_mut("options")) { + o.insert("precision".into(), json!(99)); + } + m.values_mut().for_each(bad_precision); + } + Value::Array(a) => a.iter_mut().for_each(bad_precision), + _ => {} + } + } + bad_precision(&mut v); + let mut reopened = Session::new(); + reopened.add_document(vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap(), None); + let art = reopened.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap().clone(); + run(&mut reopened, "select.set", json!({"ids":[rect]})); + run(&mut reopened, "edit.clear", json!({})); + let n = reopened.doc().unwrap().doc.node(NodeId(id)).cloned().expect("the dimension's art stays"); + assert!(n.extras().dimension.is_none(), "no longer a live dimension"); + assert_eq!(n.children().unwrap(), &art); +} + /// A linked dimension moved on its own keeps its new place when its art changes later; moved with /// its art, it keeps its distance from it. #[test] @@ -653,7 +1074,8 @@ fn an_expanded_dimension_stays_plain_art() { assert_eq!((art(&s, id), bounds(&s, id)), (plain, at)); run(&mut s, "edit.clear", json!({})); assert_eq!((art(&s, id), bounds(&s, id)), (plain, at)); - assert!(s.doc().unwrap().doc.node(NodeId(other)).is_none()); + assert_eq!(text(&s, other), "20.00 in", "the live dimension stays, no longer linked"); + assert!(recipe(&s, other).source.is_none()); // Saved and opened again, it is still plain art. let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); let mut reopened = Session::new(); @@ -975,7 +1397,9 @@ fn dimension_linked_angle_and_circle_update_and_reject_non_circles() { run(&mut s, "object.scale", json!({"sx":200,"sy":200})); assert_eq!(text(&s, id), "4 in"); run(&mut s, "object.scale", json!({"sx":200,"sy":100})); - assert!(s.doc().unwrap().doc.node(NodeId(id)).is_none()); + // An ellipse has no single radius: the dimension stays where it was, no longer linked. + assert_eq!(text(&s, id), "4 in"); + assert!(recipe(&s, id).source.is_none()); assert!(s.execute("dimension.create", &json!({"mode":"radius","source":{"kind":"circle","node":circle}})).is_err()); } diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index d13227369..7a3d3de76 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -5,8 +5,8 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; use serde_json::{Value, json}; use vectorcraft_doc::{ - Node, - dimension::{self, Dimension, DimensionMode, DimensionOptions, DimensionSource, Orientation, PointRef, SegmentRef}, + Node, NodeId, + dimension::{self, Dimension, DimensionMode, DimensionOptions, DimensionSource, FreePoint, Orientation, PointRef, SegmentRef}, }; use vectorcraft_geom::{BezPath, Point, Vec2}; @@ -213,6 +213,30 @@ fn nearest_on(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option { (q.x.is_finite() && q.y.is_finite()).then_some(q) } +/// What a dimension between two picked points links to. A point on an anchor links to the anchor. +/// A point on a segment keeps its place along the segment. Any other point is held +/// against the anchors of the path under it, or, with no path under it, the path of the other +/// point, so that it moves with that path. None when neither point has a path to link to. +fn points_source(cx: &ToolContext, (a, ra): (Point, &Option), (b, rb): (Point, &Option)) -> Option { + let (edge_a, edge_b) = (pick(cx, a), pick(cx, b)); + let node = |r: &Option, e: &Option| r.as_ref().map(|r| r.node).or_else(|| e.as_ref().map(|e| e.node)); + let (path_a, path_b) = (node(ra, &edge_a), node(rb, &edge_b)); + let free = |p: Point, r: &Option, edge: &Option, own: Option, other: Option| { + if r.is_some() { + return None; + } + edge.as_ref().and_then(|e| FreePoint::on_segment(cx.doc, e, p)).or_else(|| own.or(other).and_then(|node| FreePoint::on(cx.doc, node, p))) + }; + let (free_a, free_b) = (free(a, ra, &edge_a, path_a, path_b), free(b, rb, &edge_b, path_b, path_a)); + (ra.is_some() || rb.is_some() || free_a.is_some() || free_b.is_some()).then(|| DimensionSource::Points { + a: ra.clone(), + b: rb.clone(), + vertex: None, + free_a, + free_b, + }) +} + impl DimensionTool { /// Where a Linear pick at `p` lands: on the nearest anchor of any path, within reach (kept as /// a link to its path); else where Smart Guides put it (another object's anchor or centre, in @@ -364,7 +388,7 @@ impl DimensionTool { } self.points.push((at, reference)); if let [(a, ra), (b, rb)] = self.points.as_slice() { - let source = (ra.is_some() || rb.is_some()).then(|| DimensionSource::Points { a: ra.clone(), b: rb.clone(), vertex: None }); + let source = points_source(cx, (*a, ra), (*b, rb)); let mut d = self.recipe(DimensionMode::Linear, *a, *b, Point::ZERO, source); if d.resolve(cx.doc).is_ok() { self.second = Some((*b, p)); @@ -667,7 +691,7 @@ mod tests { let d = t.pending.as_ref().expect("both anchors were picked"); assert_eq!((d.a, d.b), (Point::new(100.0, 100.0), Point::new(200.0, 200.0))); let anchor = |anchor| Some(PointRef { node: id, subpath: 0, anchor }); - assert_eq!(d.source, Some(DimensionSource::Points { a: anchor(0), b: anchor(2), vertex: None })); + assert_eq!(d.source, Some(DimensionSource::Points { a: anchor(0), b: anchor(2), vertex: None, free_a: None, free_b: None })); // Dragged past the anchor's reach, the press drags out the dimension of a segment there. let mut t = DimensionTool::default(); t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0)); @@ -677,6 +701,66 @@ mod tests { assert!(matches!(t.pointer(&c, &ev(PointerKind::Up, 100.0, 60.0)).last(), Some(Action::Commit))); } + /// A point picked on a segment, not on an anchor, keeps its place along the segment. A point + /// off the art is held against the anchors of the other point's path. Both then follow the + /// path, as the anchor at the other end does. + #[test] + fn points_off_anchors_are_held_against_the_path() { + use vectorcraft_doc::dimension::FreePoint; + let (d, id) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let click = |t: &mut DimensionTool, x: f64, y: f64| { + t.pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, x, y)); + }; + let mut t = DimensionTool::default(); + click(&mut t, 100.0, 100.0); + click(&mut t, 200.0, 150.0); + let pending = t.pending.as_ref().expect("both points were picked"); + let anchor = Some(PointRef { node: id, subpath: 0, anchor: 0 }); + let Some(DimensionSource::Points { a, b: None, vertex: None, free_a: None, free_b: Some(FreePoint::OnSegment { edge, t }) }) = + &pending.source + else { + panic!("{:?}", pending.source) + }; + assert_eq!((a, edge), (&anchor, &SegmentRef { node: id, subpath: 0, segment: 1 })); + assert!((t - 0.5).abs() < 1e-9, "{t}"); + let mut t = DimensionTool::default(); + click(&mut t, 400.0, 400.0); + click(&mut t, 200.0, 200.0); + let pending = t.pending.as_ref().expect("both points were picked"); + let Some(DimensionSource::Points { free_a: Some(free), b: Some(b), .. }) = &pending.source else { panic!("{:?}", pending.source) }; + let FreePoint::Anchors { node, anchors, across } = free else { panic!("{free:?}") }; + assert_eq!((*node, anchors.len(), *across, b.node, b.anchor), (id, 3, 0.0, id, 2)); + let at = free.position(&d).expect("the anchors that hold it exist"); + assert!(at.distance(pending.a) < 1e-9, "{at:?} {:?}", pending.a); + } + + /// A point that Smart Guides put in line with the path's center is held, where the guide put + /// it, against the anchors of the path at the other end. + #[test] + fn a_point_smart_guides_place_is_held_against_the_path() { + use vectorcraft_doc::dimension::FreePoint; + let (d, id) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + assert!(c.smart_guides); + let mut t = DimensionTool::default(); + for (x, y) in [(200.0, 200.0), (151.5, 330.0)] { + t.pointer(&c, &PointerEvent::new(PointerKind::Move, x, y)); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, x, y)); + } + let pending = t.pending.as_ref().expect("both points were picked"); + assert!((pending.b.x - 150.0).abs() < 1e-9, "in line with the center: {:?}", pending.b); + let Some(DimensionSource::Points { a: Some(a), free_b: Some(free), .. }) = &pending.source else { panic!("{:?}", pending.source) }; + assert!(matches!(free, FreePoint::Anchors { node, .. } if *node == id), "{free:?}"); + assert_eq!(a.node, id); + let at = free.position(&d).expect("the anchors that hold it exist"); + assert!(at.distance(pending.b) < 1e-9, "{at:?} {:?}", pending.b); + } + /// The anchors of a live rectangle are picked the same way. #[test] fn a_press_on_a_live_shapes_anchor_picks_it() { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 4ac5d5dd6..a438b240e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5535,6 +5535,7 @@ theme Appearance Външен вид @msg Invalid drawing scale Невалиден мащаб на чертежа @msg Invalid real scale Невалиден реален мащаб @msg Measured anchor no longer exists Измерената опорна точка вече не съществува +@msg Invalid measured point Невалидна измерена точка @msg Measured segment no longer exists Измереният сегмент вече не съществува @msg Non-finite dimension Размерът не е крайно число @msg Only linear dimensions are horizontal or vertical Само линейните размери са хоризонтални или вертикални diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index aa7673542..d88c22324 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5583,6 +5583,7 @@ mark-style Japanese Japanski @msg Invalid drawing scale Nevažeća skala crteža @msg Invalid real scale Nevažeća stvarna skala @msg Measured anchor no longer exists Izmjereno sidro više ne postoji +@msg Invalid measured point Neispravna izmjerena tačka @msg Measured segment no longer exists Izmjereni segment više ne postoji @msg Non-finite dimension Nekonačna kota @msg Only linear dimensions are horizontal or vertical Samo linearne kote su horizontalne ili vertikalne diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f14cc12c4..d0ee71c65 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5360,6 +5360,7 @@ digits Malayalam Malaiàlam @msg Make Guides: select paths Crea guies: seleccioneu traçats @msg mask art ends outside a mask dictionary l’art de la màscara acaba fora d’un diccionari de màscara @msg Measured anchor no longer exists El punt d’ancoratge mesurat ja no existeix +@msg Invalid measured point El punt mesurat no és vàlid @msg Measured segment no longer exists El segment mesurat ja no existeix @msg missing `folder` falta `folder` @msg missing `id` (see plugin.list) falta `id` (vegeu plugin.list) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9f2e06fd1..9f80a397c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5714,6 +5714,7 @@ mark-style Japanese Japonské @msg Invalid drawing scale Neplatné měřítko výkresu @msg Invalid real scale Neplatné skutečné měřítko @msg Measured anchor no longer exists Měřený kotevní bod už neexistuje +@msg Invalid measured point Neplatný měřený bod @msg Measured segment no longer exists Měřený segment už neexistuje @msg Non-finite dimension Kóta není konečné číslo @msg Only linear dimensions are horizontal or vertical Vodorovné nebo svislé jsou jen lineární kóty diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 577f4d1ee..af4ac9b0a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5305,6 +5305,7 @@ mark-style Japanese Japansk @msg Invalid drawing scale Ugyldig tegningsmålestok @msg Invalid real scale Ugyldig virkelig målestok @msg Measured anchor no longer exists Det målte ankerpunkt findes ikke længere +@msg Invalid measured point Ugyldigt målt punkt @msg Measured segment no longer exists Det målte segment findes ikke længere @msg Non-finite dimension Ikke-endeligt mål @msg Only linear dimensions are horizontal or vertical Kun lineære mål er vandrette eller lodrette diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index cd44eba7d..c0d48f2e9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5291,6 +5291,7 @@ effect Soft Weich @msg Invalid drawing scale Ungültiger Zeichnungsmaßstab @msg Invalid real scale Ungültiger realer Maßstab @msg Measured anchor no longer exists Der gemessene Ankerpunkt existiert nicht mehr +@msg Invalid measured point Ungültiger gemessener Punkt @msg Measured segment no longer exists Das gemessene Segment existiert nicht mehr @msg Non-finite dimension Die Bemaßung ist keine endliche Zahl @msg Only linear dimensions are horizontal or vertical Nur lineare Bemaßungen sind horizontal oder vertikal diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e9d8df097..49f952365 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5669,6 +5669,7 @@ effect Points Σημεία @msg Invalid drawing scale Μη έγκυρη κλίμακα σχεδίου @msg Invalid real scale Μη έγκυρη πραγματική κλίμακα @msg Measured anchor no longer exists Το μετρημένο σημείο αγκύρωσης δεν υπάρχει πλέον +@msg Invalid measured point Μη έγκυρο μετρημένο σημείο @msg Measured segment no longer exists Το μετρημένο τμήμα δεν υπάρχει πλέον @msg Non-finite dimension Η διάσταση δεν είναι πεπερασμένος αριθμός @msg Only linear dimensions are horizontal or vertical Μόνο οι γραμμικές διαστάσεις είναι οριζόντιες ή κατακόρυφες diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 148c48fd5..8f8b5e17e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5655,6 +5655,7 @@ mark-style Japanese Japonesas @msg Invalid drawing scale Escala de dibujo no válida @msg Invalid real scale Escala real no válida @msg Measured anchor no longer exists El punto de ancla medido ya no existe +@msg Invalid measured point Punto medido no válido @msg Measured segment no longer exists El segmento medido ya no existe @msg Non-finite dimension Cota no finita @msg Only linear dimensions are horizontal or vertical Solo las cotas lineales son horizontales o verticales diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 4fe1a150d..d86f68b4f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5681,6 +5681,7 @@ mark-style Japanese Japanilainen @msg Invalid drawing scale Virheellinen piirustuksen mittakaava @msg Invalid real scale Virheellinen todellinen mittakaava @msg Measured anchor no longer exists Mitattua ankkuripistettä ei ole enää +@msg Invalid measured point Virheellinen mitattu piste @msg Measured segment no longer exists Mitattua segmenttiä ei ole enää @msg Non-finite dimension Mitta ei ole äärellinen luku @msg Only linear dimensions are horizontal or vertical Vain lineaariset mitat ovat vaaka- tai pystysuuntaisia diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index fc07cea93..933e02b95 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5709,6 +5709,7 @@ theme Appearance Apparence @msg Invalid drawing scale Échelle du dessin non valide @msg Invalid real scale Échelle réelle non valide @msg Measured anchor no longer exists Le point d’ancrage mesuré n’existe plus +@msg Invalid measured point Point mesuré non valide @msg Measured segment no longer exists Le segment mesuré n’existe plus @msg Non-finite dimension Cote non finie @msg Only linear dimensions are horizontal or vertical Seules les cotes linéaires sont horizontales ou verticales diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6ef0b5666..06f8f25dc 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6612,6 +6612,7 @@ antiAlias Sharp शार्प @msg matrix must be finite and invertible, depth finite matrix: मैट्रिक्स को परिमित और उलटा, गहराई परिमित होना चाहिए @msg matte: {e} मैट: {e} @msg Measured anchor no longer exists मापने वाला एंकर अब मौजूद नहीं है +@msg Invalid measured point मापा गया बिंदु अमान्य है @msg Measured segment no longer exists मापदंड अब मौजूद नहीं है @msg member is for order front, forward, backward or back member केवल order front, forward, backward या back के लिए है @msg memory budget (the selection is too large to hand over) स्मृति बजट (चुनाव बहुत बड़ा है) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 87a745a43..af7810f02 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5696,6 +5696,7 @@ imageTrace Outline Körvonal @msg Invalid drawing scale Érvénytelen rajzlépték @msg Invalid real scale Érvénytelen valós lépték @msg Measured anchor no longer exists A mért horgonypont már nem létezik +@msg Invalid measured point Érvénytelen mért pont @msg Measured segment no longer exists A mért szakasz már nem létezik @msg Non-finite dimension Nem véges méret @msg Only linear dimensions are horizontal or vertical Csak a lineáris méretek lehetnek vízszintesek vagy függőlegesek diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 2da754164..e1170943b 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5305,6 +5305,7 @@ mark-style Japanese Jepang @msg Invalid drawing scale Skala gambar tidak valid @msg Invalid real scale Skala sebenarnya tidak valid @msg Measured anchor no longer exists Titik jangkar yang diukur sudah tidak ada +@msg Invalid measured point Titik yang diukur tidak valid @msg Measured segment no longer exists Segmen yang diukur sudah tidak ada @msg Non-finite dimension Dimensi bukan bilangan berhingga @msg Only linear dimensions are horizontal or vertical Hanya dimensi linear yang horizontal atau vertikal diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 89e3c7dc2..bb0650e8c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5326,6 +5326,7 @@ lighting Light Luce @msg Invalid drawing scale Scala del disegno non valida @msg Invalid real scale Scala reale non valida @msg Measured anchor no longer exists Il punto di ancoraggio misurato non esiste più +@msg Invalid measured point Punto misurato non valido @msg Measured segment no longer exists Il segmento misurato non esiste più @msg Non-finite dimension Quota non finita @msg Only linear dimensions are horizontal or vertical Solo le quote lineari sono orizzontali o verticali diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5f62b25c0..f8b0e6ab4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5369,6 +5369,7 @@ lighting Light ライト @msg Invalid drawing scale 図面の縮尺が無効です @msg Invalid real scale 実寸の縮尺が無効です @msg Measured anchor no longer exists 測定したアンカーポイントはもう存在しません +@msg Invalid measured point 測定した点が無効です @msg Measured segment no longer exists 測定したセグメントはもう存在しません @msg Non-finite dimension 寸法が有限ではありません @msg Only linear dimensions are horizontal or vertical 水平または垂直にできるのは直線寸法のみです diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 1cec04df1..62a651232 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5320,6 +5320,7 @@ imageTrace Outline კონტური @msg Invalid drawing scale ნახაზის არასწორი მასშტაბი @msg Invalid real scale რეალური ზომის არასწორი მასშტაბი @msg Measured anchor no longer exists გაზომილი საყრდენი წერტილი აღარ არსებობს +@msg Invalid measured point გაზომილი წერტილი არასწორია @msg Measured segment no longer exists გაზომილი სეგმენტი აღარ არსებობს @msg Non-finite dimension ზომა სასრული რიცხვი არ არის @msg Only linear dimensions are horizontal or vertical მხოლოდ ხაზოვანი ზომებია ჰორიზონტალური ან ვერტიკალური diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b3bdd0c36..318df7a4f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5287,6 +5287,7 @@ lighting Light 광원 @msg Invalid drawing scale 잘못된 도면 축척 @msg Invalid real scale 잘못된 실제 축척 @msg Measured anchor no longer exists 측정한 고정점이 더 이상 없습니다 +@msg Invalid measured point 측정한 점이 잘못되었습니다 @msg Measured segment no longer exists 측정한 세그먼트가 더 이상 없습니다 @msg Non-finite dimension 유한하지 않은 치수 @msg Only linear dimensions are horizontal or vertical 선형 치수만 가로 또는 세로로 지정할 수 있습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 3359ff99b..9ffc7e174 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5317,6 +5317,7 @@ mark-style Japanese Japans @msg Invalid drawing scale Ongeldige tekeningsschaal @msg Invalid real scale Ongeldige werkelijke schaal @msg Measured anchor no longer exists Gemeten ankerpunt bestaat niet meer +@msg Invalid measured point Ongeldig gemeten punt @msg Measured segment no longer exists Gemeten segment bestaat niet meer @msg Non-finite dimension Niet-eindige maatlijn @msg Only linear dimensions are horizontal or vertical Alleen lineaire maatlijnen zijn horizontaal of verticaal diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d43c83a0a..b0c3f12d6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5566,6 +5566,7 @@ mark-style Japanese Japońskie @msg Invalid drawing scale Nieprawidłowa skala rysunku @msg Invalid real scale Nieprawidłowa skala rzeczywista @msg Measured anchor no longer exists Mierzony punkt kontrolny już nie istnieje +@msg Invalid measured point Nieprawidłowy mierzony punkt @msg Measured segment no longer exists Mierzony odcinek już nie istnieje @msg Non-finite dimension Wymiar nie jest liczbą skończoną @msg Only linear dimensions are horizontal or vertical Tylko wymiary liniowe są poziome lub pionowe diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9117394f7..6eb8eb521 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5645,6 +5645,7 @@ mark-style Japanese Japonesas @msg Invalid drawing scale Escala de desenho inválida @msg Invalid real scale Escala real inválida @msg Measured anchor no longer exists O ponto de ancoragem medido não existe mais +@msg Invalid measured point Ponto medido inválido @msg Measured segment no longer exists O segmento medido não existe mais @msg Non-finite dimension Cota não finita @msg Only linear dimensions are horizontal or vertical Somente cotas lineares são horizontais ou verticais diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index cef9bafd2..87f497f96 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5320,6 +5320,7 @@ lighting Light Свет @msg Invalid drawing scale Недопустимый масштаб чертежа @msg Invalid real scale Недопустимый реальный масштаб @msg Measured anchor no longer exists Измеряемая опорная точка больше не существует +@msg Invalid measured point Недопустимая измеряемая точка @msg Measured segment no longer exists Измеряемый сегмент больше не существует @msg Non-finite dimension Размер не является конечным числом @msg Only linear dimensions are horizontal or vertical Только линейные размеры бывают горизонтальными или вертикальными diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 1fdf7ce25..2bc44ef31 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5673,6 +5673,7 @@ theme Appearance Utseende @msg Invalid drawing scale Ogiltig ritningsskala @msg Invalid real scale Ogiltig verklig skala @msg Measured anchor no longer exists Den mätta ankarpunkten finns inte längre +@msg Invalid measured point Ogiltig mätt punkt @msg Measured segment no longer exists Det mätta segmentet finns inte längre @msg Non-finite dimension Måttet är inte ett ändligt tal @msg Only linear dimensions are horizontal or vertical Endast linjära mått är vågräta eller lodräta diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 91e2b3a4b..050e7c05d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5394,6 +5394,7 @@ theme Appearance Görünüm @msg Invalid drawing scale Geçersiz çizim ölçeği @msg Invalid real scale Geçersiz gerçek ölçek @msg Measured anchor no longer exists Ölçülen çapa artık mevcut değil +@msg Invalid measured point Geçersiz ölçülen nokta @msg Measured segment no longer exists Ölçülen segment artık mevcut değil @msg Non-finite dimension Sonlu olmayan boyut @msg Only linear dimensions are horizontal or vertical Yalnızca doğrusal boyutlar yatay veya dikey olabilir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5eb9f255f..f4c4f6ee8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5306,6 +5306,7 @@ lighting Light Світло @msg Invalid drawing scale Неприпустимий масштаб креслення @msg Invalid real scale Неприпустимий реальний масштаб @msg Measured anchor no longer exists Виміряна опорна точка більше не існує +@msg Invalid measured point Недійсна виміряна точка @msg Measured segment no longer exists Виміряний сегмент більше не існує @msg Non-finite dimension Розмір не є скінченним числом @msg Only linear dimensions are horizontal or vertical Лише лінійні розміри бувають горизонтальними або вертикальними diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1c17d61e6..0dda56d3d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5307,6 +5307,7 @@ theme Appearance Giao diện @msg Invalid drawing scale Tỷ lệ bản vẽ không hợp lệ @msg Invalid real scale Tỷ lệ thực không hợp lệ @msg Measured anchor no longer exists Điểm neo được đo không còn tồn tại +@msg Invalid measured point Điểm được đo không hợp lệ @msg Measured segment no longer exists Đoạn được đo không còn tồn tại @msg Non-finite dimension Kích thước không phải là số hữu hạn @msg Only linear dimensions are horizontal or vertical Chỉ kích thước tuyến tính mới nằm ngang hoặc thẳng đứng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5c0c35240..f6e3c9c14 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4757,6 +4757,7 @@ imageTrace Outline 轮廓 @msg Invalid drawing scale 图纸比例无效 @msg Invalid real scale 实际比例无效 @msg Measured anchor no longer exists 测量锚点已不存在 +@msg Invalid measured point 测量点无效 @msg Measured segment no longer exists 测量线段已不存在 @msg Non-finite dimension 尺寸标注不是有限值 @msg Only linear dimensions are horizontal or vertical 只有线性尺寸可以是水平或垂直的 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b538cb9d3..a02e03cc2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2589,6 +2589,7 @@ file-filter Templates 範本 @msg Invalid drawing scale 圖紙比例無效 @msg Invalid real scale 實際比例無效 @msg Measured anchor no longer exists 測量的錨點已不存在 +@msg Invalid measured point 測量點無效 @msg Measured segment no longer exists 測量的線段已不存在 @msg Non-finite dimension 尺寸標註不是有限值 @msg Only linear dimensions are horizontal or vertical 只有線性尺寸可以是水平或垂直的 diff --git a/docs/dimensions.md b/docs/dimensions.md index 6327c9f50..bcd08c3b8 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -6,7 +6,13 @@ The **Dimension Tool** is in the Modify section of the toolbar. Its strip offers - **Linear:** hover a path segment and drag outwards: releasing places the dimension. Or click two points (anchors snap, Shift constrains to 45°), then click to place. A click within 8 pixels of an anchor of any path, in groups and compound paths too, takes that anchor, and the dimension - stays linked to it. A press on an anchor picks it when the pointer moves less than 8 pixels + stays linked to it. A point on a segment stays at its place along the segment, also when the + segment's anchors or handles are edited. Any other point is linked to the path under it, or, + with no path under it, to the path at the other end, and moves, scales, turns and reflects with + that path. Beside a path whose anchors and handles all lie on one line, such as a straight line, + the point keeps its distance along and across the path: a reflection moves it as a half turn + does, and a scale that is not uniform changes its distance across the path in proportion to the + path's length. A press on an anchor picks it when the pointer moves less than 8 pixels before the release; a longer drag drags out the dimension of the segment under the pointer. A curved segment is measured from end to end. With Smart Guides on, both points also snap into line with other objects' anchors and centres, and the second onto the construction guides through the first; the guides @@ -52,10 +58,15 @@ position and alignment; changing one changes that dimension. **Apply to All** gi dimension in the document its style; **Set as Default** makes it the style of new dimensions. Measurements follow the art they measure through moves, scaling and anchor and handle edits, -undo and redo included. Deleting the art, or changing its number of anchors or whether it is -closed, removes its dimensions in the same undo step rather than attaching them to a different -edge. A linked dimension moved on its own stays where it was dropped (its distance from the art -changes); moved together with its art, it keeps its distance. +undo and redo included. When the art is reflected, the line of a linked linear dimension goes to +the reflected side, as far past the outermost point as before. This does not happen for art whose +anchors and handles all lie on one line, such as a straight line, which a half turn puts in the +same place as a reflection. Deleting the art, changing its number of anchors or whether it is +closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular +leaves its dimensions where they were, no longer linked, in the same undo step, rather than +attaching them to a different edge. Undo links them again. A linked dimension moved on its own +stays where it was dropped (its distance from the art changes); moved together with its art, it +keeps its distance. Native `.vectorcraft` files keep the recipes and links. SVG, PDF and bitmap output contain ordinary paths and text, so exported dimensions look the same. Ungrouping a dimension leaves its diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cc6e7a73a..70c415cff 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -565,6 +565,7 @@ since 2026-10-01. **Drawing tools — Polygon and Star counts:** The existing canvas placement dialog reads the stored count and saves it only after successful placement. `polygon_and_star_canvas_counts_persist_without_new_toolbar_dialogs` covers both tools, repeated drags, tool switching, reopening and Cancel. Counts remain session tool options. Existing click and toolbar gestures are retained; no score or hour change. **Drawing tools, Star inner radius held with Ctrl/Cmd:** While a star is dragged out, Ctrl/Cmd keeps the inner radius the star showed when the key went down, also when the key is pressed with the pointer still. `a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt` and `a_star_drag_holds_the_inner_radius_it_shows_when_ctrl_goes_down` cover it, with Alt's straight shoulders and a drag canceled with Escape. **Drawing tools, Dimension tool horizontal and vertical linear dimensions:** A linear dimension between two clicked points or dragged out from a segment is aligned, horizontal or vertical, chosen by where the pointer moves while it is placed, and a click again by the second point gives aligned; `dimension.create` and `dimension.update` take `orientation`, and the dimension stays linked to the anchors it measures, keeps its orientation when it or the art turns, with a line that lay outside the points kept outside them; an unlinked horizontal or vertical dimension that is reflected has its line on the reflected side. `the_pointer_chooses_aligned_horizontal_or_vertical`, `two_points_measure_horizontally_vertically_or_aligned`, `clicking_again_by_the_second_point_places_an_aligned_dimension`, `a_segment_drag_measures_horizontally_vertically_or_aligned`, `points_on_a_vertical_line_measure_aligned`, `horizontal_and_vertical_dimensions_draw_lines_along_their_axis`, `turned_horizontal_dimensions_stay_horizontal`, `a_linked_horizontal_dimension_keeps_its_gap_from_turned_art`, `a_linked_horizontal_dimension_below_or_between_its_points_follows_turned_art`, `a_reflected_horizontal_dimension_keeps_its_line_on_the_reflected_side`, `a_dimension_without_an_orientation_reads_as_aligned`, `dimension_orientation_is_checked` and `a_turned_dimension_keeps_its_line_on_its_side` cover it. +**Drawing tools, Dimension tool points that are not anchors follow the art:** A clicked point on a segment stays at its place along the segment, also when the segment curves, and a point off the art is linked to anchors (or, when the anchors lie on one line, anchors and handles) of the path, so both ends of the dimension follow when the art is moved, scaled, turned or reflected, also when the point was placed by a Smart Guide and when the dimension is saved and opened again; a linked linear dimension of reflected art (other than art on one line) has its line on the reflected side; deleting the art, changing its anchor count or whether it is closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular leaves the dimension where it was, no longer linked, instead of removing it. `a_dimension_to_a_point_on_a_segment_follows_the_art_at_both_ends`, `a_dimension_to_a_point_off_the_art_moves_with_the_art`, `a_dimension_whose_art_changes_its_anchors_stays_where_it_was`, `a_dimension_whose_art_is_opened_stays_where_it_was`, `angle_and_arc_dimensions_whose_art_no_longer_fits_stay_where_they_were`, `a_dimension_that_cannot_be_drawn_again_keeps_its_art`, `a_linked_dimension_of_reflected_art_has_its_line_on_the_reflected_side`, `a_dimension_to_a_point_off_a_line_follows_the_line`, `a_point_off_an_arch_is_reflected_with_the_arch`, `a_point_beside_an_almost_straight_curve_follows_it`, `a_point_off_a_loop_with_its_anchors_in_one_place_follows_it`, `a_point_beside_a_reflected_line_moves_as_a_half_turn_would`, `a_point_on_a_segment_stays_on_it_when_the_segment_curves`, `a_linked_dimension_of_a_reflected_curve_has_its_line_on_the_reflected_side`, `a_dimension_of_art_reflected_across_its_points_changes_sides`, `a_dimension_between_paths_reflected_each_in_its_own_way_keeps_its_offset`, `free_points_survive_a_native_file`, `dimension_free_points_are_checked`, `points_off_anchors_are_held_against_the_path` and `a_point_smart_guides_place_is_held_against_the_path` cover it. **Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views:** `live_trace_result_bounds_exclude_the_retained_bitmap_at_any_angle` checks every TraceView, geometric and visual bounds and rotated result bounds. `live_trace_selection_highlight_excludes_the_retained_source_rectangle` checks result highlights and the existing ordinary-group policy. Shared editing-child traversal also preserves Shaper handling. **Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards:** Shared existing preset definitions are reused; no external preset assets are added. Presets preserve each selected artboard's orientation, and orientation choices apply even when the active board already matches. Fit operations draft bounds until OK, affect only the active artboard and leave artwork in place. Regression coverage checks draft/Cancel, selected-set application, active-only fitting and Undo.