From 8aea665c4e225fd1c9beb81e04428da506ebc21c Mon Sep 17 00:00:00 2001 From: Osama Amin <341642285+osamaamin2011@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:30:24 +0300 Subject: [PATCH] Dimension tool: the documented gestures, arcs, picas and text positions; Properties section; review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag out from a segment, corner, circle or arc to place a dimension; plain clicks pick two points or two lines. Radial dimensions on perfect arcs, picas, Away/Between and Left/Center/Right text placement. The Properties panel edits a selected dimension, with Apply to All (dimension.applyToAll) and Set as Default. Dimension Selected Paths, the Arc/R/Ø prefixes and the custom defaults are left out. The refresh returns at once in documents without dimensions (Document::dimensions); a linked dimension moved on its own keeps its place; modes, units, line types, arrows and text placement are enums; the tool has its own icon. --- ASSETS.md | 1 + assets/icons/dc-dimension.svg | 1 + crates/doc/src/dimension.rs | 402 ++++++++++++++++++------ crates/doc/src/lib.rs | 5 + crates/engine/src/cmd/dimension.rs | 298 ++++++++---------- crates/engine/src/tooling.rs | 2 +- crates/engine/tests/dimensions.rs | 210 ++++++++++--- crates/tools/src/catalog.rs | 2 +- crates/tools/src/dimension.rs | 392 +++++++++++++++-------- crates/ui-egui/src/chrome.rs | 6 +- crates/ui-egui/src/dialogs/dimension.rs | 302 +++++++++++------- crates/ui-egui/src/i18n/cs.tsv | 23 +- crates/ui-egui/src/i18n/de.tsv | 24 +- crates/ui-egui/src/i18n/es.tsv | 23 +- crates/ui-egui/src/i18n/fr.tsv | 23 +- crates/ui-egui/src/i18n/hu.tsv | 23 +- crates/ui-egui/src/i18n/it.tsv | 24 +- crates/ui-egui/src/i18n/ja.tsv | 24 +- crates/ui-egui/src/i18n/ko.tsv | 24 +- crates/ui-egui/src/i18n/pl.tsv | 23 +- crates/ui-egui/src/i18n/pt-br.tsv | 1 - crates/ui-egui/src/i18n/ru.tsv | 24 +- crates/ui-egui/src/i18n/uk.tsv | 24 +- crates/ui-egui/src/i18n/vi.tsv | 24 +- crates/ui-egui/src/i18n/zh-hans.tsv | 21 +- crates/ui-egui/src/i18n/zh-hant.tsv | 21 +- crates/ui-egui/src/icon_data.rs | 1 + crates/ui-egui/src/icons.rs | 1 + crates/ui-egui/src/menus.rs | 1 - crates/ui-egui/src/panels/properties.rs | 4 + docs/dimensions.md | 88 +++--- 31 files changed, 1299 insertions(+), 743 deletions(-) create mode 100644 assets/icons/dc-dimension.svg diff --git a/ASSETS.md b/ASSETS.md index d4804d903..11e5602ea 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -117,6 +117,7 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-cube.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-dash-align.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-dash-exact.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-dimension.svg` | Osama Amin | Original work, drawn for VectorCraft: a dimension line with arrowheads between two extension lines | MIT OR Apache-2.0 | | | `assets/icons/dc-dir-off.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-dir-on.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-direct.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | diff --git a/assets/icons/dc-dimension.svg b/assets/icons/dc-dimension.svg new file mode 100644 index 000000000..9a00f9d89 --- /dev/null +++ b/assets/icons/dc-dimension.svg @@ -0,0 +1 @@ + diff --git a/crates/doc/src/dimension.rs b/crates/doc/src/dimension.rs index 9e0762ed5..81713baf1 100644 --- a/crates/doc/src/dimension.rs +++ b/crates/doc/src/dimension.rs @@ -1,23 +1,142 @@ //! 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}; +use vectorcraft_geom::{Affine, Point, Vec2}; + +/// What a dimension measures. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DimensionMode { + #[default] + Linear, + Angular, + Radius, + Diameter, +} + +impl DimensionMode { + pub fn id(self) -> &'static str { + match self { + Self::Linear => "linear", + Self::Angular => "angular", + Self::Radius => "radius", + Self::Diameter => "diameter", + } + } +} + +/// The unit a length is labelled in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LengthUnit { + #[default] + Pt, + Pc, + Px, + In, + Mm, + Cm, + M, + Ft, + Yd, +} + +impl LengthUnit { + pub const ALL: [LengthUnit; 9] = [ + LengthUnit::In, + LengthUnit::Mm, + LengthUnit::Pt, + LengthUnit::Pc, + LengthUnit::Px, + LengthUnit::Cm, + LengthUnit::M, + LengthUnit::Ft, + LengthUnit::Yd, + ]; + pub fn unit(self) -> Unit { + match self { + Self::Pt => Unit::Points, + Self::Pc => Unit::Picas, + Self::Px => Unit::Pixels, + Self::In => Unit::Inches, + Self::Mm => Unit::Millimeters, + Self::Cm => Unit::Centimeters, + Self::M => Unit::Meters, + Self::Ft => Unit::Feet, + Self::Yd => Unit::Yards, + } + } + /// The suffix written after a length. + pub fn suffix(self) -> &'static str { + match self { + Self::Pt => "pt", + Self::Pc => "pc", + Self::Px => "px", + Self::In => "in", + Self::Mm => "mm", + Self::Cm => "cm", + Self::M => "m", + Self::Ft => "ft", + Self::Yd => "yd", + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LineType { + #[default] + Solid, + Dashed, + Dotted, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ArrowStyle { + #[default] + Filled, + Open, + Tick, + None, +} + +/// Where the label sits across the dimension line. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TextPosition { + /// On the side of the dimension line away from the measured object. + #[default] + Away, + /// Between the dimension line and the measured object. + Between, +} + +/// Where the label sits along the dimension line. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TextAlignment { + Left, + #[default] + Center, + Right, +} #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase", deny_unknown_fields)] pub struct DimensionOptions { - pub units: String, + pub units: LengthUnit, pub precision: usize, /// Drawing : real size. 1:10 multiplies the drawn length by ten. pub scale: String, pub hide_units: bool, pub line_weight: f64, - pub line_type: String, + pub line_type: LineType, pub line_color: [u8; 3], - pub arrow_style: String, + pub arrow_style: ArrowStyle, pub arrow_scale: f64, pub extension_weight: f64, - pub extension_type: String, + pub extension_type: LineType, pub extension_color: [u8; 3], pub extension_offset: f64, pub extension_length: f64, @@ -26,54 +145,43 @@ pub struct DimensionOptions { pub font_style: String, pub font_size: f64, pub text_color: [u8; 3], - pub text_position: String, - pub text_alignment: String, + pub text_position: TextPosition, + pub text_alignment: TextAlignment, pub text_offset: f64, } impl Default for DimensionOptions { fn default() -> Self { + // The document's own type defaults: a font every build has, black, in points. + let type_style = crate::CharStyle::default(); Self { - units: "cm".into(), - precision: 1, + units: LengthUnit::Pt, + precision: 2, scale: "1:1".into(), hide_units: false, - line_weight: 1.0, - line_type: "solid".into(), - line_color: [224, 55, 65], - arrow_style: "filled".into(), + line_weight: 0.5, + line_type: LineType::Solid, + line_color: [0, 0, 0], + arrow_style: ArrowStyle::Filled, arrow_scale: 100.0, extension_weight: 0.5, - extension_type: "solid".into(), - extension_color: [224, 55, 65], - extension_offset: 5.0, - extension_length: 9.0, + extension_type: LineType::Solid, + extension_color: [0, 0, 0], + extension_offset: 4.0, + extension_length: 6.0, hide_extensions: false, - font_family: "Arial".into(), - font_style: "Regular".into(), - font_size: 14.0, - text_color: [224, 55, 65], - text_position: "above".into(), - text_alignment: "aligned".into(), - text_offset: 9.0, + font_family: type_style.font_family, + font_style: type_style.font_style, + font_size: type_style.size, + text_color: [0, 0, 0], + text_position: TextPosition::Away, + text_alignment: TextAlignment::Center, + text_offset: 4.0, } } } impl DimensionOptions { - pub fn unit(&self) -> Option { - Some(match self.units.as_str() { - "pt" => Unit::Points, - "px" => Unit::Pixels, - "mm" => Unit::Millimeters, - "cm" => Unit::Centimeters, - "m" => Unit::Meters, - "in" => Unit::Inches, - "ft" => Unit::Feet, - "yd" => Unit::Yards, - _ => return None, - }) - } pub fn factor(&self) -> Result { let (a, b) = self.scale.split_once(':').ok_or("Scale must be drawing:real, for example 1:10")?; let parse = |s: &str| s.trim().parse::().ok().filter(|n| n.is_finite() && *n > 0.0 && *n <= 1e9); @@ -87,8 +195,8 @@ impl DimensionOptions { } pub fn validate(&self) -> Result<(), String> { self.factor()?; - if self.unit().is_none() || self.precision > 6 { - return Err("Choose a length unit and precision from 0 to 6".into()); + if self.precision > 6 { + return Err("Choose a precision from 0 to 6".into()); } for (v, min, max) in [ (self.line_weight, 0.1, 100.0), @@ -103,22 +211,15 @@ impl DimensionOptions { return Err("Dimension size is outside its supported range".into()); } } - if !["solid", "dashed", "dotted"].contains(&self.line_type.as_str()) - || !["solid", "dashed", "dotted"].contains(&self.extension_type.as_str()) - || !["filled", "open", "tick", "none"].contains(&self.arrow_style.as_str()) - || !["above", "center", "below"].contains(&self.text_position.as_str()) - || !["aligned", "horizontal"].contains(&self.text_alignment.as_str()) - { - return Err("Unknown dimension style".into()); - } if self.font_family.is_empty() || self.font_family.len() > 256 || self.font_style.len() > 128 { return Err("Invalid dimension font name".into()); } Ok(()) } - pub fn label(&self, points: f64, angle: bool) -> Result { + /// `value` in points for a length, in degrees for an angle. + pub fn label(&self, value: f64, angle: bool) -> Result { self.validate()?; - let value = if angle { points } else { points * self.factor()? / self.unit().ok_or("Unknown unit")?.points() }; + let value = if angle { value } else { value * self.factor()? / self.units.unit().points() }; if !value.is_finite() { return Err("Non-finite dimension".into()); } @@ -127,7 +228,7 @@ impl DimensionOptions { } else if angle { "°" } else { - self.units.as_str() + self.units.suffix() }; let sep = if suffix.is_empty() || angle { "" } else { " " }; Ok(format!("{:.*}{sep}{suffix}", self.precision, value)) @@ -143,30 +244,52 @@ pub struct SegmentRef { } impl SegmentRef { - pub fn endpoints(&self, doc: &Document) -> Option<(Point, Point, f64)> { + /// The segment's two end points. + pub fn endpoints(&self, doc: &Document) -> Option<(Point, Point)> { let sp = doc.node(self.node)?.path_data()?.subpaths.get(self.subpath)?; if self.segment >= sp.segment_count() { return None; } let seg = sp.segment(self.segment); - use vectorcraft_geom::kurbo::ParamCurveArclen; - Some((seg.p0, seg.p3, if sp.segment_is_line(self.segment) { seg.p0.distance(seg.p3) } else { seg.arclen(1e-5) })) + Some((seg.p0, seg.p3)) + } + /// Whether the segment is a straight line. + pub fn is_straight(&self, doc: &Document) -> bool { + doc.node(self.node) + .and_then(|n| n.path_data()) + .and_then(|p| p.subpaths.get(self.subpath)) + .is_some_and(|sp| self.segment < sp.segment_count() && sp.segment_is_line(self.segment)) } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] pub enum DimensionSource { - Segment { edge: SegmentRef }, - Angle { first: SegmentRef, second: SegmentRef }, - Circle { node: NodeId }, - Points { a: Option, b: Option, vertex: Option }, + Segment { + edge: SegmentRef, + }, + Angle { + first: SegmentRef, + second: SegmentRef, + }, + Circle { + node: NodeId, + }, + /// One segment that is a perfect arc. + Arc { + edge: SegmentRef, + }, + Points { + a: Option, + b: Option, + vertex: Option, + }, } impl DimensionSource { pub fn nodes(&self) -> Vec { match self { - Self::Segment { edge } => vec![edge.node], + 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(), @@ -205,8 +328,7 @@ impl PointRef { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Dimension { - /// linear, angular, radius or diameter. - pub mode: String, + pub mode: DimensionMode, pub a: Point, pub b: Point, /// Angular vertex. For radius/diameter a is the centre and b lies on the circle. @@ -215,15 +337,58 @@ pub struct Dimension { pub length: f64, pub options: DimensionOptions, pub source: Option, + /// How far a linked dimension was moved on its own since the last refresh: the refresh turns + /// it into a new placement unless the measured art moved too. + #[serde(skip)] + pub moved: Option, } impl Dimension { + /// A point on the drawn dimension line (linear), arc (angular) or radius line. + pub fn handle(&self) -> Point { + match self.mode { + DimensionMode::Linear => self.a.midpoint(self.b) + self.normal() * self.offset, + DimensionMode::Angular => self.vertex + self.bisector() * self.offset.abs().max(12.0), + DimensionMode::Radius | DimensionMode::Diameter => self.b, + } + } + /// The left normal of a → b. + pub fn normal(&self) -> Vec2 { + let u = self.b - self.a; + let len = u.hypot(); + if len > 1e-9 { Vec2::new(-u.y, u.x) / len } else { Vec2::new(0.0, 1.0) } + } + fn bisector(&self) -> Vec2 { + let start = (self.a - self.vertex).atan2(); + Vec2::from_angle(start + self.angle() * 0.5) + } + /// Places the dimension through `p`: the line's distance from the art (linear), the arc's + /// radius (angular) or the label's direction (radial). + pub fn place(&mut self, p: Point) { + match self.mode { + DimensionMode::Linear => self.offset = (p - self.a).dot(self.normal()), + DimensionMode::Angular => self.offset = p.distance(self.vertex).max(12.0), + DimensionMode::Radius | DimensionMode::Diameter => { + if p.distance(self.a) > 1e-6 { + let r = self.a.distance(self.b); + let u = p - self.a; + self.b = self.a + u / u.hypot() * r; + } + } + } + } pub fn transform(&mut self, xf: Affine) { - // Linked recipes are resolved again from the source by the engine. - if self.source.is_none() { - self.a = xf * self.a; - self.b = xf * self.b; - self.vertex = xf * self.vertex; + if self.source.is_some() { + // The art it measures decides where its ends are: record the move, and the refresh + // after this edit turns it into a new placement unless the art moved with it. + let at = self.handle(); + self.moved = Some((xf * at) - at + self.moved.unwrap_or_default()); + return; + } + self.a = xf * self.a; + self.b = xf * self.b; + self.vertex = xf * self.vertex; + if self.mode == DimensionMode::Linear { self.length = self.a.distance(self.b); } } @@ -238,24 +403,19 @@ impl Dimension { self.length = self.a.distance(self.b); } Some(DimensionSource::Segment { edge }) => { - let (a, b, length) = edge.endpoints(doc).ok_or("Measured segment no longer exists")?; + let (a, b) = edge.endpoints(doc).ok_or("Measured segment no longer exists")?; self.a = a; self.b = b; - self.length = length; + self.length = a.distance(b); } Some(DimensionSource::Angle { first, second }) => { for edge in [first, second] { - let sp = doc - .node(edge.node) - .and_then(|n| n.path_data()) - .and_then(|p| p.subpaths.get(edge.subpath)) - .ok_or("Measured path no longer exists")?; - if edge.segment >= sp.segment_count() || !sp.segment_is_line(edge.segment) { + if !edge.is_straight(doc) { return Err("Angles require two straight segments".into()); } } - let (a, b, _) = first.endpoints(doc).ok_or("First measured segment no longer exists")?; - let (c, d, _) = second.endpoints(doc).ok_or("Second measured segment no longer exists")?; + let (a, b) = first.endpoints(doc).ok_or("First measured segment no longer exists")?; + let (c, d) = second.endpoints(doc).ok_or("Second measured segment no longer exists")?; let u = b - a; let v = d - c; let den = u.cross(v); @@ -268,17 +428,24 @@ impl Dimension { self.b = if c.distance(p) > d.distance(p) { c } else { d }; } Some(DimensionSource::Circle { node }) => { - let (center, radius) = circle(doc, *node).ok_or("Choose a circular path")?; - let direction = self.b - self.a; - let dir = if direction.hypot() > 1e-9 { direction / direction.hypot() } else { vectorcraft_geom::Vec2::new(1.0, 0.0) }; - self.a = center; - self.b = center + dir * radius; - self.length = radius; + let (center, radius) = circle(doc, *node).ok_or("Choose a circle or a perfect arc")?; + self.set_radial(center, radius); + } + Some(DimensionSource::Arc { edge }) => { + let (center, radius) = arc(doc, edge).ok_or("Choose a circle or a perfect arc")?; + self.set_radial(center, radius); } None => {} } self.validate() } + fn set_radial(&mut self, center: Point, radius: f64) { + let direction = self.b - self.a; + let dir = if direction.hypot() > 1e-9 { direction / direction.hypot() } else { Vec2::new(1.0, 0.0) }; + self.a = center; + self.b = center + dir * radius; + self.length = radius; + } pub fn validate(&self) -> Result<(), String> { self.options.validate()?; for p in [self.a, self.b, self.vertex] { @@ -289,13 +456,10 @@ impl Dimension { if !self.offset.is_finite() || self.offset.abs() > 1e6 || !self.length.is_finite() || self.length < 0.0 || self.length > 1e9 { return Err("Invalid dimension distance".into()); } - if !["linear", "angular", "radius", "diameter"].contains(&self.mode.as_str()) { - return Err("Unknown dimension mode".into()); - } if self.a.distance(self.b) < 1e-6 { return Err("Choose two different points".into()); } - if self.mode == "angular" && (self.a.distance(self.vertex) < 1e-6 || self.b.distance(self.vertex) < 1e-6) { + if self.mode == DimensionMode::Angular && (self.a.distance(self.vertex) < 1e-6 || self.b.distance(self.vertex) < 1e-6) { return Err("Choose two nonzero angle arms".into()); } Ok(()) @@ -306,13 +470,13 @@ impl Dimension { u.cross(v).atan2(u.dot(v)) } pub fn label(&self) -> Result { - let (value, prefix) = match self.mode.as_str() { - "angular" => (self.angle().abs().to_degrees(), ""), - "radius" => (self.a.distance(self.b), "R "), - "diameter" => (self.a.distance(self.b) * 2.0, "Ø "), - _ => (self.length, if self.length > self.a.distance(self.b) + 0.01 { "Arc " } else { "" }), + let value = match self.mode { + DimensionMode::Angular => self.angle().abs().to_degrees(), + DimensionMode::Radius => self.a.distance(self.b), + DimensionMode::Diameter => self.a.distance(self.b) * 2.0, + DimensionMode::Linear => self.a.distance(self.b), }; - Ok(format!("{prefix}{}", self.options.label(value, self.mode == "angular")?)) + self.options.label(value, self.mode == DimensionMode::Angular) } } @@ -339,3 +503,59 @@ pub fn circle(doc: &Document, id: NodeId) -> Option<(Point, f64)> { } Some((center, radius)) } + +/// A curved segment that follows one circle (a perfect arc): its centre and radius. +pub fn arc(doc: &Document, edge: &SegmentRef) -> Option<(Point, f64)> { + use vectorcraft_geom::kurbo::ParamCurve; + let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?; + if edge.segment >= sp.segment_count() || sp.segment_is_line(edge.segment) { + return None; + } + let seg = sp.segment(edge.segment); + let (p, q, r) = (seg.p0, seg.eval(0.5), seg.p3); + // The circle through the ends and the middle of the segment. + let d = 2.0 * (p.x * (q.y - r.y) + q.x * (r.y - p.y) + r.x * (p.y - q.y)); + if d.abs() < 1e-9 { + return None; + } + let sq = |a: Point| a.x * a.x + a.y * a.y; + let center = Point::new( + (sq(p) * (q.y - r.y) + sq(q) * (r.y - p.y) + sq(r) * (p.y - q.y)) / d, + (sq(p) * (r.x - q.x) + sq(q) * (p.x - r.x) + sq(r) * (q.x - p.x)) / d, + ); + let radius = center.distance(p); + if !radius.is_finite() || !(1e-6..=1e8).contains(&radius) { + return None; + } + for t in [0.125, 0.25, 0.375, 0.625, 0.75, 0.875] { + if (seg.eval(t).distance(center) - radius).abs() > radius * 0.002 { + return None; + } + } + Some((center, radius)) +} + +/// The two straight segments that meet at a corner anchor, if both are straight. +pub fn corner(doc: &Document, node: NodeId, subpath: usize, anchor: usize) -> Option<(SegmentRef, SegmentRef)> { + let sp = doc.node(node)?.path_data()?.subpaths.get(subpath)?; + let count = sp.segment_count(); + let n = sp.anchors.len(); + if count < 2 || anchor >= n { + return None; + } + // Segment i runs from anchor i to anchor i + 1 (wrapping on closed paths). + let before = if anchor > 0 { + anchor - 1 + } else if sp.closed { + count - 1 + } else { + return None; + }; + let after = anchor; + if after >= count { + return None; + } + let first = SegmentRef { node, subpath, segment: before }; + let second = SegmentRef { node, subpath, segment: after }; + (first.is_straight(doc) && second.is_straight(doc)).then_some((first, second)) +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 17c7def44..9c2670329 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -687,6 +687,10 @@ pub struct Document { /// state, never saved. #[serde(skip)] pub puppet: Option>, + /// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this + /// is set, so documents without any pay nothing for them. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub dimensions: bool, /// Where each node is in the tree, for lookups by id ([`id_index`]): a cache, never saved. #[serde(skip)] index: id_index::IdIndex, @@ -767,6 +771,7 @@ impl Document { print_setup: None, assets: vec![], puppet: None, + dimensions: false, index: Default::default(), }; let id = d.alloc_id(); diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index a083ee823..2e3ffae73 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -3,7 +3,7 @@ use super::*; use serde_json::{Value, json}; use std::sync::Arc; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::dimension::{Dimension, DimensionOptions, DimensionSource, SegmentRef}; +use vectorcraft_doc::dimension::{ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, TextAlignment, TextPosition}; use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, Dash, Document, Node, NodeId, NodeKind, TextObject}; use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2}; @@ -14,28 +14,28 @@ 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}, offset?: pt, options?: {units,precision,scale,hideUnits,lineWeight,lineType,lineColor,arrowStyle,arrowScale,extensionWeight,extensionType,extensionColor,extensionOffset,extensionLength,hideExtensions,fontFamily,fontStyle,fontSize,textColor,textPosition,textAlignment,textOffset}} → {id,label}; scale is drawing:real; 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}}, offset?: pt, 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 ), - cmd!( - "dimension.selection", - "Dimension Selected Paths", - ["Object", "Dimensions"], - None, - "{offset?: pt = 30, options?: dimension options} add a linked dimension to every segment of the selected paths (up to 1000), as one undo step → {ids}", - has_selection, - selection - ), cmd!( "dimension.update", "Update Dimensions", [], None, - "{ids?: [dimension ids] (default selection), options?: dimension options, offset?: pt} edit existing dimensions → {ids}", + "{ids?: [dimension ids] (default selection), options?: dimension options, offset?: pt} restyle or move existing dimensions → {ids}", has_doc, update ), + cmd!( + "dimension.applyToAll", + "Apply to All", + [], + None, + "{id?: dimension id (default the first selected dimension)} gives every dimension in the document that dimension's style (its scale too), as one undo step → {ids}", + has_doc, + apply_to_all + ), ] } @@ -65,30 +65,9 @@ fn distance(p: &Value, fallback: f64) -> Result { } } -/// Return the signed winding area of a closed path subpath. Positive area means -/// the path interior is to the left of its directed segments, which lets the -/// automatic dimension command put its positive (left-normal) offset outside. -fn subpath_area(path: &PathData, subpath: usize) -> Option { - use vectorcraft_geom::kurbo::ParamCurve; - let sp = path.subpaths.get(subpath)?; - if !sp.closed || sp.segment_count() == 0 { - return None; - } - let mut area = 0.0; - for index in 0..sp.segment_count() { - let segment = sp.segment(index); - let mut previous = segment.eval(0.0); - for step in 1..=16 { - let current = segment.eval(step as f64 / 16.0); - area += previous.x * current.y - current.x * previous.y; - previous = current; - } - } - (area.is_finite() && area.abs() > 1e-9).then_some(area * 0.5) -} - fn recipe(s: &Session, p: &Value) -> Result { - let mode = p.get("mode").map_or(Ok("linear"), |v| v.as_str().ok_or_else(|| error("Invalid dimension mode")))?; + let mode: DimensionMode = + p.get("mode").map(|v| serde_json::from_value(v.clone()).map_err(|_| error("Unknown dimension mode"))).transpose()?.unwrap_or_default(); let source: Option = p.get("source").map(|v| serde_json::from_value(v.clone()).map_err(error)).transpose()?; if let Some(source) = &source { let doc = &s.doc()?.doc; @@ -97,26 +76,25 @@ fn recipe(s: &Session, p: &Value) -> Result { return Err(error("Choose original artwork, not a dimension's generated paths")); } } - } - if let Some(source) = &source { let compatible = matches!( (mode, source), - ("linear", DimensionSource::Segment { .. }) - | ("angular", DimensionSource::Angle { .. }) - | ("radius" | "diameter", DimensionSource::Circle { .. }) + (DimensionMode::Linear, DimensionSource::Segment { .. }) + | (DimensionMode::Angular, DimensionSource::Angle { .. }) + | (DimensionMode::Radius | DimensionMode::Diameter, DimensionSource::Circle { .. } | DimensionSource::Arc { .. }) + | (DimensionMode::Linear | DimensionMode::Angular, DimensionSource::Points { .. }) ); - if !compatible && !matches!((mode, source), ("linear" | "angular", DimensionSource::Points { .. })) { + if !compatible { return Err(error("Dimension mode does not match its source")); } } let a = if source.is_none() || p.get("a").is_some() { point(p, "a")? } else { Point::ZERO }; let b = if source.is_none() || p.get("b").is_some() { point(p, "b")? } else { Point::new(1.0, 0.0) }; - let vertex = if mode == "angular" && (source.is_none() || matches!(source, Some(DimensionSource::Points { .. }))) { + let vertex = if mode == DimensionMode::Angular && (source.is_none() || matches!(source, Some(DimensionSource::Points { .. }))) { point(p, "vertex")? } else { Point::ZERO }; - let mut d = Dimension { mode: mode.into(), a, b, vertex, offset: distance(p, 30.0)?, length: a.distance(b), options: options(s, p)?, source }; + let mut d = Dimension { mode, a, b, vertex, offset: distance(p, 30.0)?, length: a.distance(b), options: options(s, p)?, source, moved: None }; d.resolve(&s.doc()?.doc).map_err(error)?; Ok(d) } @@ -126,6 +104,7 @@ fn insert(doc: &mut Document, parent: Option, dim: Dimension) -> Result< let mut group = Node::new(id, NodeKind::Group { children: vec![], clip: false }); group.name = Some(format!("Dimension · {}", dim.label().map_err(error)?)); group.kind = NodeKind::Group { children: artwork(doc, &dim, &[])?, clip: false }; + doc.dimensions = true; group.extras_mut().dimension = Some(Box::new(dim)); doc.insert(parent, usize::MAX, group)?; Ok(id) @@ -144,79 +123,8 @@ fn create(s: &mut Session, p: &Value) -> Result { Ok(json!({"id":id.0,"label":label})) } -fn selection(s: &mut Session, p: &Value) -> Result { - let o = options(s, p)?; - let offset = distance(p, 30.0)?; - let st = s.doc()?; - let mut refs = Vec::new(); - fn gather(n: &Node, refs: &mut Vec) -> Result<()> { - if !n.visible || n.locked || n.extras().dimension.is_some() { - return Ok(()); - } - if let Some(path) = n.path_data() { - for (si, sp) in path.subpaths.iter().enumerate() { - for seg in 0..sp.segment_count() { - if refs.len() >= 1000 { - return Err(error("Select at most 1000 segments at a time")); - } - if sp.segment(seg).p0.distance(sp.segment(seg).p3) > 1e-6 { - refs.push(SegmentRef { node: n.id, subpath: si, segment: seg }); - } - } - } - } - if let Some(children) = n.children() { - for c in children { - gather(c, refs)?; - } - } - Ok(()) - } - for id in &st.selection.objects { - if !st.doc.is_editable(*id) { - continue; - } - if let Some(n) = st.doc.node(*id) { - gather(n, &mut refs)?; - } - } - refs.sort_by_key(|r| (r.node, r.subpath, r.segment)); - refs.dedup(); - if refs.is_empty() { - return Err(error("Select one or more paths first")); - } - let parent = st.insertion_parent(); - let offsets: Vec = refs - .iter() - .map(|edge| { - st.doc - .node(edge.node) - .and_then(Node::path_data) - .and_then(|path| subpath_area(path, edge.subpath)) - .map_or(offset, |area| -area.signum() * offset.abs()) - }) - .collect(); - s.note_journal("options", serde_json::to_value(&o).map_err(error)?); - let ids = s.edit("Dimension Selected Paths", |doc, sel| { - let mut ids = Vec::new(); - for (edge, edge_offset) in refs.into_iter().zip(offsets) { - let mut dim = Dimension { - mode: "linear".into(), - a: Point::ZERO, - b: Point::ZERO, - vertex: Point::ZERO, - offset: edge_offset, - length: 0.0, - options: o.clone(), - source: Some(DimensionSource::Segment { edge }), - }; - dim.resolve(doc).map_err(error)?; - ids.push(insert(doc, parent, dim)?); - } - sel.set(ids.clone()); - Ok(ids) - })?; - Ok(json!({"ids":ids})) +fn dimension_of(doc: &Document, id: NodeId) -> Result { + doc.node(id).and_then(|n| n.extras().dimension.as_deref()).cloned().ok_or_else(|| error("Select only dimensions")) } fn update(s: &mut Session, p: &Value) -> Result { @@ -224,8 +132,8 @@ fn update(s: &mut Session, p: &Value) -> Result { Some(v) => serde_json::from_value(v.clone()).map_err(error)?, None => s.doc()?.selection.objects.clone(), }; - if ids.is_empty() || ids.len() > 1000 { - return Err(error("Select dimensions to update (at most 1000)")); + if ids.is_empty() || ids.len() > 10_000 { + return Err(error("Select dimensions to update")); } let o = if p.get("options").is_some() { Some(options(s, p)?) } else { None }; s.edit("Update Dimensions", |doc, _| { @@ -233,8 +141,7 @@ fn update(s: &mut Session, p: &Value) -> Result { if !doc.is_editable(*id) { return Err(error("Dimension is locked or hidden")); } - let mut dim = - doc.node(*id).and_then(|n| n.extras().dimension.as_deref()).cloned().ok_or_else(|| error("Select only dimension groups"))?; + let mut dim = dimension_of(doc, *id)?; if let Some(o) = &o { dim.options = o.clone(); } @@ -247,6 +154,36 @@ fn update(s: &mut Session, p: &Value) -> Result { Ok(json!({"ids":ids})) } +fn apply_to_all(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let from = match p.get("id") { + Some(v) => NodeId(v.as_u64().ok_or_else(|| error("id must be a dimension id"))?), + None => *st + .selection + .objects + .iter() + .find(|id| st.doc.node(**id).is_some_and(|n| n.extras().dimension.is_some())) + .ok_or_else(|| error("Select a dimension first"))?, + }; + let o = dimension_of(&st.doc, from)?.options; + let mut ids = vec![]; + st.doc.walk(|n| { + if n.extras().dimension.is_some() && st.doc.is_editable(n.id) { + ids.push(n.id); + } + }); + s.edit("Apply to All", |doc, _| { + for id in &ids { + let mut dim = dimension_of(doc, *id)?; + dim.options = o.clone(); + dim.resolve(doc).map_err(error)?; + replace(doc, *id, dim)?; + } + Ok(()) + })?; + Ok(json!({"ids":ids})) +} + 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)?; @@ -258,21 +195,39 @@ fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> { Ok(()) } -/// Called inside the source edit transaction, so its dimensions and undo history stay in sync. +/// Called inside every edit, so dimensions and their undo history stay in step with the art they +/// measure. Returns at once in documents without dimensions. pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> { + if !doc.dimensions { + return Ok(()); + } let mut dimensions = Vec::new(); doc.walk(|n| { if let Some(d) = &n.extras().dimension { dimensions.push((n.id, (**d).clone())); } }); + doc.dimensions = !dimensions.is_empty(); for (id, old) in dimensions { 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)?; - } else if next != old || before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next) { + continue; + } + // 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); + if next.resolve(doc).is_err() { + next = old.clone(); + next.moved = None; + } + } + if next != old || before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next) { replace(doc, id, next)?; } } @@ -291,23 +246,23 @@ fn line(a: Point, b: Point) -> BezPath { fn appearance(o: &DimensionOptions, ext: bool, arrows: bool) -> Appearance { let (color, width, kind) = - if ext { (o.extension_color, o.extension_weight, o.extension_type.as_str()) } else { (o.line_color, o.line_weight, o.line_type.as_str()) }; + if ext { (o.extension_color, o.extension_weight, o.extension_type) } else { (o.line_color, o.line_weight, o.line_type) }; let mut a = Appearance::basic(Paint::None, paint(color), width); if let Some(st) = a.stroke_mut() { st.dash = match kind { - "dashed" => Some(Dash { pattern: vec![width * 5.0, width * 3.0], ..Default::default() }), - "dotted" => { + LineType::Dashed => Some(Dash { pattern: vec![width * 5.0, width * 3.0], ..Default::default() }), + LineType::Dotted => { st.cap = vectorcraft_doc::LineCap::Round; Some(Dash { pattern: vec![0.0, width * 3.0], ..Default::default() }) } - _ => None, + LineType::Solid => None, }; if arrows { - let head = match o.arrow_style.as_str() { - "filled" => Some(Arrowhead::Triangle), - "open" => Some(Arrowhead::TriangleOpen), - "tick" => Some(Arrowhead::Slash), - _ => None, + let head = match o.arrow_style { + ArrowStyle::Filled => Some(Arrowhead::Triangle), + ArrowStyle::Open => Some(Arrowhead::TriangleOpen), + ArrowStyle::Tick => Some(Arrowhead::Slash), + ArrowStyle::None => None, }; st.start_arrow = head; st.end_arrow = head; @@ -318,6 +273,15 @@ fn appearance(o: &DimensionOptions, ext: bool, arrows: bool) -> Appearance { a } +/// How far along the line (0 start, 0.5 middle, 1 end) the label sits, kept clear of the arrows. +fn along(o: &DimensionOptions) -> f64 { + match o.text_alignment { + TextAlignment::Left => 0.2, + TextAlignment::Center => 0.5, + TextAlignment::Right => 0.8, + } +} + fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result>> { d.validate().map_err(error)?; let o = &d.options; @@ -325,13 +289,9 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result = Vec::new(); let mut angle = 0.0; let text_at; - let side = if d.offset < 0.0 { -1.0 } else { 1.0 }; - let text_shift = match o.text_position.as_str() { - "below" => o.text_offset + o.font_size, - "center" => o.font_size * 0.35, - _ => -o.text_offset, - }; - if d.mode == "angular" { + // Away from the object: past the dimension line; between: on the object's side of it. + let away = o.text_position == TextPosition::Away; + if d.mode == DimensionMode::Angular { let start = (d.a - d.vertex).atan2(); let sweep = d.angle(); let r = d.offset.abs().max(12.0); @@ -348,32 +308,42 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result (d.a, d.b), - "diameter" => (d.a - delta, d.b), + let (a, b) = match d.mode { + DimensionMode::Radius => (d.a, d.b), + DimensionMode::Diameter => (d.a - delta, d.b), _ => (d.a + n * d.offset, d.b + n * d.offset), }; - if d.mode == "linear" && !o.hide_extensions { + // The side of the line facing away from the object. + let outward = match d.mode { + DimensionMode::Linear => { + if d.offset < 0.0 { + -n + } else { + n + } + } + _ => n, + }; + if d.mode == DimensionMode::Linear && !o.hide_extensions { for (p, q) in [(d.a, a), (d.b, b)] { - paths.push((line(p + n * (o.extension_offset * side), q + n * (o.extension_length * side)), appearance(o, true, false))); + paths.push((line(p + outward * o.extension_offset, q + outward * o.extension_length), appearance(o, true, false))); } } angle = u.atan2(); if !(-std::f64::consts::FRAC_PI_2..=std::f64::consts::FRAC_PI_2).contains(&angle) { angle += std::f64::consts::PI; } - let normal = Vec2::new(-angle.sin(), angle.cos()); - text_at = a.midpoint(b) + normal * text_shift; + let shift = o.text_offset + o.font_size * 0.5; + text_at = a.lerp(b, along(o)) + outward * if away { shift } else { -shift }; paths.push((line(a, b), appearance(o, false, true))); } - if o.text_alignment == "horizontal" { - angle = 0.0; - } let style = CharStyle { font_family: o.font_family.clone(), font_style: o.font_style.clone(), @@ -384,32 +354,10 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result Session { @@ -30,6 +31,13 @@ fn text(s: &Session, id: u64) -> String { let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap(); n.children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).unwrap() } +fn recipe(s: &Session, id: u64) -> vectorcraft_doc::dimension::Dimension { + (**s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().dimension.as_ref().unwrap()).clone() +} +fn ev(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap(); +} + #[test] fn dimension_length_scale_live_edit_undo_and_roundtrip() { let mut s = session(); @@ -45,6 +53,7 @@ fn dimension_length_scale_live_edit_undo_and_roundtrip() { assert_eq!(text(&s, id), "20.00 in"); let json = serde_json::to_string(&*s.doc().unwrap().doc).unwrap(); let doc: Document = serde_json::from_str(&json).unwrap(); + assert!(doc.dimensions, "saved with the document"); let mut reopened = Session::new(); reopened.add_document(doc, None); run(&mut reopened, "select.set", json!({"ids":[line]})); @@ -55,32 +64,38 @@ fn dimension_length_scale_live_edit_undo_and_roundtrip() { run(&mut reopened, "edit.undo", json!({})); assert_eq!(text(&reopened, id), "10.00 in"); } + +/// Documents without dimensions skip the refresh, and lose the flag when the last goes. #[test] -fn dimension_selected_rectangle_and_styles_are_one_undo() { +fn documents_without_dimensions_skip_the_refresh() { let mut s = session(); - run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":144})); - let result = run( - &mut s, - "dimension.selection", - json!({"options":{"units":"in","precision":0,"lineType":"dashed","arrowStyle":"open","hideExtensions":true,"textPosition":"center"}}), - ); - let ids = result["ids"].as_array().unwrap(); - assert_eq!(ids.len(), 4); - let labels: Vec<_> = ids.iter().map(|id| text(&s, id.as_u64().unwrap())).collect(); - assert_eq!(labels.iter().filter(|s| s.as_str() == "1 in").count(), 2); - let id = NodeId(ids[0].as_u64().unwrap()); - let node = s.doc().unwrap().doc.node(id).unwrap(); - assert_eq!(node.children().unwrap().len(), 3); // Split line and text, no extension lines. - let stroke = node.children().unwrap()[0].appearance.stroke().unwrap(); - assert!(stroke.dash.is_some()); - assert_eq!(stroke.start_arrow, Some(vectorcraft_doc::Arrowhead::TriangleOpen)); - run(&mut s, "edit.undo", json!({})); - assert!(s.doc().unwrap().doc.node(id).is_none()); - run(&mut s, "edit.redo", json!({})); - assert!(s.doc().unwrap().doc.node(id).is_some()); + let line = edge(&mut s); + assert!(!s.doc().unwrap().doc.dimensions); + let id = dim(&mut s, line); + assert!(s.doc().unwrap().doc.dimensions); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "edit.clear", json!({})); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.move", json!({"dx":1,"dy":0})); + assert!(!s.doc().unwrap().doc.dimensions); } + #[test] -fn dimension_angles_circles_and_curved_lengths() { +fn apply_to_all_restyles_every_dimension_in_one_undo_step() { + let mut s = session(); + let line = edge(&mut s); + let first = dim(&mut s, line); + let second = run(&mut s, "dimension.create", json!({"a":[0,0],"b":[72,0],"options":{"units":"pt","precision":0}}))["id"].as_u64().unwrap(); + assert_eq!(text(&s, second), "72 pt"); + run(&mut s, "dimension.applyToAll", json!({"id":first})); + assert_eq!(text(&s, second), "10.00 in", "the first dimension's style, its scale too"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s, second), "72 pt"); + assert!(s.execute("dimension.applyToAll", &json!({"id":line})).is_err(), "not a dimension"); +} + +#[test] +fn dimension_angles_circles_arcs_and_curves() { let mut s = session(); let a = run( &mut s, @@ -91,18 +106,39 @@ fn dimension_angles_circles_and_curved_lengths() { .unwrap(); assert_eq!(text(&s, a), "90.0°"); let circle = run(&mut s, "shape.ellipse", json!({"x":100,"y":100,"width":144,"height":144}))["id"].as_u64().unwrap(); - for (mode, want) in [("radius", "R 1.0 in"), ("diameter", "Ø 2.0 in")] { - let id = run(&mut s, "dimension.create", json!({"mode":mode,"source":{"kind":"circle","node":circle},"options":{"units":"in"}}))["id"] + for (mode, want) in [("radius", "1.0 in"), ("diameter", "2.0 in")] { + let id = run( + &mut s, + "dimension.create", + json!({"mode":mode,"source":{"kind":"circle","node":circle},"options":{"units":"in","precision":1}}), + )["id"] .as_u64() .unwrap(); assert_eq!(text(&s, id), want); } + // One quarter of the circle is a perfect arc. + let id = run( + &mut s, + "dimension.create", + json!({"mode":"radius","source":{"kind":"arc","edge":{"node":circle,"subpath":0,"segment":0}},"options":{"units":"in","precision":1}}), + )["id"] + .as_u64() + .unwrap(); + assert_eq!(text(&s, id), "1.0 in"); + // A curve that isn't an arc: no radius, and its length is measured end to end. let curve = run(&mut s, "path.create", json!({"d":"M 0 0 C 0 100 100 100 100 0"}))["id"].as_u64().unwrap(); - let id = dim(&mut s, curve); - assert!(text(&s, id).starts_with("Arc ")); - let d = s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().dimension.as_ref().unwrap(); - assert!((d.length - 200.0).abs() < 0.001); + let arc = json!({"mode":"radius","source":{"kind":"arc","edge":{"node":curve,"subpath":0,"segment":0}}}); + assert!(s.execute("dimension.create", &arc).is_err()); + let id = run( + &mut s, + "dimension.create", + json!({"source":{"kind":"segment","edge":{"node":curve,"subpath":0,"segment":0}},"options":{"units":"pt","precision":0}}), + )["id"] + .as_u64() + .unwrap(); + assert_eq!(text(&s, id), "100 pt"); } + #[test] fn dimension_rejects_bad_input_without_mutation() { let mut s = session(); @@ -116,6 +152,9 @@ fn dimension_rejects_bad_input_without_mutation() { json!({"a":[0,0],"b":[10,0],"options":{"precision":99999}}), json!({"a":[0,0],"b":[10,0],"options":{"scale":"0:10"}}), json!({"a":[0,0],"b":[10,0],"options":{"scale":"1:NaN"}}), + json!({"a":[0,0],"b":[10,0],"options":{"units":"furlongs"}}), + json!({"a":[0,0],"b":[10,0],"options":{"textPosition":"sideways"}}), + json!({"a":[0,0],"b":[10,0],"mode":"spiral"}), json!({"source":{"kind":"segment","edge":{"node":line,"subpath":0,"segment":18446744073709551615u64}}}), json!({"mode":"angular","source":{"kind":"segment","edge":{"node":line,"subpath":0,"segment":0}}}), ] { @@ -126,33 +165,86 @@ fn dimension_rejects_bad_input_without_mutation() { assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), original); } +/// Hover a segment and drag outwards: releasing places the dimension; Escape cancels the drag. #[test] -fn dimension_gesture_highlights_preview_commits_and_cancels() { - use vectorcraft_engine::{ - ViewInfo, - tools::{Mods, PointerEvent, PointerKind, ToolKey}, - }; +fn dragging_out_from_a_segment_places_its_dimension() { let mut s = session(); edge(&mut s); - let view = ViewInfo::default(); - s.select_tool("dimension", view).unwrap(); + s.select_tool("dimension", ViewInfo::default()).unwrap(); let before = s.doc().unwrap().doc.node_count(); - s.pointer(&PointerEvent::new(PointerKind::Move, 130.0, 100.0), view).unwrap(); - s.pointer(&PointerEvent::new(PointerKind::Down, 130.0, 100.0), view).unwrap(); - s.pointer(&PointerEvent::new(PointerKind::Move, 130.0, 60.0), view).unwrap(); + ev(&mut s, PointerKind::Move, 130.0, 100.0); + ev(&mut s, PointerKind::Down, 130.0, 100.0); + ev(&mut s, PointerKind::Drag, 130.0, 70.0); assert!(s.in_interaction()); - s.tool_key(ToolKey::Escape, Mods::default(), view).unwrap(); + s.tool_key(ToolKey::Escape, Mods::default(), ViewInfo::default()).unwrap(); assert_eq!(s.doc().unwrap().doc.node_count(), before); assert!(!s.in_interaction()); - s.pointer(&PointerEvent::new(PointerKind::Down, 130.0, 100.0), view).unwrap(); - s.pointer(&PointerEvent::new(PointerKind::Move, 130.0, 60.0), view).unwrap(); - s.pointer(&PointerEvent::new(PointerKind::Down, 130.0, 60.0), view).unwrap(); + ev(&mut s, PointerKind::Up, 130.0, 70.0); + ev(&mut s, PointerKind::Down, 130.0, 100.0); + ev(&mut s, PointerKind::Drag, 130.0, 80.0); + ev(&mut s, PointerKind::Drag, 130.0, 60.0); + ev(&mut s, PointerKind::Up, 130.0, 60.0); assert!(!s.in_interaction()); assert!(s.doc().unwrap().doc.node_count() > before); + let id = s.doc().unwrap().selection.objects[0].0; + assert_eq!(text(&s, id), "72.00 pt"); + assert!((recipe(&s, id).offset.abs() - 40.0).abs() < 1e-6, "placed where it was released"); run(&mut s, "edit.undo", json!({})); assert_eq!(s.doc().unwrap().doc.node_count(), before); } +/// Plain clicks pick two points, and a third click places the dimension. +#[test] +fn clicking_two_points_then_placing_measures_between_them() { + let mut s = session(); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + for (x, y) in [(300.0, 300.0), (372.0, 300.0)] { + ev(&mut s, PointerKind::Down, x, y); + ev(&mut s, PointerKind::Up, x, y); + } + ev(&mut s, PointerKind::Move, 336.0, 330.0); + assert!(s.in_interaction()); + ev(&mut s, PointerKind::Down, 336.0, 330.0); + ev(&mut s, PointerKind::Up, 336.0, 330.0); + assert!(!s.in_interaction()); + let id = s.doc().unwrap().selection.objects[0].0; + assert_eq!(text(&s, id), "72.00 pt"); +} + +/// Dragging out from a corner of two straight segments measures the angle between them. +#[test] +fn dragging_out_from_a_corner_measures_its_angle() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":50})); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"mode":"angular"}})).unwrap(); + ev(&mut s, PointerKind::Move, 100.0, 100.0); + ev(&mut s, PointerKind::Down, 100.0, 100.0); + ev(&mut s, PointerKind::Drag, 120.0, 120.0); + ev(&mut s, PointerKind::Drag, 130.0, 130.0); + ev(&mut s, PointerKind::Up, 130.0, 130.0); + let id = s.doc().unwrap().selection.objects[0].0; + assert_eq!(text(&s, id), "90.00°"); +} + +/// Dragging out from a circle gives its radius; dragging inwards, its diameter. +#[test] +fn dragging_from_a_circle_gives_its_radius_or_diameter() { + let mut s = session(); + run(&mut s, "shape.ellipse", json!({"x":100,"y":100,"width":144,"height":144})); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"mode":"radial","style":{"units":"in","precision":0}}})).unwrap(); + for ((x0, y0), (x1, y1), want) in [((244.0, 172.0), (280.0, 172.0), "1 in"), ((244.0, 172.0), (200.0, 172.0), "2 in")] { + ev(&mut s, PointerKind::Move, x0, y0); + ev(&mut s, PointerKind::Down, x0, y0); + ev(&mut s, PointerKind::Drag, (x0 + x1) / 2.0, y0); + ev(&mut s, PointerKind::Drag, x1, y1); + ev(&mut s, PointerKind::Up, x1, y1); + let id = s.doc().unwrap().selection.objects[0].0; + assert_eq!(text(&s, id), want); + } +} + #[test] fn dimension_anchor_links_and_native_file_survive_source_moves() { let mut s = session(); @@ -169,14 +261,36 @@ fn dimension_anchor_links_and_native_file_survive_source_moves() { assert_eq!(text(&s, id), "100 pt"); } +/// 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] +fn a_moved_linked_dimension_stays_where_it_was_dropped() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let offset = recipe(&s, id).offset; + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.move", json!({"dx":0,"dy":-20})); + let moved = recipe(&s, id).offset; + assert!((moved - offset).abs() - 20.0 < 1e-6 && (moved - offset).abs() > 19.0, "{offset} → {moved}"); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":100})); + assert!((recipe(&s, id).offset - moved).abs() < 1e-6, "no jump back"); + run(&mut s, "select.set", json!({"ids":[line, id]})); + run(&mut s, "object.move", json!({"dx":0,"dy":50})); + assert!((recipe(&s, id).offset - moved).abs() < 1e-6, "moved together"); +} + #[test] fn dimension_options_are_persistent_and_invalid_updates_report_errors() { let mut s = session(); - s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"style":{"scale":"10:1","units":"mm","precision":3},"mode":"diameter"}})).unwrap(); + s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"style":{"scale":"10:1","units":"mm","precision":3},"mode":"radial"}})).unwrap(); assert_eq!(s.tool_options_of("dimension")["style"]["scale"], "10:1"); assert!(s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"style":{"scale":"1:0"}}})).is_err()); let o: vectorcraft_doc::dimension::DimensionOptions = serde_json::from_value(s.tool_options_of("dimension")["style"].clone()).unwrap(); assert_eq!(o.label(72.0, false).unwrap(), "2.540 mm"); + let picas: vectorcraft_doc::dimension::DimensionOptions = serde_json::from_value(json!({"units":"pc","precision":1})).unwrap(); + assert_eq!(picas.label(72.0, false).unwrap(), "6.0 pc"); let prefs = serde_json::to_value(&s.prefs).unwrap(); let mut fresh = Session::new(); fresh.prefs = serde_json::from_value(prefs).unwrap(); @@ -196,7 +310,7 @@ fn dimension_linked_angle_and_circle_update_and_reject_non_circles() { 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 id=run(&mut s,"dimension.create",json!({"mode":"angular","source":{"kind":"angle","first":{"node":first,"subpath":0,"segment":0},"second":{"node":second,"subpath":0,"segment":0}}}))["id"].as_u64().unwrap(); + let id=run(&mut s,"dimension.create",json!({"mode":"angular","source":{"kind":"angle","first":{"node":first,"subpath":0,"segment":0},"second":{"node":second,"subpath":0,"segment":0}},"options":{"precision":1}}))["id"].as_u64().unwrap(); assert_eq!(text(&s, id), "90.0°"); run(&mut s, "select.set", json!({"ids":[second]})); run(&mut s, "object.rotate", json!({"angle":45,"origin":[100,100]})); @@ -211,7 +325,7 @@ fn dimension_linked_angle_and_circle_update_and_reject_non_circles() { .unwrap(); run(&mut s, "select.set", json!({"ids":[circle]})); run(&mut s, "object.scale", json!({"sx":200,"sy":200})); - assert_eq!(text(&s, id), "Ø 4 in"); + 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()); assert!(s.execute("dimension.create", &json!({"mode":"radius","source":{"kind":"circle","node":circle}})).is_err()); @@ -225,10 +339,10 @@ fn dimension_style_update_keeps_links_and_can_be_undone() { run( &mut s, "dimension.update", - json!({"ids":[id],"offset":-42,"options":{"units":"mm","precision":2,"hideUnits":true,"textPosition":"below","lineColor":[10,20,30]}}), + json!({"ids":[id],"offset":-42,"options":{"units":"mm","precision":2,"hideUnits":true,"textPosition":"between","textAlignment":"right","lineColor":[10,20,30]}}), ); assert_eq!(text(&s, id), "25.40"); - let d = s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().dimension.as_ref().unwrap(); + let d = recipe(&s, id); assert!(d.source.is_some()); assert_eq!(d.offset, -42.0); run(&mut s, "edit.undo", json!({})); diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs index 9e91f12d7..7c1fe218f 100644 --- a/crates/tools/src/catalog.rs +++ b/crates/tools/src/catalog.rs @@ -102,7 +102,7 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ &[t("gradient", "Gradient Tool", Some("G"), "tool-gradient")], &[t("eyedropper", "Eyedropper Tool", Some("I"), "tool-eyedropper"), t("measure", "Measure Tool", None, "tool-measure")], &[t("blend", "Blend Tool", Some("W"), "tool-blend")], - &[t("dimension", "Dimension Tool", None, "tool-measure")], + &[t("dimension", "Dimension Tool", None, "tool-dimension")], &[ t("symbolSprayer", "Symbol Sprayer Tool", Some("Shift+S"), "tool-symbol"), t("symbolShifter", "Symbol Shifter Tool", None, "tool-symbol"), diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index 8d8961f6c..eb482a036 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -1,22 +1,64 @@ -//! Click a highlighted segment, then click to place its linked dimension. Empty-space clicks -//! measure two points (three for an angle). Escape cancels without leaving any artwork. +//! The Dimension tool. Hover a segment (linear), a corner (angular) or a circle or perfect arc +//! (radial) and drag outwards: releasing places the linked dimension. Plain clicks pick what to +//! measure instead: two points (linear) or two straight lines (angular), then a click places it; +//! a click on a circle or arc gives its radius. Escape cancels without leaving any artwork. use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; use serde_json::{Value, json}; use vectorcraft_doc::{ Node, - dimension::{Dimension, DimensionOptions, DimensionSource, SegmentRef}, + dimension::{self, Dimension, DimensionMode, DimensionOptions, DimensionSource, PointRef, SegmentRef}, }; use vectorcraft_geom::{BezPath, Point, Vec2}; +/// The three kinds of dimension the tool draws. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Kind { + #[default] + Linear, + Angular, + Radial, +} + +impl Kind { + pub fn id(self) -> &'static str { + match self { + Kind::Linear => "linear", + Kind::Angular => "angular", + Kind::Radial => "radial", + } + } + /// `radius` and `diameter` are the radial kind. + pub fn parse(s: &str) -> Option { + match s { + "linear" => Some(Kind::Linear), + "angular" => Some(Kind::Angular), + "radial" | "radius" | "diameter" => Some(Kind::Radial), + _ => None, + } + } +} + +/// What the pointer is over. +#[derive(Clone, Debug, PartialEq)] +enum Target { + Segment(SegmentRef), + Corner { first: SegmentRef, second: SegmentRef, at: Point }, + Radial { source: DimensionSource, center: Point, radius: f64 }, +} + #[derive(Default)] pub struct DimensionTool { - mode: String, + kind: Kind, style: DimensionOptions, - points: Vec, - point_refs: Vec>, - first_edge: Option, + /// Where the button went down, and what was under it. + press: Option<(Point, Option)>, + /// Plain-click picks: points (linear) or straight lines (angular). + points: Vec<(Point, Option)>, + lines: Vec, + /// A dimension being placed: previews follow the pointer. pending: Option, - hover: Option, + dragging: bool, + hover: Option, at: Point, began: bool, } @@ -58,22 +100,64 @@ pub fn pick(cx: &ToolContext, p: Point) -> Option { best } +/// The anchor of `edge` within reach of `p`, if any. +fn anchor_near(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option<(usize, Point)> { + let sp = cx.doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?; + let next = if edge.segment + 1 < sp.anchors.len() { edge.segment + 1 } else { 0 }; + [edge.segment, next] + .into_iter() + .filter_map(|i| Some((i, sp.anchors.get(i)?.p))) + .filter(|(_, a)| a.distance(p) <= cx.tol(8.0)) + .min_by(|(_, a), (_, b)| a.distance(p).total_cmp(&b.distance(p))) +} + impl DimensionTool { - fn point_source(&self, angular: bool) -> Option { - if self.point_refs.iter().all(Option::is_none) { - return None; + fn target(&self, cx: &ToolContext, p: Point) -> Option { + let edge = pick(cx, p)?; + match self.kind { + Kind::Linear => Some(Target::Segment(edge)), + Kind::Angular => { + let (anchor, at) = anchor_near(cx, &edge, p)?; + let (first, second) = dimension::corner(cx.doc, edge.node, edge.subpath, anchor)?; + Some(Target::Corner { first, second, at }) + } + Kind::Radial => { + if let Some((center, radius)) = dimension::circle(cx.doc, edge.node) { + return Some(Target::Radial { source: DimensionSource::Circle { node: edge.node }, center, radius }); + } + let (center, radius) = dimension::arc(cx.doc, &edge)?; + Some(Target::Radial { source: DimensionSource::Arc { edge }, center, radius }) + } } - Some(DimensionSource::Points { - a: self.point_refs.first().cloned().flatten(), - b: self.point_refs.get(if angular { 2 } else { 1 }).cloned().flatten(), - vertex: if angular { self.point_refs.get(1).cloned().flatten() } else { None }, - }) } - fn mode(&self) -> &str { - if self.mode.is_empty() { "linear" } else { &self.mode } + fn recipe(&self, mode: DimensionMode, a: Point, b: Point, vertex: Point, source: Option) -> Dimension { + Dimension { mode, a, b, vertex, offset: 0.0, length: a.distance(b), options: self.style.clone(), source, moved: None } } - fn recipe(&self, a: Point, b: Point, vertex: Point, source: Option) -> Dimension { - Dimension { mode: self.mode().into(), a, b, vertex, offset: 30.0, length: a.distance(b), options: self.style.clone(), source } + /// The dimension a target gives when dragged from. + fn dimension_for(&self, target: &Target, p: Point) -> Option { + let mut d = match target { + Target::Segment(edge) => self.recipe( + DimensionMode::Linear, + Point::ZERO, + Point::new(1.0, 0.0), + Point::ZERO, + Some(DimensionSource::Segment { edge: edge.clone() }), + ), + Target::Corner { first, second, .. } => self.recipe( + DimensionMode::Angular, + Point::ZERO, + Point::new(1.0, 0.0), + Point::ZERO, + Some(DimensionSource::Angle { first: first.clone(), second: second.clone() }), + ), + Target::Radial { source, center, radius } => { + // Dragged outwards: the radius; inwards: the diameter. + let mode = if p.distance(*center) >= *radius { DimensionMode::Radius } else { DimensionMode::Diameter }; + self.recipe(mode, *center, p, Point::ZERO, Some(source.clone())) + } + }; + d.offset = 30.0; + Some(d) } fn params(d: &Dimension) -> Value { let mut p = json!({"mode":d.mode,"a":[d.a.x,d.a.y],"b":[d.b.x,d.b.y],"vertex":[d.vertex.x,d.vertex.y],"offset":d.offset,"options":d.options}); @@ -84,16 +168,12 @@ impl DimensionTool { } fn preview(&mut self, cx: &ToolContext, p: Point) -> Vec { let Some(d) = &mut self.pending else { return vec![] }; - if d.mode == "linear" { - let u = d.b - d.a; - d.offset = (p - d.a).dot(Vec2::new(-u.y, u.x) / u.hypot()); - } else if d.mode == "angular" { - d.offset = p.distance(d.vertex).max(12.0); - } else if p.distance(d.a) > 1e-6 { - let r = d.a.distance(d.b); - let u = p - d.a; - d.b = d.a + u / u.hypot() * r; + if matches!(d.mode, DimensionMode::Radius | DimensionMode::Diameter) + && let Some(Target::Radial { center, radius, .. }) = self.press.as_ref().and_then(|(_, t)| t.as_ref()) + { + d.mode = if p.distance(*center) >= *radius { DimensionMode::Radius } else { DimensionMode::Diameter }; } + d.place(p); if d.resolve(cx.doc).is_err() { return vec![]; } @@ -105,13 +185,84 @@ impl DimensionTool { actions.push(Action::Preview("dimension.create".into(), Self::params(d))); actions } + /// Places the pending dimension at `p` and commits it. + fn place(&mut self, cx: &ToolContext, p: Point) -> Vec { + let mut out = self.preview(cx, p); + if self.began { + out.push(Action::Commit); + } + self.reset(); + out + } fn reset(&mut self) { self.pending = None; + self.press = None; self.points.clear(); - self.point_refs.clear(); - self.first_edge = None; + self.lines.clear(); + self.dragging = false; self.began = false; } + /// A plain click: picks a point or a line, or measures a circle's radius. + fn click(&mut self, cx: &ToolContext, p: Point, target: Option, mods: Mods) -> Vec { + match self.kind { + Kind::Linear => { + let (mut at, _) = crate::guides::snap_pick(cx, p); + let mut reference = None; + if let Some(edge) = pick(cx, p) + && let Some((anchor, point)) = anchor_near(cx, &edge, p) + { + at = point; + reference = Some(PointRef { node: edge.node, subpath: edge.subpath, anchor }); + } + if mods.shift + && let Some((last, _)) = self.points.last() + { + at = *last + vectorcraft_geom::constrain_angle_from(at - *last, 45.0, cx.constrain_angle); + reference = reference.filter(|r| r.position(cx.doc) == Some(at)); + } + 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 mut d = self.recipe(DimensionMode::Linear, *a, *b, Point::ZERO, source); + if d.resolve(cx.doc).is_ok() { + self.pending = Some(d); + } + self.points.clear(); + } + vec![] + } + Kind::Angular => { + let Some(edge) = pick(cx, p).filter(|e| e.is_straight(cx.doc)) else { return vec![] }; + if self.lines.first().is_some_and(|first| *first == edge) { + return vec![]; + } + self.lines.push(edge); + if let [first, second] = self.lines.as_slice() { + let mut d = self.recipe( + DimensionMode::Angular, + Point::ZERO, + Point::new(1.0, 0.0), + Point::ZERO, + Some(DimensionSource::Angle { first: first.clone(), second: second.clone() }), + ); + if d.resolve(cx.doc).is_ok() { + d.offset = 30.0; + self.pending = Some(d); + } + self.lines.clear(); + } + vec![] + } + Kind::Radial => { + let Some(target @ Target::Radial { .. }) = target else { return vec![] }; + let Some(mut d) = self.dimension_for(&target, p) else { return vec![] }; + d.mode = DimensionMode::Radius; + self.pending = Some(d); + self.press = Some((p, Some(target))); + self.place(cx, p) + } + } + } } impl Tool for DimensionTool { @@ -119,19 +270,19 @@ impl Tool for DimensionTool { "dimension" } fn busy(&self) -> bool { - self.pending.is_some() || !self.points.is_empty() || self.first_edge.is_some() + self.pending.is_some() || !self.points.is_empty() || !self.lines.is_empty() } fn cursor(&self, _: &ToolContext, _: Point, _: Mods) -> Cursor { Cursor::Crosshair } fn options(&self) -> Value { - json!({"mode":self.mode(),"style":self.style}) + json!({"mode":self.kind.id(),"style":self.style}) } fn set_option(&mut self, key: &str, value: &Value) { if key == "mode" - && let Some(v) = value.as_str().filter(|v| ["linear", "angular", "radius", "diameter"].contains(v)) + && let Some(kind) = value.as_str().and_then(Kind::parse) { - self.mode = v.into(); + self.kind = kind; } if key == "style" && let Ok(o) = serde_json::from_value::(value.clone()) @@ -142,97 +293,45 @@ impl Tool for DimensionTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { self.at = ev.pos; - if matches!(ev.kind, PointerKind::Move | PointerKind::Drag) { - if self.pending.is_some() { - return self.preview(cx, ev.pos); - } - self.hover = pick(cx, ev.pos); - return vec![]; - } - if ev.kind != PointerKind::Down { - return vec![]; - } - if self.pending.is_some() { - let mut out = self.preview(cx, ev.pos); - if self.began { - out.push(Action::Commit); - } - self.reset(); - return out; - } - let hit = pick(cx, ev.pos); - let mut candidate = None; - match self.mode() { - "linear" if self.points.is_empty() && hit.is_some() && !ev.mods.alt => { - candidate = hit.map(|edge| self.recipe(Point::ZERO, Point::new(1.0, 0.0), Point::ZERO, Some(DimensionSource::Segment { edge }))); - } - "angular" if hit.is_some() && self.points.is_empty() && !ev.mods.alt => { - if let Some(second) = hit { - let straight = |edge: &SegmentRef| { - cx.doc - .node(edge.node) - .and_then(Node::path_data) - .and_then(|p| p.subpaths.get(edge.subpath)) - .is_some_and(|sp| edge.segment < sp.segment_count() && sp.segment_is_line(edge.segment)) - }; - if !straight(&second) { - return vec![]; - } - if let Some(first) = self.first_edge.clone() { - candidate = Some(self.recipe(Point::ZERO, Point::new(1.0, 0.0), Point::ZERO, Some(DimensionSource::Angle { first, second }))); - } else { - self.first_edge = Some(second); - } + match ev.kind { + PointerKind::Move => { + if self.pending.is_some() { + return self.preview(cx, ev.pos); } + self.hover = self.target(cx, ev.pos); + vec![] } - "radius" | "diameter" => { - if let Some(edge) = hit - && let Some((center, _)) = vectorcraft_doc::dimension::circle(cx.doc, edge.node) + PointerKind::Down => { + if self.pending.is_some() && !self.dragging { + // The second click of a picked measurement places it. + return self.place(cx, ev.pos); + } + self.press = Some((ev.pos, self.target(cx, ev.pos))); + vec![] + } + PointerKind::Drag => { + if self.pending.is_none() + && let Some((start, Some(target))) = self.press.clone() + && start.distance(ev.pos) > cx.tol(3.0) + && self.points.is_empty() + && self.lines.is_empty() { - candidate = Some(self.recipe(center, ev.pos, Point::ZERO, Some(DimensionSource::Circle { node: edge.node }))); + self.pending = self.dimension_for(&target, ev.pos); + self.dragging = self.pending.is_some(); } + if self.dragging { self.preview(cx, ev.pos) } else { vec![] } } - _ => { - let (mut p, _) = crate::guides::snap_pick(cx, ev.pos); - let reference = hit.as_ref().and_then(|edge| { - let sp = cx.doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?; - let next = if edge.segment + 1 < sp.anchors.len() { edge.segment + 1 } else { 0 }; - [edge.segment, next] - .into_iter() - .filter_map(|i| Some((i, sp.anchors.get(i)?.p))) - .filter(|(_, point)| point.distance(ev.pos) <= cx.tol(8.0)) - .min_by(|(_, a), (_, b)| a.distance(ev.pos).total_cmp(&b.distance(ev.pos))) - .map(|(anchor, point)| { - p = point; - vectorcraft_doc::dimension::PointRef { node: edge.node, subpath: edge.subpath, anchor } - }) - }); - if ev.mods.shift - && let Some(a) = self.points.last() - { - p = *a + vectorcraft_geom::constrain_angle_from(p - *a, 45.0, cx.constrain_angle); + PointerKind::Up => { + if self.dragging { + return self.place(cx, ev.pos); } - self.points.push(p); - self.point_refs.push(reference.filter(|r| r.position(cx.doc) == Some(p))); - match self.points.as_slice() { - [a, b] if self.mode() == "linear" => candidate = Some(self.recipe(*a, *b, Point::ZERO, self.point_source(false))), - [a, v, b] if self.mode() == "angular" => candidate = Some(self.recipe(*a, *b, *v, self.point_source(true))), - _ => {} + match self.press.take() { + Some((start, target)) if self.pending.is_none() => self.click(cx, start, target, ev.mods), + _ => vec![], } } + _ => vec![], } - if let Some(mut d) = candidate { - if d.resolve(cx.doc).is_ok() { - self.pending = Some(d); - self.points.clear(); - self.point_refs.clear(); - self.first_edge = None; - } else if self.points.len() >= 2 { - self.points.clear(); - self.point_refs.clear(); - } - } - vec![] } fn key(&mut self, _: &ToolContext, key: ToolKey, _: Mods) -> Vec { if key == ToolKey::Escape { @@ -248,29 +347,52 @@ impl Tool for DimensionTool { fn overlays(&self, cx: &ToolContext) -> Vec { let color = [60, 150, 255]; let mut out = vec![]; - if self.pending.is_none() - && let Some(edge) = &self.hover - && let Some(sp) = cx.doc.node(edge.node).and_then(Node::path_data).and_then(|p| p.subpaths.get(edge.subpath)) - && edge.segment < sp.segment_count() - { - let c = sp.segment(edge.segment); - let mut path = BezPath::new(); - path.move_to(c.p0); - path.curve_to(c.p1, c.p2, c.p3); - out.push(Overlay::Path { path, color, width: 3.0, dashed: false }); - for p in [c.p0, c.p3] { - out.push(Overlay::Anchor { p, color, filled: true, size: 6.0 }); - } - if let Some((_, _, length)) = edge.endpoints(cx.doc) - && let Ok(text) = self.style.label(length, false) + let segment = |edge: &SegmentRef, out: &mut Vec| { + if let Some(sp) = cx.doc.node(edge.node).and_then(Node::path_data).and_then(|p| p.subpaths.get(edge.subpath)) + && edge.segment < sp.segment_count() { - out.push(Overlay::Measure { p: self.at + Vec2::new(12.0, 12.0), text }); + let c = sp.segment(edge.segment); + let mut path = BezPath::new(); + path.move_to(c.p0); + path.curve_to(c.p1, c.p2, c.p3); + out.push(Overlay::Path { path, color, width: 3.0, dashed: false }); + } + }; + if self.pending.is_none() { + match &self.hover { + Some(Target::Segment(edge)) => { + segment(edge, &mut out); + if let Some((a, b)) = edge.endpoints(cx.doc) { + for p in [a, b] { + out.push(Overlay::Anchor { p, color, filled: true, size: 6.0 }); + } + if let Ok(text) = self.style.label(a.distance(b), false) { + out.push(Overlay::Measure { p: self.at + Vec2::new(12.0, 12.0), text }); + } + } + } + Some(Target::Corner { first, second, at }) => { + segment(first, &mut out); + segment(second, &mut out); + out.push(Overlay::Anchor { p: *at, color, filled: true, size: 7.0 }); + } + Some(Target::Radial { center, radius, .. }) => { + out.push(Overlay::Anchor { p: *center, color, filled: false, size: 6.0 }); + let toward = self.at - *center; + if toward.hypot() > 1e-9 { + out.push(Overlay::Line { a: *center, b: *center + toward / toward.hypot() * *radius, color, dashed: true }); + } + } + None => {} } } - for p in &self.points { + for (p, _) in &self.points { out.push(Overlay::Anchor { p: *p, color, filled: true, size: 6.0 }); } - if let Some(p) = self.points.last() { + for edge in &self.lines { + segment(edge, &mut out); + } + if let Some((p, _)) = self.points.last() { out.push(Overlay::Line { a: *p, b: self.at, color, dashed: true }); } out diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 5e921d29f..ac6c0cfb7 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -611,10 +611,10 @@ const ANIMATED_ZOOM_HINT: &str = "zoom.animated"; fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { Some(match tool { "dimension" => &[ - ("Click", true), - (" a highlighted edge, then click to place its dimension | ", false), + ("Drag", true), + (" out from a highlighted edge to place its dimension | ", false), ("Esc", true), - (" cancels | Double-click the tool for options", false), + (" cancels | Double-click the tool for options", false), ], "selection" => &[ ("Click", true), diff --git a/crates/ui-egui/src/dialogs/dimension.rs b/crates/ui-egui/src/dialogs/dimension.rs index 222e83a23..e494928bb 100644 --- a/crates/ui-egui/src/dialogs/dimension.rs +++ b/crates/ui-egui/src/dialogs/dimension.rs @@ -1,4 +1,5 @@ -//! Dimension options and compact mode strip. Values are drafts until Save. +//! Dimension Tool Options, the tool's control strip, and the Properties panel's Dimension section. +//! Dialog values are drafts until Save, which sets the style of new dimensions. use super::DialogSpec; use crate::{VectorcraftApp, state::Dialog, theme::Tokens, widgets}; use egui::{RichText, Ui}; @@ -16,68 +17,96 @@ pub(super) const SPEC: DialogSpec = DialogSpec { ..DialogSpec::FORM }; +/// (value, label) of each choice. +type Choices = &'static [(&'static str, &'static str)]; +const UNITS: Choices = &[ + ("in", "Inches"), + ("mm", "Millimeters"), + ("pt", "Points"), + ("pc", "Picas"), + ("px", "Pixels"), + ("cm", "Centimeters"), + ("m", "Meters"), + ("ft", "Feet"), + ("yd", "Yards"), +]; +const LINE_TYPES: Choices = &[("solid", "Solid"), ("dashed", "Dashed"), ("dotted", "Dotted")]; +const ARROWS: Choices = &[("filled", "Filled"), ("open", "Open"), ("tick", "Tick"), ("none", "None")]; +const POSITIONS: Choices = &[("away", "Away from the Line"), ("between", "Between Line and Object")]; +const ALIGNMENTS: Choices = &[("left", "Left"), ("center", "Center"), ("right", "Right")]; + pub fn open(app: &mut VectorcraftApp) -> Result { if app.session.tool_id() == "dimension" && app.session.tool_busy() { app.run("tool.select", json!({"tool":"selection"}))?; app.run("tool.select", json!({"tool":"dimension"}))?; } - let selected = app - .session - .active() - .and_then(|st| st.selection.objects.first().and_then(|id| st.doc.node(*id))) - .and_then(|n| n.extras().dimension.as_deref()); - let style = if let Some(d) = selected { - serde_json::to_value(&d.options).map_err(|e| e.to_string())? - } else { - app.session.tool_options_of("dimension")["style"].clone() - }; + let style = app.session.tool_options_of("dimension")["style"].clone(); let o: DimensionOptions = serde_json::from_value(style).unwrap_or_default(); - let mut fields = serde_json::to_value(o).map_err(|e| e.to_string())?.as_object().cloned().unwrap_or_default(); - fields.insert("applySelected".into(), json!(selected.is_some())); + let fields = serde_json::to_value(o).map_err(|e| e.to_string())?.as_object().cloned().unwrap_or_default(); app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); Ok(json!({"dialog":KIND})) } -fn read(d: &Dialog) -> Result { - let mut fields = d.fields.clone(); - fields.remove("applySelected"); - let o: DimensionOptions = serde_json::from_value(Value::Object(fields)).map_err(|e| e.to_string())?; +fn read(fields: &serde_json::Map) -> Result { + let o: DimensionOptions = serde_json::from_value(Value::Object(fields.clone())).map_err(|e| e.to_string())?; o.validate()?; Ok(o) } -fn choose(ui: &mut Ui, d: &mut Dialog, key: &str, label: &str, choices: &[(&str, &str)]) { +/// A dropdown row: `fields[key]` is one of `choices`' values. +fn choose(ui: &mut Ui, fields: &mut serde_json::Map, key: &str, label: &str, choices: Choices) -> bool { + let mut changed = false; widgets::label_row(ui, tl!(label), 96.0, |ui| { - let current = d.str(key); + let current = fields.get(key).and_then(Value::as_str).unwrap_or_default().to_owned(); let text = choices.iter().find(|(v, _)| *v == current).map_or(current.as_str(), |(_, label)| *label); if let Some(index) = - widgets::dropdown_names(ui, ("dimension", key), tl!(text), &choices.iter().map(|(_, s)| tl!(*s)).collect::>(), 134.0) + widgets::dropdown_names(ui, ("dimension", key), tl!(text), &choices.iter().map(|(_, s)| tl!(*s)).collect::>(), 150.0) && let Some((value, _)) = choices.get(index) { - d.fields.insert(key.into(), json!(value)); + fields.insert(key.into(), json!(value)); + changed = true; } }); + changed } -fn number(ui: &mut Ui, d: &mut Dialog, key: &str, label: &str, range: (f64, f64), suffix: &str) { +fn number(ui: &mut Ui, fields: &mut serde_json::Map, key: &str, label: &str, range: (f64, f64), suffix: &str) -> bool { + let mut changed = false; widgets::label_row(ui, tl!(label), 96.0, |ui| { - if let Some(v) = widgets::plain_field(ui, ("dimension", key), d.f64(key, 0.0), suffix, 2, 90.0) { - d.fields.insert(key.into(), json!(v.clamp(range.0, range.1))); + let value = fields.get(key).and_then(Value::as_f64).unwrap_or_default(); + if let Some(v) = widgets::plain_field(ui, ("dimension", key), value, suffix, 2, 90.0) { + fields.insert(key.into(), json!(v.clamp(range.0, range.1))); + changed = true; } }); + changed } -fn check(ui: &mut Ui, d: &mut Dialog, key: &str, label: &str) { - let mut value = d.bool(key); - if ui.checkbox(&mut value, tl!(label)).changed() { - d.fields.insert(key.into(), json!(value)); +fn check(ui: &mut Ui, fields: &mut serde_json::Map, key: &str, label: &str) -> bool { + let mut value = fields.get(key).and_then(Value::as_bool).unwrap_or_default(); + let changed = ui.checkbox(&mut value, tl!(label)).changed(); + if changed { + fields.insert(key.into(), json!(value)); } + changed } -fn color(ui: &mut Ui, d: &mut Dialog, key: &str) { - let mut value: [u8; 3] = serde_json::from_value(d.fields.get(key).cloned().unwrap_or(Value::Null)).unwrap_or([224, 55, 65]); - ui.push_id(key, |ui| { - if ui.color_edit_button_srgb(&mut value).changed() { - d.fields.insert(key.into(), json!(value)); +fn color(ui: &mut Ui, fields: &mut serde_json::Map, key: &str) -> bool { + let mut value: [u8; 3] = serde_json::from_value(fields.get(key).cloned().unwrap_or(Value::Null)).unwrap_or([0, 0, 0]); + let changed = ui.push_id(key, |ui| ui.color_edit_button_srgb(&mut value).changed()).inner; + if changed { + fields.insert(key.into(), json!(value)); + } + changed +} +fn precision(ui: &mut Ui, fields: &mut serde_json::Map, id: &str) -> bool { + let before = fields.get("precision").and_then(Value::as_u64).unwrap_or(2) as u32; + let mut value = before; + ui.label(tl!("Precision:")); + egui::ComboBox::from_id_salt(id).selected_text(value.to_string()).width(55.0).show_ui(ui, |ui| { + for n in 0..=6 { + ui.selectable_value(&mut value, n, n.to_string()); } }); + fields.insert("precision".into(), json!(value)); + value != before } fn section(ui: &mut Ui, title: &str, body: impl FnOnce(&mut Ui)) { let t = Tokens::get(ui.ctx()); @@ -89,150 +118,117 @@ fn section(ui: &mut Ui, title: &str, body: impl FnOnce(&mut Ui)) { }); ui.add_space(7.0); } -const LINE_TYPES: &[(&str, &str)] = &[("solid", "Solid"), ("dashed", "Dashed"), ("dotted", "Dotted")]; fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let f = &mut d.fields; section(ui, "Units & Scale", |ui| { ui.horizontal(|ui| { - choose( - ui, - d, - "units", - "Units:", - &[ - ("mm", "Millimeters"), - ("cm", "Centimeters"), - ("m", "Meters"), - ("in", "Inches"), - ("ft", "Feet"), - ("yd", "Yards"), - ("pt", "Points"), - ("px", "Pixels"), - ], - ); - ui.label(tl!("Precision:")); - let mut value = d.f64("precision", 1.0) as u32; - egui::ComboBox::from_id_salt("dimension-precision").selected_text(value.to_string()).width(55.0).show_ui(ui, |ui| { - for n in 0..=6 { - ui.selectable_value(&mut value, n, n.to_string()); - } - }); - d.fields.insert("precision".into(), json!(value)); + choose(ui, f, "units", "Units:", UNITS); + precision(ui, f, "dimension-precision"); }); ui.horizontal(|ui| { widgets::label_row(ui, tl!("Scale:"), 96.0, |ui| { - let mut scale = d.str("scale"); + let mut scale = f.get("scale").and_then(Value::as_str).unwrap_or_default().to_owned(); if ui.add(egui::TextEdit::singleline(&mut scale).desired_width(72.0)).changed() { - d.fields.insert("scale".into(), json!(scale)); + f.insert("scale".into(), json!(scale)); } egui::ComboBox::from_id_salt("dimension-scales").selected_text(tl!("Presets")).width(76.0).show_ui(ui, |ui| { for scale in ["1:1", "1:2", "1:5", "1:10", "1:20", "1:50", "1:100", "1:200", "2:1", "5:1", "10:1"] { if ui.selectable_label(false, scale).clicked() { - d.fields.insert("scale".into(), json!(scale)); + f.insert("scale".into(), json!(scale)); } } }); }); - check(ui, d, "hideUnits", "Hide Units"); + check(ui, f, "hideUnits", "Hide Units"); }); - ui.label(RichText::new(tl!("Drawing : real size — 1:10 means 1 cm on the page represents 10 cm.")).small()); + ui.label(RichText::new(tl!("Drawing : real size — 1:10 labels 1 unit on the page as 10.")).small()); }); section(ui, "Dimension Lines", |ui| { ui.horizontal(|ui| { - number(ui, d, "lineWeight", "Line Weight:", (0.1, 100.0), "pt"); - choose(ui, d, "lineType", "Line Type:", LINE_TYPES); - color(ui, d, "lineColor"); + number(ui, f, "lineWeight", "Line Weight:", (0.1, 100.0), "pt"); + choose(ui, f, "lineType", "Line Type:", LINE_TYPES); + color(ui, f, "lineColor"); }); ui.horizontal(|ui| { - choose(ui, d, "arrowStyle", "Arrow Style:", &[("filled", "Filled"), ("open", "Open"), ("tick", "Tick"), ("none", "None")]); - number(ui, d, "arrowScale", "Arrow Scale:", (1.0, 1000.0), "%"); + choose(ui, f, "arrowStyle", "Arrow Style:", ARROWS); + number(ui, f, "arrowScale", "Arrow Scale:", (1.0, 1000.0), "%"); }); }); section(ui, "Extension Lines", |ui| { ui.horizontal(|ui| { - number(ui, d, "extensionWeight", "Line Weight:", (0.1, 100.0), "pt"); - choose(ui, d, "extensionType", "Line Type:", LINE_TYPES); - color(ui, d, "extensionColor"); + number(ui, f, "extensionWeight", "Line Weight:", (0.1, 100.0), "pt"); + choose(ui, f, "extensionType", "Line Type:", LINE_TYPES); + color(ui, f, "extensionColor"); }); ui.horizontal(|ui| { - number(ui, d, "extensionOffset", "Offset:", (0.0, 10000.0), "pt"); - number(ui, d, "extensionLength", "Extension:", (0.0, 10000.0), "pt"); + number(ui, f, "extensionOffset", "Offset:", (0.0, 10000.0), "pt"); + number(ui, f, "extensionLength", "Extension:", (0.0, 10000.0), "pt"); }); - check(ui, d, "hideExtensions", "Hide Extension Lines"); + check(ui, f, "hideExtensions", "Hide Extension Lines"); }); section(ui, "Dimension Text", |ui| { widgets::label_row(ui, tl!("Font Family:"), 96.0, |ui| { + let family = f.get("fontFamily").and_then(Value::as_str).unwrap_or_default().to_owned(); if let Some(crate::font_menu::FontPick::Chosen(family, style)) = - crate::font_menu::font_menu(ui, "dimension-font", &d.str("fontFamily"), 280.0, Some("123.45 cm"), crate::font_menu::MenuLook::of(app)) + crate::font_menu::font_menu(ui, "dimension-font", &family, 280.0, Some("123.45"), crate::font_menu::MenuLook::of(app)) { - d.fields.insert("fontFamily".into(), json!(family)); - d.fields.insert("fontStyle".into(), json!(style.unwrap_or_else(|| "Regular".into()))); + f.insert("fontFamily".into(), json!(family)); + f.insert("fontStyle".into(), json!(style.unwrap_or_else(|| "Regular".into()))); } }); widgets::label_row(ui, tl!("Font Style:"), 96.0, |ui| { - let styles = vectorcraft_text::FontDb::global().styles(&d.str("fontFamily")); + let family = f.get("fontFamily").and_then(Value::as_str).unwrap_or_default().to_owned(); + let current = f.get("fontStyle").and_then(Value::as_str).unwrap_or_default().to_owned(); + let styles = vectorcraft_text::FontDb::global().styles(&family); let names: Vec<&str> = styles.iter().map(|s| s.as_str()).collect(); - if let Some(i) = widgets::dropdown_names(ui, "dimension-font-style", &d.str("fontStyle"), &names, 180.0) + if let Some(i) = widgets::dropdown_names(ui, "dimension-font-style", ¤t, &names, 180.0) && let Some(style) = styles.get(i) { - d.fields.insert("fontStyle".into(), json!(style)); + f.insert("fontStyle".into(), json!(style)); } }); ui.horizontal(|ui| { - number(ui, d, "fontSize", "Font Size:", (1.0, 1000.0), "pt"); - color(ui, d, "textColor"); + number(ui, f, "fontSize", "Font Size:", (1.0, 1000.0), "pt"); + color(ui, f, "textColor"); }); ui.horizontal(|ui| { - choose(ui, d, "textPosition", "Position:", &[("above", "Above"), ("center", "Centered"), ("below", "Below")]); - number(ui, d, "textOffset", "Offset:", (0.0, 10000.0), "pt"); + choose(ui, f, "textPosition", "Position:", POSITIONS); + number(ui, f, "textOffset", "Offset:", (0.0, 10000.0), "pt"); }); - choose(ui, d, "textAlignment", "Orientation:", &[("aligned", "Along dimension"), ("horizontal", "Horizontal")]); + choose(ui, f, "textAlignment", "Alignment:", ALIGNMENTS); }); - match read(d) { + match read(f) { Ok(o) => { - let sample = o.label(72.0 / 2.54, false).unwrap_or_default(); + let sample = o.label(72.0, false).unwrap_or_default(); let t = Tokens::get(ui.ctx()); - ui.label(RichText::new(format!("{}: {sample}", tl!("Preview (1 cm on page)"))).color(t.text)); + ui.label(RichText::new(format!("{}: {sample}", tl!("Preview (1 inch on the page)"))).color(t.text)); } Err(e) => { ui.colored_label(Tokens::get(ui.ctx()).text, e); } } - ui.horizontal(|ui| { - if ui.button(tl!("Reset")).clicked() - && let Ok(Value::Object(fields)) = serde_json::to_value(DimensionOptions::default()) - { - let apply = d.bool("applySelected"); - d.fields = fields; - d.fields.insert("applySelected".into(), json!(apply)); - } - check(ui, d, "applySelected", "Apply to selected dimensions"); - }); + if ui.button(tl!("Reset")).clicked() + && let Ok(Value::Object(fields)) = serde_json::to_value(DimensionOptions::default()) + { + d.fields = fields; + } false } fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let o = read(d)?; - if d.bool("applySelected") { - let ids: Vec<_> = app - .session - .active() - .map(|s| s.selection.objects.iter().filter(|id| s.doc.node(**id).is_some_and(|n| n.extras().dimension.is_some())).copied().collect()) - .unwrap_or_default(); - if !ids.is_empty() { - app.run("dimension.update", json!({"ids":ids,"options":o}))?; - } - } + let o = read(&d.fields)?; let r = app.run("tool.setOption", json!({"tool":"dimension","values":{"style":o}}))?; app.ui.dialog = None; Ok(r) } +/// The Dimension tool's strip: the kind of dimension, and Tool Options. pub fn controls(app: &mut VectorcraftApp, ui: &mut Ui) { let mode = app.session.tool_options()["mode"].as_str().unwrap_or("linear").to_owned(); - for (id, label) in [("linear", "Length"), ("angular", "Angle"), ("radius", "Radius"), ("diameter", "Diameter")] { + for (id, label) in [("linear", "Linear"), ("angular", "Angular"), ("radial", "Radial")] { if ui.selectable_label(mode == id, tl!(label)).clicked() { - // Switching away cancels previews and unfinished point selections. + // Switching away cancels previews and unfinished picks. if let Err(e) = app .run("tool.select", json!({"tool":"selection"})) .and_then(|_| app.run("tool.select", json!({"tool":"dimension"}))) @@ -242,16 +238,11 @@ pub fn controls(app: &mut VectorcraftApp, ui: &mut Ui) { } } } - if ui.button(tl!("Options…")).clicked() + if ui.button(tl!("Tool Options…")).clicked() && let Err(e) = open(app) { app.ui.status = e; } - if ui.button(tl!("All selected edges")).clicked() - && let Err(e) = app.run("dimension.selection", json!({})) - { - app.ui.status = e; - } } /// Always available while measuring, including workspaces with the main Control bar hidden. @@ -259,7 +250,7 @@ pub fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) { if app.session.tool_id() != "dimension" || app.ui.dialog.is_some() { return; } - egui::Window::new(tl!("Dimensions")) + egui::Window::new(tl!("Dimension Tool")) .id(egui::Id::new("dimension-strip")) .default_pos(egui::pos2(90.0, 120.0)) .resizable(false) @@ -268,14 +259,69 @@ pub fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.horizontal(|ui| controls(app, ui)); let mode = app.session.tool_options()["mode"].as_str().unwrap_or("linear").to_owned(); let hint = match mode.as_str() { - "angular" => "Click two straight edges, then place the angle.", - "radius" | "diameter" => "Click a circle, then click to choose the label direction.", - _ => "Click an edge, then place the dimension. Alt-click picks individual anchors.", + "angular" => "Drag out from a corner, or click two straight lines, then click to place.", + "radial" | "radius" | "diameter" => "Drag out from a circle or arc for its radius, inwards for its diameter.", + _ => "Drag out from a segment, or click two points, then click to place.", }; ui.label(RichText::new(tl!(hint)).small().color(Tokens::get(ctx).text_dim)); }); } +/// The selected dimension, if one is selected alone: its id and style. +pub fn selected(app: &VectorcraftApp) -> Option<(u64, DimensionOptions)> { + let st = app.session.active()?; + let [id] = st.selection.objects.as_slice() else { return None }; + let d = st.doc.node(*id)?.extras().dimension.as_deref()?; + Some((id.0, d.options.clone())) +} + +/// The Properties panel's Dimension section: the selected dimension's style, Apply to All and Set +/// as Default. Its scale is set in the Tool Options. +pub fn properties_section(app: &mut VectorcraftApp, ui: &mut Ui) { + let Some((id, o)) = selected(app) else { return }; + let Ok(Value::Object(mut f)) = serde_json::to_value(&o) else { return }; + widgets::section_header(ui, tl!("Dimension")); + let mut changed = false; + ui.horizontal(|ui| { + changed |= choose(ui, &mut f, "units", "Units:", UNITS); + }); + ui.horizontal(|ui| changed |= precision(ui, &mut f, "props-dimension-precision")); + ui.horizontal(|ui| { + changed |= number(ui, &mut f, "lineWeight", "Line Weight:", (0.1, 100.0), "pt"); + changed |= color(ui, &mut f, "lineColor"); + }); + ui.horizontal(|ui| changed |= choose(ui, &mut f, "arrowStyle", "Arrow Style:", ARROWS)); + ui.horizontal(|ui| { + changed |= number(ui, &mut f, "fontSize", "Font Size:", (1.0, 1000.0), "pt"); + changed |= color(ui, &mut f, "textColor"); + }); + ui.horizontal(|ui| changed |= choose(ui, &mut f, "textPosition", "Position:", POSITIONS)); + ui.horizontal(|ui| changed |= choose(ui, &mut f, "textAlignment", "Alignment:", ALIGNMENTS)); + if changed { + match read(&f) { + Ok(o) => { + if let Err(e) = app.run("dimension.update", json!({"ids":[id],"options":o})) { + app.ui.status = e; + } + } + Err(e) => app.ui.status = e, + } + } + let w = (ui.available_width() - 6.0) / 2.0; + ui.horizontal(|ui| { + if widgets::flat_button(ui, tl!("Apply to All"), w).clicked() + && let Err(e) = app.run("dimension.applyToAll", json!({"id":id})) + { + app.ui.status = e; + } + if widgets::flat_button(ui, tl!("Set as Default"), w).clicked() + && let Err(e) = app.run("tool.setOption", json!({"tool":"dimension","values":{"style":o}})) + { + app.ui.status = e; + } + }); +} + #[cfg(test)] mod tests { use super::*; @@ -289,12 +335,26 @@ mod tests { } let d = app.ui.dialog.as_mut().unwrap(); d.fields.insert("scale".into(), json!("1:10")); + d.fields.insert("units".into(), json!("pc")); super::super::confirm(&mut app).unwrap(); assert_eq!(app.session.tool_options_of("dimension")["style"]["scale"], "1:10"); + assert_eq!(app.session.tool_options_of("dimension")["style"]["units"], "pc"); open(&mut app).unwrap(); app.ui.dialog.as_mut().unwrap().fields.insert("scale".into(), json!("1:0")); assert!(super::super::confirm(&mut app).is_err()); app.ui.dialog = None; assert_eq!(app.session.tool_options_of("dimension")["style"]["scale"], "1:10"); } + + /// A selected dimension shows its style in Properties, with Apply to All and Set as Default. + #[test] + fn properties_show_the_selected_dimension() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("dimension.create", json!({"a":[10,10],"b":[110,10]})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, properties_section); + for label in ["Dimension", "Units:", "Apply to All", "Set as Default"] { + assert!(text.contains(label), "{label}: {text}"); + } + } } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 0bad4c320..570f3778b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4809,25 +4809,31 @@ mark-style Japanese Japonské Ambient Light Okolní světlo Light behind the object Světlo za objektem # Dimension tool (linked dimension annotations) - Dimension Selected Paths Okótovat vybrané cesty Display Zobrazení # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). - a highlighted edge, then click to place its dimension | na zvýrazněnou hranu a potom kliknutím umístěte kótu | - cancels | Double-click the tool for options zruší | Dvojklikem na nástroj otevřete volby - All selected edges Všechny vybrané hrany + Dimension Rozměr + Drawing : real size — 1:10 labels 1 unit on the page as 10. Výkres : skutečná velikost — 1:10 označí 1 jednotku na stránce jako 10. + Preview (1 inch on the page) Náhled (1 palec na stránce) + Set as Default Nastavit jako výchozí + out from a highlighted edge to place its dimension | ze zvýrazněné hrany ven a umístíte její kótu | + cancels | Double-click the tool for options zruší | Dvojitým kliknutím na nástroj otevřete volby + Angular Úhlová + Away from the Line Dál od čáry + Between Line and Object Mezi čárou a objektem + Drag out from a segment, or click two points, then click to place. Táhněte ven od úsečky, nebo klikněte na dva body a potom klikněním umístěte. + Drag out from a corner, or click two straight lines, then click to place. Táhněte ven od rohu, nebo klikněte na dvě rovné čáry a potom klikněním umístěte. + Drag out from a circle or arc for its radius, inwards for its diameter. Táhněte od kruhu nebo oblouku ven pro poloměr, dovnitř pro průměr. +@msg Choose a circle or a perfect arc Vyberte kruh nebo dokonalý oblouk +@msg Choose a precision from 0 to 6 Zvolte přesnost od 0 do 6 Create Dimension Vytvořit kótu Dimension Tool Nástroj Kóta Dimension Tool Options Volby nástroje Kóta - Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Výkres : skutečná velikost — 1:10 znamená, že 1 cm na stránce odpovídá 10 cm. Font Family: Rodina písma: Font Style: Řez písma: Precision: Přesnost: - Preview (1 cm on page) Náhled (1 cm na stránce) Update Dimensions Aktualizovat kóty @msg Angles require two straight segments Úhly vyžadují dva přímé segmenty -@msg Choose a circular path Vyberte kruhovou cestu -@msg Choose a length unit and precision from 0 to 6 Vyberte délkovou jednotku a přesnost 0 až 6 @msg Choose two different points Vyberte dva různé body @msg Choose two non-parallel straight segments Vyberte dva nerovnoběžné přímé segmenty @msg Choose two nonzero angle arms Vyberte dvě ramena úhlu nenulové délky @@ -4839,7 +4845,6 @@ 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 Measured path no longer exists Měřená cesta už neexistuje @msg Measured segment no longer exists Měřený segment už neexistuje @msg Non-finite dimension Kóta není konečné číslo @msg Scale must be drawing:real, for example 1:10 Měřítko musí mít tvar výkres:skutečnost, například 1:10 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c1071c1cd..def112ce7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4337,25 +4337,32 @@ theme Appearance Erscheinungsbild Light behind the object Licht hinter dem Objekt # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). - a highlighted edge, then click to place its dimension | auf eine hervorgehobene Kante, dann klicken, um ihre Bemaßung zu platzieren | - cancels | Double-click the tool for options bricht ab | Doppelklick auf das Werkzeug für Optionen - All selected edges Alle ausgewählten Kanten + Dimension Bemaßung + Drawing : real size — 1:10 labels 1 unit on the page as 10. Zeichnung : reale Größe — 1:10 beschriftet 1 Einheit auf der Seite als 10. + Preview (1 inch on the page) Vorschau (1 Zoll auf der Seite) + Set as Default Als Standard festlegen + out from a highlighted edge to place its dimension | von einer hervorgehobenen Kante nach außen, um ihre Bemaßung zu platzieren | + cancels | Double-click the tool for options bricht ab | Doppelklick auf das Werkzeug für Optionen + Linear Linear + Angular Winkel + Away from the Line Abseits der Linie + Between Line and Object Zwischen Linie und Objekt + Drag out from a segment, or click two points, then click to place. Von einem Segment nach außen ziehen oder zwei Punkte anklicken, dann zum Platzieren klicken. + Drag out from a corner, or click two straight lines, then click to place. Von einer Ecke nach außen ziehen oder zwei gerade Linien anklicken, dann zum Platzieren klicken. + Drag out from a circle or arc for its radius, inwards for its diameter. Von einem Kreis oder Bogen nach außen ziehen für den Radius, nach innen für den Durchmesser. +@msg Choose a circle or a perfect arc Wählen Sie einen Kreis oder einen perfekten Bogen +@msg Choose a precision from 0 to 6 Wählen Sie eine Genauigkeit von 0 bis 6 Create Dimension Bemaßung erstellen - Dimension Selected Paths Ausgewählte Pfade bemaßen Dimension Tool Bemaßungswerkzeug Dimension Tool Options Optionen für Bemaßungswerkzeug Display Anzeige - Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Zeichnung : Realgröße — 1:10 bedeutet, 1 cm auf der Seite entspricht 10 cm. Font Family: Schriftfamilie: Font Style: Schriftschnitt: Precision: Genauigkeit: - Preview (1 cm on page) Vorschau (1 cm auf der Seite) Update Dimensions Bemaßungen aktualisieren X: X: Y: Y: @msg Angles require two straight segments Winkel erfordern zwei gerade Segmente -@msg Choose a circular path Wählen Sie einen kreisförmigen Pfad -@msg Choose a length unit and precision from 0 to 6 Wählen Sie eine Längeneinheit und eine Genauigkeit von 0 bis 6 @msg Choose two different points Wählen Sie zwei verschiedene Punkte @msg Choose two non-parallel straight segments Wählen Sie zwei nicht parallele gerade Segmente @msg Choose two nonzero angle arms Wählen Sie zwei Winkelschenkel mit einer Länge ungleich null @@ -4367,7 +4374,6 @@ theme Appearance Erscheinungsbild @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 Measured path no longer exists Der gemessene Pfad existiert nicht mehr @msg Measured segment no longer exists Das gemessene Segment existiert nicht mehr @msg Non-finite dimension Die Bemaßung ist keine endliche Zahl @msg Scale must be drawing:real, for example 1:10 Der Maßstab muss Zeichnung:Realität sein, zum Beispiel 1:10 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ed0f74ddb..dd32182cf 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4750,25 +4750,31 @@ mark-style Japanese Japonesas Ambient Light Luz ambiental Light behind the object Luz detrás del objeto # Dimension tool (linked dimension annotations) - a highlighted edge, then click to place its dimension | en un borde resaltado y luego haga clic para colocar su cota | - cancels | Double-click the tool for options cancela | Doble clic en la herramienta para ver las opciones - All selected edges Todos los bordes seleccionados Create Dimension Crear cota - Dimension Selected Paths Acotar trazados seleccionados Dimension Tool Herramienta Cota Dimension Tool Options Opciones de la herramienta Cota - Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Dibujo : tamaño real — 1:10 significa que 1 cm en la página representa 10 cm. Font Family: Familia de fuentes: Font Style: Estilo de fuente: Precision: Precisión: - Preview (1 cm on page) Vista previa (1 cm en la página) Update Dimensions Actualizar cotas Display Visualización # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). + Dimension Cota + Drawing : real size — 1:10 labels 1 unit on the page as 10. Dibujo : tamaño real — 1:10 rotula 1 unidad de la página como 10. + Preview (1 inch on the page) Vista previa (1 pulgada en la página) + Set as Default Establecer como predeterminado + out from a highlighted edge to place its dimension | hacia fuera desde un borde resaltado para colocar su cota | + cancels | Double-click the tool for options cancela | Doble clic en la herramienta para ver las opciones + Angular Angular + Away from the Line Lejos de la línea + Between Line and Object Entre la línea y el objeto + Drag out from a segment, or click two points, then click to place. Arrastre hacia fuera desde un segmento, o haga clic en dos puntos y luego haga clic para colocar. + Drag out from a corner, or click two straight lines, then click to place. Arrastre hacia fuera desde una esquina, o haga clic en dos líneas rectas y luego haga clic para colocar. + Drag out from a circle or arc for its radius, inwards for its diameter. Arrastre desde un círculo o arco hacia fuera para el radio, hacia dentro para el diámetro. +@msg Choose a circle or a perfect arc Elija un círculo o un arco perfecto +@msg Choose a precision from 0 to 6 Elija una precisión de 0 a 6 @msg Angles require two straight segments Los ángulos requieren dos segmentos rectos -@msg Choose a circular path Elija un trazado circular -@msg Choose a length unit and precision from 0 to 6 Elija una unidad de longitud y una precisión de 0 a 6 @msg Choose two different points Elija dos puntos distintos @msg Choose two non-parallel straight segments Elija dos segmentos rectos no paralelos @msg Choose two nonzero angle arms Elija dos lados del ángulo de longitud distinta de cero @@ -4780,7 +4786,6 @@ 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 Measured path no longer exists El trazado medido ya no existe @msg Measured segment no longer exists El segmento medido ya no existe @msg Non-finite dimension Cota no finita @msg Scale must be drawing:real, for example 1:10 La escala debe ser dibujo:real, por ejemplo 1:10 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3fdd5eee9..7917f1950 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4810,23 +4810,29 @@ theme Appearance Apparence Light behind the object Lumière derrière l'objet # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). - a highlighted edge, then click to place its dimension | sur un bord en surbrillance, puis cliquez pour placer sa cote | - cancels | Double-click the tool for options pour annuler | Double-cliquez sur l’outil pour ses options - All selected edges Tous les bords sélectionnés + Dimension Cote + Drawing : real size — 1:10 labels 1 unit on the page as 10. Dessin : taille réelle — 1:10 étiquette 1 unité de la page comme 10. + Preview (1 inch on the page) Aperçu (1 pouce sur la page) + Set as Default Définir par défaut + out from a highlighted edge to place its dimension | vers l'extérieur depuis un bord mis en évidence pour placer sa cote | + cancels | Double-click the tool for options annule | Double-clic sur l'outil pour les options + Angular Angulaire + Away from the Line À l'écart de la ligne + Between Line and Object Entre la ligne et l'objet + Drag out from a segment, or click two points, then click to place. Faites glisser vers l'extérieur depuis un segment, ou cliquez sur deux points, puis cliquez pour placer. + Drag out from a corner, or click two straight lines, then click to place. Faites glisser vers l'extérieur depuis un angle, ou cliquez sur deux lignes droites, puis cliquez pour placer. + Drag out from a circle or arc for its radius, inwards for its diameter. Faites glisser depuis un cercle ou un arc vers l'extérieur pour le rayon, vers l'intérieur pour le diamètre. +@msg Choose a circle or a perfect arc Choisissez un cercle ou un arc parfait +@msg Choose a precision from 0 to 6 Choisissez une précision de 0 à 6 Create Dimension Créer une cote - Dimension Selected Paths Coter les tracés sélectionnés Dimension Tool Outil Cotation Dimension Tool Options Options de l’outil Cotation Display Affichage - Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Dessin : taille réelle — 1:10 signifie que 1 cm sur la page représente 10 cm. Font Family: Famille de polices : Font Style: Style de police : Precision: Précision : - Preview (1 cm on page) Aperçu (1 cm sur la page) Update Dimensions Mettre à jour les cotes @msg Angles require two straight segments Les angles nécessitent deux segments droits -@msg Choose a circular path Choisissez un tracé circulaire -@msg Choose a length unit and precision from 0 to 6 Choisissez une unité de longueur et une précision de 0 à 6 @msg Choose two different points Choisissez deux points différents @msg Choose two non-parallel straight segments Choisissez deux segments droits non parallèles @msg Choose two nonzero angle arms Choisissez deux côtés d’angle de longueur non nulle @@ -4838,7 +4844,6 @@ 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 Measured path no longer exists Le tracé mesuré n’existe plus @msg Measured segment no longer exists Le segment mesuré n’existe plus @msg Non-finite dimension Cote non finie @msg Scale must be drawing:real, for example 1:10 L’échelle doit être dessin:réel, par exemple 1:10 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index cb1f374b9..184fa5c55 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4777,23 +4777,29 @@ imageTrace Outline Körvonal Light behind the object Fény az objektum mögött # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). - a highlighted edge, then click to place its dimension | egy kiemelt élre, majd kattintson a méretvonal elhelyezéséhez | - cancels | Double-click the tool for options – mégse | Kattintson duplán az eszközre a beállításokért - All selected edges Minden kijelölt él + Dimension Méret + Drawing : real size — 1:10 labels 1 unit on the page as 10. Rajz : valós méret — 1:10 esetén az oldalon 1 egység 10-ként jelenik meg. + Preview (1 inch on the page) Előnézet (1 hüvelyk az oldalon) + Set as Default Beállítás alapértelmezettként + out from a highlighted edge to place its dimension | egy kiemelt élről kifelé a méret elhelyezéséhez | + cancels | Double-click the tool for options megszakítja | Dupla kattintás az eszközre a beállításokhoz + Angular Szög + Away from the Line A vonaltól távol + Between Line and Object A vonal és az objektum között + Drag out from a segment, or click two points, then click to place. Húzzon kifelé egy szakaszról, vagy kattintson két pontra, majd kattintson az elhelyezéshez. + Drag out from a corner, or click two straight lines, then click to place. Húzzon kifelé egy sarokról, vagy kattintson két egyenes vonalra, majd kattintson az elhelyezéshez. + Drag out from a circle or arc for its radius, inwards for its diameter. Húzzon egy körről vagy ívről kifelé a sugárhoz, befelé az átmérőhöz. +@msg Choose a circle or a perfect arc Válasszon egy kört vagy egy tökéletes ívet +@msg Choose a precision from 0 to 6 Válasszon 0 és 6 közötti pontosságot Create Dimension Méretvonal létrehozása - Dimension Selected Paths Kijelölt útvonalak méretezése Dimension Tool Méretezés eszköz Dimension Tool Options Méretezés eszköz beállításai Display Megjelenítés - Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Rajz : valós méret — 1:10 azt jelenti, hogy az oldalon 1 cm 10 cm-nek felel meg. Font Family: Betűcsalád: Font Style: Betűstílus: Precision: Pontosság: - Preview (1 cm on page) Előnézet (1 cm az oldalon) Update Dimensions Méretek frissítése @msg Angles require two straight segments A szöghöz két egyenes szakasz kell -@msg Choose a circular path Válassz kör alakú útvonalat -@msg Choose a length unit and precision from 0 to 6 Válassz hosszegységet és 0–6 közötti pontosságot @msg Choose two different points Válassz két különböző pontot @msg Choose two non-parallel straight segments Válassz két nem párhuzamos egyenes szakaszt @msg Choose two nonzero angle arms Válassz két nem nulla hosszúságú szögszárat @@ -4805,7 +4811,6 @@ 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 Measured path no longer exists A mért útvonal már nem létezik @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 Scale must be drawing:real, for example 1:10 A léptéknek rajz:valós alakúnak kell lennie, például 1:10 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index eb8827582..9cd975ed5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4382,25 +4382,32 @@ lighting Light Luce Light behind the object Luce dietro l'oggetto # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). - a highlighted edge, then click to place its dimension | su un bordo evidenziato, poi fai clic per posizionarne la quota | - cancels | Double-click the tool for options per annullare | Doppio clic sullo strumento per le opzioni - All selected edges Tutti i bordi selezionati + Dimension Quota + Drawing : real size — 1:10 labels 1 unit on the page as 10. Disegno : dimensione reale — 1:10 indica 1 unità sulla pagina come 10. + Preview (1 inch on the page) Anteprima (1 pollice sulla pagina) + Set as Default Imposta come predefinito + out from a highlighted edge to place its dimension | verso l'esterno da un bordo evidenziato per posizionarne la quota | + cancels | Double-click the tool for options annulla | Doppio clic sullo strumento per le opzioni + Linear Lineare + Angular Angolare + Away from the Line Lontano dalla linea + Between Line and Object Tra linea e oggetto + Drag out from a segment, or click two points, then click to place. Trascina verso l'esterno da un segmento, oppure fai clic su due punti, poi fai clic per posizionare. + Drag out from a corner, or click two straight lines, then click to place. Trascina verso l'esterno da un angolo, oppure fai clic su due linee rette, poi fai clic per posizionare. + Drag out from a circle or arc for its radius, inwards for its diameter. Trascina da un cerchio o un arco verso l'esterno per il raggio, verso l'interno per il diametro. +@msg Choose a circle or a perfect arc Scegli un cerchio o un arco perfetto +@msg Choose a precision from 0 to 6 Scegli una precisione da 0 a 6 Create Dimension Crea quota - Dimension Selected Paths Quota i tracciati selezionati Dimension Tool Strumento Quota Dimension Tool Options Opzioni strumento Quota Display Visualizzazione - Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Disegno : dimensione reale — 1:10 significa che 1 cm sulla pagina rappresenta 10 cm. Font Family: Famiglia di font: Font Style: Stile font: Precision: Precisione: - Preview (1 cm on page) Anteprima (1 cm sulla pagina) Update Dimensions Aggiorna quote X: X: Y: Y: @msg Angles require two straight segments Gli angoli richiedono due segmenti rettilinei -@msg Choose a circular path Scegli un tracciato circolare -@msg Choose a length unit and precision from 0 to 6 Scegli un’unità di lunghezza e una precisione da 0 a 6 @msg Choose two different points Scegli due punti diversi @msg Choose two non-parallel straight segments Scegli due segmenti rettilinei non paralleli @msg Choose two nonzero angle arms Scegli due lati dell’angolo di lunghezza non nulla @@ -4412,7 +4419,6 @@ 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 Measured path no longer exists Il tracciato misurato non esiste più @msg Measured segment no longer exists Il segmento misurato non esiste più @msg Non-finite dimension Quota non finita @msg Scale must be drawing:real, for example 1:10 La scala deve essere disegno:reale, ad esempio 1:10 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e1c72fcff..0b6d27759 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4375,27 +4375,34 @@ lighting Light ライト Ambient Light 環境光 Light behind the object オブジェクトの背後のライト # Dimension tool (linked dimension annotations) - a highlighted edge, then click to place its dimension | :ハイライトされたエッジをクリックし、もう一度クリックして寸法を配置 | - cancels | Double-click the tool for options :キャンセル | ツールをダブルクリックでオプション - All selected edges 選択したすべてのエッジ Create Dimension 寸法を作成 - Dimension Selected Paths 選択したパスに寸法を記入 Dimension Tool 寸法ツール Dimension Tool Options 寸法ツールオプション - Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. 図面 : 実寸 — 1:10 は、ページ上の 1 cm が実際の 10 cm を表します。 Font Family: フォントファミリー: Font Style: フォントスタイル: Precision: 精度: - Preview (1 cm on page) プレビュー (ページ上の 1 cm) Update Dimensions 寸法を更新 Display 表示 # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). + Dimension 寸法 + Drawing : real size — 1:10 labels 1 unit on the page as 10. 図面 : 実寸 — 1:10 では、ページ上の 1 単位を 10 と表示します。 + Preview (1 inch on the page) プレビュー(ページ上の 1 インチ) + Set as Default デフォルトに設定 + out from a highlighted edge to place its dimension | でハイライトされた辺から外側へ寸法を配置 | + cancels | Double-click the tool for options でキャンセル | ツールをダブルクリックしてオプション + Linear 直線 + Angular 角度 + Away from the Line 寸法線から離す + Between Line and Object 寸法線とオブジェクトの間 + Drag out from a segment, or click two points, then click to place. セグメントから外側へドラッグするか、2 点をクリックしてからクリックして配置します。 + Drag out from a corner, or click two straight lines, then click to place. 角から外側へドラッグするか、2 本の直線をクリックしてからクリックして配置します。 + Drag out from a circle or arc for its radius, inwards for its diameter. 円または円弧から外側へドラッグすると半径、内側へドラッグすると直径になります。 +@msg Choose a circle or a perfect arc 円または正確な円弧を選択してください +@msg Choose a precision from 0 to 6 精度は 0〜6 で選択してください X: X: Y: Y: @msg Angles require two straight segments 角度には直線セグメントが 2 つ必要です -@msg Choose a circular path 円形のパスを選択してください -@msg Choose a length unit and precision from 0 to 6 長さの単位と 0〜6 の精度を選択してください @msg Choose two different points 異なる 2 点を選択してください @msg Choose two non-parallel straight segments 平行でない直線セグメントを 2 つ選択してください @msg Choose two nonzero angle arms 長さが 0 でない角度の辺を 2 つ選択してください @@ -4407,7 +4414,6 @@ lighting Light ライト @msg Invalid drawing scale 図面の縮尺が無効です @msg Invalid real scale 実寸の縮尺が無効です @msg Measured anchor no longer exists 測定したアンカーポイントはもう存在しません -@msg Measured path no longer exists 測定したパスはもう存在しません @msg Measured segment no longer exists 測定したセグメントはもう存在しません @msg Non-finite dimension 寸法が有限ではありません @msg Scale must be drawing:real, for example 1:10 縮尺は 図面:実寸 の形式にしてください (例: 1:10) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e84fdfe61..229be3ef0 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4346,25 +4346,32 @@ lighting Light 광원 Light behind the object 개체 뒤의 조명 # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). - a highlighted edge, then click to place its dimension | 강조 표시된 가장자리를 클릭한 다음 다시 클릭하여 치수 배치 | - cancels | Double-click the tool for options 취소 | 도구를 두 번 클릭하면 옵션 - All selected edges 선택한 모든 가장자리 + Dimension 치수 + Drawing : real size — 1:10 labels 1 unit on the page as 10. 도면 : 실제 크기 — 1:10은 페이지의 1단위를 10으로 표시합니다. + Preview (1 inch on the page) 미리 보기 (페이지의 1인치) + Set as Default 기본값으로 설정 + out from a highlighted edge to place its dimension | 강조된 가장자리에서 바깥쪽으로 끌어 치수 배치 | + cancels | Double-click the tool for options 취소 | 도구를 두 번 클릭하면 옵션 + Linear 선형 + Angular 각도 + Away from the Line 선에서 떨어짐 + Between Line and Object 선과 개체 사이 + Drag out from a segment, or click two points, then click to place. 세그먼트에서 바깥쪽으로 끌거나, 두 점을 클릭한 다음 클릭하여 배치합니다. + Drag out from a corner, or click two straight lines, then click to place. 모서리에서 바깥쪽으로 끌거나, 두 직선을 클릭한 다음 클릭하여 배치합니다. + Drag out from a circle or arc for its radius, inwards for its diameter. 원이나 호에서 바깥쪽으로 끌면 반지름, 안쪽으로 끌면 지름입니다. +@msg Choose a circle or a perfect arc 원 또는 완전한 호를 선택하십시오 +@msg Choose a precision from 0 to 6 정밀도를 0에서 6 사이로 선택하십시오 Create Dimension 치수 만들기 - Dimension Selected Paths 선택한 패스에 치수 기입 Dimension Tool 치수 도구 Dimension Tool Options 치수 도구 옵션 Display 표시 - Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. 도면 : 실제 크기 — 1:10은 페이지의 1cm가 실제 10cm임을 뜻합니다. Font Family: 글꼴 패밀리: Font Style: 글꼴 스타일: Precision: 정밀도: - Preview (1 cm on page) 미리 보기(페이지의 1cm) Update Dimensions 치수 업데이트 X: X: Y: Y: @msg Angles require two straight segments 각도에는 직선 세그먼트 두 개가 필요합니다 -@msg Choose a circular path 원형 패스를 선택하십시오 -@msg Choose a length unit and precision from 0 to 6 길이 단위와 0~6 사이의 정밀도를 선택하십시오 @msg Choose two different points 서로 다른 두 점을 선택하십시오 @msg Choose two non-parallel straight segments 평행하지 않은 직선 세그먼트 두 개를 선택하십시오 @msg Choose two nonzero angle arms 길이가 0이 아닌 각도 변 두 개를 선택하십시오 @@ -4376,7 +4383,6 @@ lighting Light 광원 @msg Invalid drawing scale 잘못된 도면 축척 @msg Invalid real scale 잘못된 실제 축척 @msg Measured anchor no longer exists 측정한 고정점이 더 이상 없습니다 -@msg Measured path no longer exists 측정한 패스가 더 이상 없습니다 @msg Measured segment no longer exists 측정한 세그먼트가 더 이상 없습니다 @msg Non-finite dimension 유한하지 않은 치수 @msg Scale must be drawing:real, for example 1:10 축척은 도면:실제 형식이어야 합니다(예: 1:10) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c305fbfb4..ce9e2b268 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4652,23 +4652,29 @@ mark-style Japanese Japońskie Ambient Light Światło otoczenia Light behind the object Światło za obiektem # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). - a highlighted edge, then click to place its dimension | podświetloną krawędź, a następnie kliknij, aby umieścić jej wymiar | - cancels | Double-click the tool for options anuluje | Kliknij dwukrotnie narzędzie, aby wyświetlić opcje - All selected edges Wszystkie zaznaczone krawędzie + Dimension Wymiar + Drawing : real size — 1:10 labels 1 unit on the page as 10. Rysunek : rozmiar rzeczywisty — 1:10 opisuje 1 jednostkę na stronie jako 10. + Preview (1 inch on the page) Podgląd (1 cal na stronie) + Set as Default Ustaw jako domyślne + out from a highlighted edge to place its dimension | od podświetlonej krawędzi na zewnątrz, aby umieścić jej wymiar | + cancels | Double-click the tool for options anuluje | Kliknij dwukrotnie narzędzie, aby wyświetlić opcje + Angular Kątowy + Away from the Line Z dala od linii + Between Line and Object Między linią a obiektem + Drag out from a segment, or click two points, then click to place. Przeciągnij na zewnątrz od odcinka albo kliknij dwa punkty, a następnie kliknij, aby umieścić. + Drag out from a corner, or click two straight lines, then click to place. Przeciągnij na zewnątrz od narożnika albo kliknij dwie proste linie, a następnie kliknij, aby umieścić. + Drag out from a circle or arc for its radius, inwards for its diameter. Przeciągnij od okręgu lub łuku na zewnątrz, aby uzyskać promień, do wewnątrz — średnicę. +@msg Choose a circle or a perfect arc Wybierz okrąg lub idealny łuk +@msg Choose a precision from 0 to 6 Wybierz dokładność od 0 do 6 Create Dimension Utwórz wymiar - Dimension Selected Paths Zwymiaruj zaznaczone ścieżki Dimension Tool Narzędzie Wymiar Dimension Tool Options Opcje narzędzia Wymiar Display Wyświetlanie - Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Rysunek : rozmiar rzeczywisty — 1:10 oznacza, że 1 cm na stronie odpowiada 10 cm. Font Family: Rodzina czcionek: Font Style: Styl czcionki: Precision: Dokładność: - Preview (1 cm on page) Podgląd (1 cm na stronie) Update Dimensions Aktualizuj wymiary @msg Angles require two straight segments Kąty wymagają dwóch prostych odcinków -@msg Choose a circular path Wybierz ścieżkę w kształcie okręgu -@msg Choose a length unit and precision from 0 to 6 Wybierz jednostkę długości i dokładność od 0 do 6 @msg Choose two different points Wybierz dwa różne punkty @msg Choose two non-parallel straight segments Wybierz dwa nierównoległe proste odcinki @msg Choose two nonzero angle arms Wybierz dwa niezerowe ramiona kąta @@ -4680,7 +4686,6 @@ 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 Measured path no longer exists Mierzona ścieżka już nie istnieje @msg Measured segment no longer exists Mierzony odcinek już nie istnieje @msg Non-finite dimension Wymiar nie jest liczbą skończoną @msg Scale must be drawing:real, for example 1:10 Skala musi mieć postać rysunek:rzeczywistość, na przykład 1:10 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d10903801..4a60aed9f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3764,7 +3764,6 @@ axis Both Ambos Ambient Light Luz ambiente Light behind the object Luz atrás do objeto # Dimension tool (linked dimension annotations) - Dimension Selected Paths Cotar Caminhos Selecionados Dimension Tool Ferramenta Cota Create Dimension Criar Cota Update Dimensions Atualizar Cotas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8b5ac2194..7a75c4bce 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4378,25 +4378,32 @@ lighting Light Свет Light behind the object Свет позади объекта # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). - a highlighted edge, then click to place its dimension | по выделенному краю, затем щёлкните, чтобы разместить размер | - cancels | Double-click the tool for options — отмена | Двойной щелчок по инструменту — параметры - All selected edges Все выделенные края + Dimension Размер + Drawing : real size — 1:10 labels 1 unit on the page as 10. Чертёж : реальный размер — при 1:10 1 единица на странице подписывается как 10. + Preview (1 inch on the page) Просмотр (1 дюйм на странице) + Set as Default Установить по умолчанию + out from a highlighted edge to place its dimension | наружу от подсвеченного края, чтобы разместить его размер | + cancels | Double-click the tool for options отменяет | Двойной щелчок по инструменту открывает параметры + Linear Линейный + Angular Угловой + Away from the Line Вдали от линии + Between Line and Object Между линией и объектом + Drag out from a segment, or click two points, then click to place. Перетащите наружу от сегмента или щёлкните две точки, затем щёлкните, чтобы разместить. + Drag out from a corner, or click two straight lines, then click to place. Перетащите наружу от угла или щёлкните две прямые линии, затем щёлкните, чтобы разместить. + Drag out from a circle or arc for its radius, inwards for its diameter. Перетащите от окружности или дуги наружу — радиус, внутрь — диаметр. +@msg Choose a circle or a perfect arc Выберите окружность или правильную дугу +@msg Choose a precision from 0 to 6 Выберите точность от 0 до 6 Create Dimension Создать размер - Dimension Selected Paths Проставить размеры выделенных контуров Dimension Tool Инструмент «Размер» Dimension Tool Options Параметры инструмента «Размер» Display Отображение - Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Чертёж : реальный размер — 1:10 означает, что 1 см на странице соответствует 10 см. Font Family: Семейство шрифтов: Font Style: Начертание: Precision: Точность: - Preview (1 cm on page) Просмотр (1 см на странице) Update Dimensions Обновить размеры X: X: Y: Y: @msg Angles require two straight segments Для угла нужны два прямых сегмента -@msg Choose a circular path Выберите круглый контур -@msg Choose a length unit and precision from 0 to 6 Выберите единицу длины и точность от 0 до 6 @msg Choose two different points Выберите две разные точки @msg Choose two non-parallel straight segments Выберите два непараллельных прямых сегмента @msg Choose two nonzero angle arms Выберите две стороны угла ненулевой длины @@ -4408,7 +4415,6 @@ lighting Light Свет @msg Invalid drawing scale Недопустимый масштаб чертежа @msg Invalid real scale Недопустимый реальный масштаб @msg Measured anchor no longer exists Измеряемая опорная точка больше не существует -@msg Measured path no longer exists Измеряемый контур больше не существует @msg Measured segment no longer exists Измеряемый сегмент больше не существует @msg Non-finite dimension Размер не является конечным числом @msg Scale must be drawing:real, for example 1:10 Масштаб должен быть в виде чертёж:реальность, например 1:10 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c1febb513..47ea60f9c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4362,25 +4362,32 @@ lighting Light Світло Light behind the object Світло позаду об'єкта # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). - a highlighted edge, then click to place its dimension | по виділеному краю, потім клацніть, щоб розмістити розмір | - cancels | Double-click the tool for options — скасування | Подвійне клацання по інструменту — параметри - All selected edges Усі виділені краї + Dimension Розмір + Drawing : real size — 1:10 labels 1 unit on the page as 10. Креслення : реальний розмір — при 1:10 1 одиниця на сторінці підписується як 10. + Preview (1 inch on the page) Попередній перегляд (1 дюйм на сторінці) + Set as Default Встановити за замовчуванням + out from a highlighted edge to place its dimension | назовні від підсвіченого краю, щоб розмістити його розмір | + cancels | Double-click the tool for options скасовує | Подвійне клацання по інструменту відкриває параметри + Linear Лінійний + Angular Кутовий + Away from the Line Далі від лінії + Between Line and Object Між лінією та об'єктом + Drag out from a segment, or click two points, then click to place. Перетягніть назовні від сегмента або клацніть дві точки, потім клацніть, щоб розмістити. + Drag out from a corner, or click two straight lines, then click to place. Перетягніть назовні від кута або клацніть дві прямі лінії, потім клацніть, щоб розмістити. + Drag out from a circle or arc for its radius, inwards for its diameter. Перетягніть від кола або дуги назовні — радіус, усередину — діаметр. +@msg Choose a circle or a perfect arc Виберіть коло або правильну дугу +@msg Choose a precision from 0 to 6 Виберіть точність від 0 до 6 Create Dimension Створити розмір - Dimension Selected Paths Проставити розміри виділених контурів Dimension Tool Інструмент «Розмір» Dimension Tool Options Параметри інструмента «Розмір» Display Відображення - Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Креслення : реальний розмір — 1:10 означає, що 1 см на сторінці відповідає 10 см. Font Family: Сімейство шрифтів: Font Style: Накреслення: Precision: Точність: - Preview (1 cm on page) Попередній перегляд (1 см на сторінці) Update Dimensions Оновити розміри X: X: Y: Y: @msg Angles require two straight segments Для кута потрібні два прямі сегменти -@msg Choose a circular path Виберіть круглий контур -@msg Choose a length unit and precision from 0 to 6 Виберіть одиницю довжини й точність від 0 до 6 @msg Choose two different points Виберіть дві різні точки @msg Choose two non-parallel straight segments Виберіть два непаралельні прямі сегменти @msg Choose two nonzero angle arms Виберіть дві сторони кута ненульової довжини @@ -4392,7 +4399,6 @@ lighting Light Світло @msg Invalid drawing scale Неприпустимий масштаб креслення @msg Invalid real scale Неприпустимий реальний масштаб @msg Measured anchor no longer exists Виміряна опорна точка більше не існує -@msg Measured path no longer exists Виміряний контур більше не існує @msg Measured segment no longer exists Виміряний сегмент більше не існує @msg Non-finite dimension Розмір не є скінченним числом @msg Scale must be drawing:real, for example 1:10 Масштаб має бути у вигляді креслення:реальність, наприклад 1:10 diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8824ebaa6..dbf15afd9 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4363,23 +4363,30 @@ theme Appearance Giao diện Ambient Light Ánh sáng môi trường Light behind the object Ánh sáng phía sau đối tượng # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). - a highlighted edge, then click to place its dimension | một cạnh được tô sáng, rồi nhấp để đặt kích thước của nó | - cancels | Double-click the tool for options để hủy | Nhấp đúp vào công cụ để mở tùy chọn - All selected edges Tất cả các cạnh đã chọn + Dimension Kích thước + Drawing : real size — 1:10 labels 1 unit on the page as 10. Bản vẽ : kích thước thực — 1:10 ghi 1 đơn vị trên trang thành 10. + Preview (1 inch on the page) Xem trước (1 inch trên trang) + Set as Default Đặt làm mặc định + out from a highlighted edge to place its dimension | ra ngoài từ một cạnh được tô sáng để đặt kích thước của nó | + cancels | Double-click the tool for options để hủy | Nhấp đúp vào công cụ để mở tùy chọn + Linear Tuyến tính + Angular Góc + Away from the Line Cách xa đường + Between Line and Object Giữa đường và đối tượng + Drag out from a segment, or click two points, then click to place. Kéo ra ngoài từ một đoạn, hoặc nhấp hai điểm, rồi nhấp để đặt. + Drag out from a corner, or click two straight lines, then click to place. Kéo ra ngoài từ một góc, hoặc nhấp hai đường thẳng, rồi nhấp để đặt. + Drag out from a circle or arc for its radius, inwards for its diameter. Kéo từ đường tròn hoặc cung ra ngoài để lấy bán kính, vào trong để lấy đường kính. +@msg Choose a circle or a perfect arc Hãy chọn một đường tròn hoặc một cung hoàn hảo +@msg Choose a precision from 0 to 6 Hãy chọn độ chính xác từ 0 đến 6 Create Dimension Tạo kích thước - Dimension Selected Paths Ghi kích thước cho các đường đã chọn Dimension Tool Công cụ Kích thước Dimension Tool Options Tùy chọn công cụ Kích thước Display Hiển thị - Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Bản vẽ : kích thước thực — 1:10 nghĩa là 1 cm trên trang tương ứng với 10 cm. Font Family: Họ phông chữ: Font Style: Kiểu phông chữ: Precision: Độ chính xác: - Preview (1 cm on page) Xem trước (1 cm trên trang) Update Dimensions Cập nhật kích thước @msg Angles require two straight segments Góc cần hai đoạn thẳng -@msg Choose a circular path Hãy chọn một đường tròn -@msg Choose a length unit and precision from 0 to 6 Hãy chọn đơn vị độ dài và độ chính xác từ 0 đến 6 @msg Choose two different points Hãy chọn hai điểm khác nhau @msg Choose two non-parallel straight segments Hãy chọn hai đoạn thẳng không song song @msg Choose two nonzero angle arms Hãy chọn hai cạnh góc có độ dài khác 0 @@ -4391,7 +4398,6 @@ 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 Measured path no longer exists Đường được đo không còn tồn tại @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 Scale must be drawing:real, for example 1:10 Tỷ lệ phải có dạng bản vẽ:thực tế, ví dụ 1:10 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 552d879fb..9aa3f05da 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3958,21 +3958,30 @@ lighting Light 光源 Ambient Light 环境光 Light behind the object 光源位于对象后方 # Dimension tool (linked dimension annotations) - a highlighted edge, then click to place its dimension | 高亮显示的边缘,然后单击以放置尺寸标注 | - cancels | Double-click the tool for options 以取消 | 双击工具以打开选项 - All selected edges 所有选定的边缘 Create Dimension 创建尺寸标注 - Dimension Selected Paths 为选定路径标注尺寸 Dimension Tool 尺寸标注工具 Dimension Tool Options 尺寸标注工具选项 - Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. 图纸 : 实际尺寸 — 1:10 表示页面上的 1 cm 代表实际的 10 cm。 Font Family: 字体系列: Font Style: 字体样式: Precision: 精度: - Preview (1 cm on page) 预览(页面上的 1 cm) Update Dimensions 更新尺寸标注 Display 显示 # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). + Dimension 尺寸 + Drawing : real size — 1:10 labels 1 unit on the page as 10. 图纸 : 实际尺寸 — 1:10 会将页面上的 1 个单位标注为 10。 + Preview (1 inch on the page) 预览(页面上 1 英寸) + Set as Default 设为默认 + out from a highlighted edge to place its dimension | 从高亮的边向外拖动以放置其尺寸 | + cancels | Double-click the tool for options 取消 | 双击工具打开选项 + Linear 线性 + Angular 角度 + Away from the Line 远离尺寸线 + Between Line and Object 在尺寸线与对象之间 + Drag out from a segment, or click two points, then click to place. 从线段向外拖动,或单击两个点,然后单击以放置。 + Drag out from a corner, or click two straight lines, then click to place. 从角向外拖动,或单击两条直线,然后单击以放置。 + Drag out from a circle or arc for its radius, inwards for its diameter. 从圆或圆弧向外拖动得到半径,向内拖动得到直径。 +@msg Choose a circle or a perfect arc 请选择圆或标准圆弧 +@msg Choose a precision from 0 to 6 请选择 0 到 6 之间的精度 # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 封闭开放轮廓的圆形端部。部分旋转的切割侧面保持开放。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2a06ad98c..60a51adba 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3742,22 +3742,31 @@ lighting Light 光源 Ambient Light 環境光 Light behind the object 光源位於物件後方 # Dimension tool (linked dimension annotations) - a highlighted edge, then click to place its dimension | 醒目提示的邊緣,然後按一下以放置尺寸標註 | - cancels | Double-click the tool for options 可取消 | 按兩下工具以開啟選項 - All selected edges 所有選取的邊緣 Create Dimension 建立尺寸標註 - Dimension Selected Paths 為選取的路徑標註尺寸 Dimension Tool 尺寸標註工具 Dimension Tool Options 尺寸標註工具選項 - Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. 圖面 : 實際尺寸 — 1:10 表示頁面上的 1 cm 代表實際的 10 cm。 Font Family: 字體系列: Font Style: 字體樣式: Precision: 精確度: - Preview (1 cm on page) 預覽(頁面上的 1 cm) Update Dimensions 更新尺寸標註 Display 顯示 # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). + Dimension 尺寸 + Drawing : real size — 1:10 labels 1 unit on the page as 10. 圖紙 : 實際尺寸 — 1:10 會將頁面上的 1 個單位標示為 10。 + Preview (1 inch on the page) 預覽(頁面上 1 英寸) + Set as Default 設為預設 + out from a highlighted edge to place its dimension | 從醒目提示的邊緣向外拖曳以放置其尺寸 | + cancels | Double-click the tool for options 取消 | 按兩下工具開啟選項 + Linear 線性 + Angular 角度 + Away from the Line 遠離尺寸線 + Between Line and Object 在尺寸線與物件之間 + Drag out from a segment, or click two points, then click to place. 從線段向外拖曳,或按一下兩個點,然後按一下以放置。 + Drag out from a corner, or click two straight lines, then click to place. 從角向外拖曳,或按一下兩條直線,然後按一下以放置。 + Drag out from a circle or arc for its radius, inwards for its diameter. 從圓形或圓弧向外拖曳得到半徑,向內拖曳得到直徑。 +@msg Choose a circle or a perfect arc 請選擇圓形或標準圓弧 +@msg Choose a precision from 0 to 6 請選擇 0 到 6 之間的精度 X: X: Y: Y: # Revolve cap description diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 016b47227..7f4022b4c 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -64,6 +64,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("dc-cube", include_bytes!("../../../assets/icons/dc-cube.svg")), ("dc-dash-align", include_bytes!("../../../assets/icons/dc-dash-align.svg")), ("dc-dash-exact", include_bytes!("../../../assets/icons/dc-dash-exact.svg")), + ("dc-dimension", include_bytes!("../../../assets/icons/dc-dimension.svg")), ("dc-dir-off", include_bytes!("../../../assets/icons/dc-dir-off.svg")), ("dc-dir-on", include_bytes!("../../../assets/icons/dc-dir-on.svg")), ("dc-direct", include_bytes!("../../../assets/icons/dc-direct.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 20b8cf279..fb4ea148c 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -82,6 +82,7 @@ pub fn tool_icon(name: &str) -> &'static str { "tool-gradient" => "dc-gradient", "tool-eyedropper" => "pipette", "tool-measure" => "dc-measure", + "tool-dimension" => "dc-dimension", "tool-blend" => "dc-blend", "tool-symbol" => "dc-symbol-sprayer", "tool-graph" => "chart-column", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0dea3fcee..7f32ab483 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2396,7 +2396,6 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Fit to Selected Art", "artboard.fitToSelection"), ], ), - sub("Dimensions", vec![c("Dimension Selected Paths", "dimension.selection")]), sub( "Graph", vec![ diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 5e50b5fd8..beadd2084 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -60,6 +60,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { type_sections(app, ui); divider(ui); } + if crate::dialogs::dimension::selected(app).is_some() { + crate::dialogs::dimension::properties_section(app, ui); + divider(ui); + } appearance_section(app, ui); divider(ui); section_header(ui, tl!("Align")); diff --git a/docs/dimensions.md b/docs/dimensions.md index 0d330f0c4..f457d8dcc 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -1,58 +1,54 @@ # Dimensions -Choose **Dimension Tool** in the Modify section of the toolbar. Its control strip offers -**Length**, **Angle**, **Radius**, **Diameter**, **Options…** and **All selected edges**. +The **Dimension Tool** is in the Modify section of the toolbar. Its strip offers **Linear**, +**Angular**, **Radial** and **Tool Options…** (double-clicking the tool opens Tool Options too). -- **Length:** hover a path to highlight the segment between two anchors. Click it, move the - pointer to place the dimension, then click again. Curved segments report their actual curve - length, prefixed **Arc**, rather than the distance between their endpoints. -- **Two points:** Alt-click an anchor to start measuring individual points, click the second - point, then click to place the dimension. Picked anchors remain linked to their original paths. - Empty-space clicks also work; these fixed points have no source object to follow. Shift - constrains a manually picked point to 45-degree increments. -- **Angle:** click two nonparallel straight segments, then click to place the angle. To choose - three points instead, Alt-click the first arm, click the vertex, then the second arm; click - once more to place the annotation. Angles use the smaller angle between the chosen arms. -- **Radius / Diameter:** click a circular path, then move and click to choose the direction of - the annotation. Ellipses are rejected because they have no single radius. -- **All selected edges:** select paths or groups, then use this button or - **Object → Dimensions → Dimension Selected Paths**. Adds up to 1,000 segment dimensions in - one undo step. Existing dimension groups, hidden art and locked art are skipped. -- **Escape** or switching tools cancels a measurement being placed. +- **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 curved segment is measured + from end to end. +- **Angular:** hover a corner where two straight segments meet and drag outwards. Or click two + straight lines that meet (or would, extended), then click to place. +- **Radial:** hover a circle, or a segment that is a perfect arc, and drag: outwards gives its + radius, inwards its diameter. Clicking it gives its radius. Ellipses have no single radius. +- **Escape** or switching tools cancels a dimension being placed. -Double-click the tool or choose **Options…** for units, precision (0–6 decimal places), scale, -line weight/type/colour, arrows and arrow size, extension-line gaps/overshoot/visibility, font -family/style/size/colour, text position/orientation/offset. **Reset** restores defaults in the -dialog; **Cancel** leaves settings unchanged. **Save** stores tool defaults, and optionally -updates selected dimension groups. These changes are undoable; defaults persist in preferences. +**Tool Options** set the style of new dimensions: units (inches, millimetres, points, picas, +pixels, centimetres, metres, feet, yards), precision (0–6 decimals), scale, the dimension and +extension lines (weight, type, colour, arrows), and the text (font, size, colour, position and +alignment). **Position** is **Away from the Line** (the label on the far side of the dimension line +from the object) or **Between Line and Object**; **Alignment** is **Left**, **Center** or +**Right** along the line. **Save** keeps them for new dimensions; existing ones don't change. -Scale means **drawing : real size**, independent of zoom. At **1:10**, a 1 cm line on the page -is labelled 10 cm. At **10:1**, it is labelled 0.1 cm. Presets and custom positive ratios are -supported. Scale affects lengths, not angles or the physical size of text/arrows. +Scale means **drawing : real size**, independent of zoom. At **1:10** a 1 in line on the page is +labelled 10 in; at **10:1**, 0.1 in. It scales lengths, not angles or the size of text and arrows. -Measurements track source moves, scaling and anchor/handle edits, including undo/redo. -Deleting a source or making it degenerate removes its linked annotations in the same undo -step. Changing a path's anchor count/closedness invalidates indexed references and removes -those annotations rather than attaching them to a different edge. Recreate them after such -topology edits. A circle changed into an ellipse similarly loses its radius/diameter annotation. +**Properties** shows a selected dimension's units, precision, line, arrows, text size, colour, +position and alignment; changing one changes that dimension. **Apply to All** gives every +dimension in the document its style; **Set as Default** makes it the style of new dimensions. -Native `.vectorcraft` files retain recipes and links. SVG/PDF/bitmap output contains ordinary -paths and text, so exported dimensions look the same; generic SVG/PDF round trips do not promise -editable measurement links. Ungrouping exposes the generated paths/text as ordinary artwork. -Copied/pasted dimensions retain their measured geometry but detach their original links, so -source ids cannot accidentally bind to unrelated objects in another document. +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. + +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 +paths and text as ordinary art. Pasted dimensions keep their geometry and drop their links, so +they never bind to unrelated art in another document. ## Commands -- `dimension.create`: fixed points or linked segment/angle/circle/anchor sources, mode, offset, - and style options; returns the group id and label. -- `dimension.selection`: all selected path segments, one undo step. -- `dimension.update`: change existing groups' options and/or placement offset. +- `dimension.create`: two points, or a source (segment, angle, circle, arc, points), the mode, + offset and style; returns the group id and label. +- `dimension.update`: restyle or move existing dimensions. +- `dimension.applyToAll`: every dimension takes one dimension's style, in one undo step. - `tool.select {"tool":"dimension"}` and - `tool.setOption {"tool":"dimension","values":{"mode":"linear","style":{"units":"cm","scale":"1:10"}}}`. -- `tool.options {"tool":"dimension"}` opens the agent-accessible `dimensionOptions` dialog. + `tool.setOption {"tool":"dimension","values":{"mode":"linear","style":{"units":"mm","scale":"1:10"}}}` + (`mode`: `linear`, `angular` or `radial`). +- `tool.options {"tool":"dimension"}` opens the `dimensionOptions` dialog. -Recipes live on normal document groups (`Node.dimension`); rendering and export use their -generated children. Commands validate options and finite bounds before mutation. Source edits -refresh annotations inside `Session::edit`, sharing history and cancellation with the source. -The icon uses the project's existing original measurement artwork; no external assets are added. +Recipes live on ordinary groups (the node's `dimension` field); rendering and export use their +generated children. Commands check options and bounds before changing anything. Linked +dimensions refresh inside `Session::edit`, sharing the source edit's undo step; documents without +any (`Document::dimensions` unset) skip that refresh.