From 9d813e906a893423ac6ed38f03d814a246ef119f Mon Sep 17 00:00:00 2001 From: Osama Amin <341642285+osamaamin2011@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:30:45 +0300 Subject: [PATCH] Fix #1372: Dimension tool and Object > Dimensions: linked length, angle, radius and diameter annotations --- contributors/people.toml | 5 + crates/doc/src/dimension.rs | 341 +++++++++++++++++++ crates/doc/src/lib.rs | 8 + crates/doc/src/node.rs | 15 + crates/engine/src/cmd/dimension.rs | 423 ++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/lib.rs | 6 +- crates/engine/src/tooling.rs | 11 + crates/engine/tests/dimensions.rs | 245 ++++++++++++++ crates/tools/src/catalog.rs | 1 + crates/tools/src/dimension.rs | 278 ++++++++++++++++ crates/tools/src/lib.rs | 2 + crates/tools/src/settings.rs | 1 + crates/ui-egui/src/chrome.rs | 6 + crates/ui-egui/src/dialogs/dimension.rs | 300 +++++++++++++++++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/i18n/cs.tsv | 39 +++ crates/ui-egui/src/i18n/de.tsv | 40 +++ crates/ui-egui/src/i18n/es.tsv | 39 +++ crates/ui-egui/src/i18n/fr.tsv | 38 +++ crates/ui-egui/src/i18n/hu.tsv | 38 +++ crates/ui-egui/src/i18n/it.tsv | 40 +++ crates/ui-egui/src/i18n/ja.tsv | 41 +++ crates/ui-egui/src/i18n/ko.tsv | 40 +++ crates/ui-egui/src/i18n/pl.tsv | 39 ++- crates/ui-egui/src/i18n/pt-br.tsv | 6 +- crates/ui-egui/src/i18n/ru.tsv | 40 +++ crates/ui-egui/src/i18n/uk.tsv | 40 +++ crates/ui-egui/src/i18n/vi.tsv | 39 ++- crates/ui-egui/src/i18n/zh-hans.tsv | 16 + crates/ui-egui/src/i18n/zh-hant.tsv | 18 + crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/toolbar.rs | 6 + docs/dimensions.md | 58 ++++ 34 files changed, 2220 insertions(+), 4 deletions(-) create mode 100644 crates/doc/src/dimension.rs create mode 100644 crates/engine/src/cmd/dimension.rs create mode 100644 crates/engine/tests/dimensions.rs create mode 100644 crates/tools/src/dimension.rs create mode 100644 crates/ui-egui/src/dialogs/dimension.rs create mode 100644 docs/dimensions.md diff --git a/contributors/people.toml b/contributors/people.toml index 23f904650..354719f8c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -98,3 +98,8 @@ sync_github_name = false real_name = "Tomasz Lewandowski" display_name = "Tomasz Lewandowski" sync_github_name = false + +[people.osamaamin2011] +real_name = "Osama Amin" +display_name = "Osama Amin" +sync_github_name = false diff --git a/crates/doc/src/dimension.rs b/crates/doc/src/dimension.rs new file mode 100644 index 000000000..9e0762ed5 --- /dev/null +++ b/crates/doc/src/dimension.rs @@ -0,0 +1,341 @@ +//! 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}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct DimensionOptions { + pub units: String, + 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_color: [u8; 3], + pub arrow_style: String, + pub arrow_scale: f64, + pub extension_weight: f64, + pub extension_type: String, + pub extension_color: [u8; 3], + pub extension_offset: f64, + pub extension_length: f64, + pub hide_extensions: bool, + pub font_family: String, + pub font_style: String, + pub font_size: f64, + pub text_color: [u8; 3], + pub text_position: String, + pub text_alignment: String, + pub text_offset: f64, +} + +impl Default for DimensionOptions { + fn default() -> Self { + Self { + units: "cm".into(), + precision: 1, + scale: "1:1".into(), + hide_units: false, + line_weight: 1.0, + line_type: "solid".into(), + line_color: [224, 55, 65], + arrow_style: "filled".into(), + 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, + 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, + } + } +} + +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); + let a = parse(a).ok_or("Invalid drawing scale")?; + let b = parse(b).ok_or("Invalid real scale")?; + let f = b / a; + if !(1e-6..=1e6).contains(&f) { + return Err("Scale ratio must be between 1:1000000 and 1000000:1".into()); + } + Ok(f) + } + 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()); + } + for (v, min, max) in [ + (self.line_weight, 0.1, 100.0), + (self.extension_weight, 0.1, 100.0), + (self.arrow_scale, 1.0, 1000.0), + (self.font_size, 1.0, 1000.0), + (self.extension_offset, 0.0, 10000.0), + (self.extension_length, 0.0, 10000.0), + (self.text_offset, 0.0, 10000.0), + ] { + if !v.is_finite() || v < min || v > max { + 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 { + self.validate()?; + let value = if angle { points } else { points * self.factor()? / self.unit().ok_or("Unknown unit")?.points() }; + if !value.is_finite() { + return Err("Non-finite dimension".into()); + } + let suffix = if self.hide_units { + "" + } else if angle { + "°" + } else { + self.units.as_str() + }; + let sep = if suffix.is_empty() || angle { "" } else { " " }; + Ok(format!("{:.*}{sep}{suffix}", self.precision, value)) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SegmentRef { + pub node: NodeId, + pub subpath: usize, + pub segment: usize, +} + +impl SegmentRef { + pub fn endpoints(&self, doc: &Document) -> Option<(Point, Point, f64)> { + 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) })) + } +} + +#[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 }, +} + +impl DimensionSource { + pub fn nodes(&self) -> Vec { + match self { + Self::Segment { 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(), + } + } + /// Anchor indexes cannot safely follow a topology replacement. Ordinary moves and handle + /// edits keep their topology and remain linked. + pub fn topology_changed(&self, before: &Document, after: &Document) -> bool { + if matches!(self, Self::Circle { .. }) { + return false; + } + self.nodes().iter().any(|id| { + let counts = |doc: &Document| { + doc.node(*id).and_then(|n| n.path_data()).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect::>()) + }; + let old = counts(before); + old.is_some() && old != counts(after) + }) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PointRef { + pub node: NodeId, + pub subpath: usize, + pub anchor: usize, +} + +impl PointRef { + pub fn position(&self, doc: &Document) -> Option { + Some(doc.node(self.node)?.path_data()?.subpaths.get(self.subpath)?.anchors.get(self.anchor)?.p) + } +} + +#[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 a: Point, + pub b: Point, + /// Angular vertex. For radius/diameter a is the centre and b lies on the circle. + pub vertex: Point, + pub offset: f64, + pub length: f64, + pub options: DimensionOptions, + pub source: Option, +} + +impl Dimension { + 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; + self.length = self.a.distance(self.b); + } + } + pub fn resolve(&mut self, doc: &Document) -> Result<(), String> { + match &self.source { + Some(DimensionSource::Points { a, b, vertex }) => { + for (reference, point) in [(a, &mut self.a), (b, &mut self.b), (vertex, &mut self.vertex)] { + if let Some(r) = reference { + *point = r.position(doc).ok_or("Measured anchor no longer exists")?; + } + } + 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")?; + self.a = a; + self.b = b; + self.length = length; + } + 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) { + 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 u = b - a; + let v = d - c; + let den = u.cross(v); + if den.abs() < 1e-9 { + return Err("Choose two non-parallel straight segments".into()); + } + let p = a + u * ((c - a).cross(v) / den); + self.vertex = p; + self.a = if a.distance(p) > b.distance(p) { a } else { b }; + 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; + } + None => {} + } + self.validate() + } + pub fn validate(&self) -> Result<(), String> { + self.options.validate()?; + for p in [self.a, self.b, self.vertex] { + if !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1e8 || p.y.abs() > 1e8 { + return Err("Dimension point is outside the canvas".into()); + } + } + 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) { + return Err("Choose two nonzero angle arms".into()); + } + Ok(()) + } + pub fn angle(&self) -> f64 { + let u = self.a - self.vertex; + let v = self.b - self.vertex; + 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 { "" }), + }; + Ok(format!("{prefix}{}", self.options.label(value, self.mode == "angular")?)) + } +} + +/// Accept circular paths (including imported circles), reject ellipses and arbitrary closed shapes. +pub fn circle(doc: &Document, id: NodeId) -> Option<(Point, f64)> { + use vectorcraft_geom::kurbo::ParamCurve; + let path = doc.node(id)?.path_data()?; + let sp = path.subpaths.first()?; + if path.subpaths.len() != 1 || !sp.closed || !(2..=64).contains(&sp.segment_count()) { + return None; + } + let bounds = path.bounds()?; + let center = bounds.center(); + let radius = bounds.width() * 0.5; + if radius < 1e-6 || (bounds.height() * 0.5 - radius).abs() > radius * 0.002 { + return None; + } + for i in 0..sp.segment_count() { + for t in [0.0, 0.25, 0.5, 0.75] { + if (sp.segment(i).eval(t).distance(center) - radius).abs() > radius * 0.002 { + return None; + } + } + } + Some((center, radius)) +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 7d7a33e52..17c7def44 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -12,6 +12,7 @@ pub mod clipnest; pub mod cmyk; pub mod corners; pub mod depth; +pub mod dimension; pub mod graph; pub mod hit; mod id_index; @@ -1399,6 +1400,13 @@ impl Document { pub fn reid(&mut self, node: &Node) -> Node { let mut n = node.clone(); n.id = self.alloc_id(); + // A copied annotation must never bind its source ids to unrelated art in a different + // document. Keep the measured geometry; new measurements can be linked after pasting. + if let Some(e) = n.extras_if_any() + && let Some(dimension) = &mut e.dimension + { + dimension.source = None; + } if let Some(ch) = n.children_mut() { let old: Vec> = std::mem::take(ch); *ch = old.iter().map(|c| Arc::new(self.reid(c))).collect(); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 70d6418d7..d6711ab44 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -555,6 +555,9 @@ pub struct NodeExtras { pub slice: Option>, /// Object › Perspective: the perspective grid plane the object is attached to. pub perspective: Option>, + /// Dimension object: the live measurement it draws; its ordinary group children hold its + /// exportable appearance (Object › Dimensions, the Dimension tool). + pub dimension: Option>, } /// No extras: what [`Node::extras`] gives for a node without any. @@ -569,6 +572,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras { attrs: None, slice: None, perspective: None, + dimension: None, }; /// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal @@ -699,6 +703,7 @@ impl Serialize for Node { e.slice.is_none(), is_default(&self.bbox_angle), e.perspective.is_none(), + e.dimension.is_none(), ]; let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; let mut skip = skips.into_iter(); @@ -733,6 +738,7 @@ impl Serialize for Node { field!("slice", &e.slice); field!("bbox_angle", &self.bbox_angle); field!("perspective", &e.perspective); + field!("dimension", &e.dimension); st.end() } } @@ -784,6 +790,8 @@ struct NodeWire { bbox_angle: f64, #[serde(default)] perspective: Option>, + #[serde(default)] + dimension: Option>, } impl From for Node { @@ -799,6 +807,7 @@ impl From for Node { attrs: w.attrs, slice: w.slice, perspective: w.perspective, + dimension: w.dimension, }; Node { id: w.id, @@ -1159,6 +1168,12 @@ impl Node { self.transform_scaled(a, &scaling.into()); } fn transform_scaled(&mut self, a: Affine, sc: &Scaling) { + // A dimension's measurement moves with it. + if let Some(e) = self.extras_if_any() + && let Some(dimension) = &mut e.dimension + { + dimension.transform(a); + } if !self.is_layer() { self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a); } diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs new file mode 100644 index 000000000..a083ee823 --- /dev/null +++ b/crates/engine/src/cmd/dimension.rs @@ -0,0 +1,423 @@ +//! Linked dimensions: generated groups share the existing text/path render and export pipeline. +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::{Appearance, Arrowhead, CharStyle, Dash, Document, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "dimension.create", + "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", + 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}", + has_doc, + update + ), + ] +} + +fn error(s: impl ToString) -> EngineError { + EngineError::Other(s.to_string()) +} + +fn options(s: &Session, p: &Value) -> Result { + let default = s.tool_options_of("dimension"); + let value = p.get("options").cloned().unwrap_or_else(|| default.get("style").cloned().unwrap_or(json!({}))); + let o: DimensionOptions = serde_json::from_value(value).map_err(error)?; + o.validate().map_err(error)?; + Ok(o) +} + +fn point(p: &Value, key: &str) -> Result { + let a = p.get(key).and_then(Value::as_array).filter(|a| a.len() == 2).ok_or_else(|| error(format!("{key} must be [x,y]")))?; + let x = a.first().and_then(Value::as_f64).ok_or_else(|| error("Invalid point"))?; + let y = a.get(1).and_then(Value::as_f64).ok_or_else(|| error("Invalid point"))?; + Ok(Point::new(x, y)) +} + +fn distance(p: &Value, fallback: f64) -> Result { + match p.get("offset") { + Some(v) => v.as_f64().filter(|n| n.is_finite() && n.abs() <= 1e6).ok_or_else(|| error("Invalid dimension offset")), + None => Ok(fallback), + } +} + +/// 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 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; + for id in source.nodes() { + if doc.ancestry(id).is_some_and(|path| path.iter().any(|id| doc.node(*id).is_some_and(|n| n.extras().dimension.is_some()))) { + 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 { .. }) + ); + if !compatible && !matches!((mode, source), ("linear" | "angular", DimensionSource::Points { .. })) { + 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 { .. }))) { + 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 }; + d.resolve(&s.doc()?.doc).map_err(error)?; + Ok(d) +} + +fn insert(doc: &mut Document, parent: Option, dim: Dimension) -> Result { + let id = doc.alloc_id(); + 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 }; + group.extras_mut().dimension = Some(Box::new(dim)); + doc.insert(parent, usize::MAX, group)?; + Ok(id) +} + +fn create(s: &mut Session, p: &Value) -> Result { + let dim = recipe(s, p)?; + let label = dim.label().map_err(error)?; + let parent = s.doc()?.insertion_parent(); + s.note_journal("options", serde_json::to_value(&dim.options).map_err(error)?); + let id = s.edit("Create Dimension", |doc, sel| { + let id = insert(doc, parent, dim)?; + sel.set([id]); + Ok(id) + })?; + 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 update(s: &mut Session, p: &Value) -> Result { + let ids: Vec = match p.get("ids") { + 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)")); + } + let o = if p.get("options").is_some() { Some(options(s, p)?) } else { None }; + s.edit("Update Dimensions", |doc, _| { + for id in &ids { + 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"))?; + if let Some(o) = &o { + dim.options = o.clone(); + } + dim.offset = distance(p, dim.offset)?; + 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)?; + if let Some(n) = doc.node_mut(id) { + n.kind = NodeKind::Group { children, clip: false }; + n.name = Some(format!("Dimension · {}", dim.label().map_err(error)?)); + n.extras_mut().dimension = Some(Box::new(dim)); + } + Ok(()) +} + +/// Called inside the source edit transaction, so its dimensions and undo history stay in sync. +pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> { + let mut dimensions = Vec::new(); + doc.walk(|n| { + if let Some(d) = &n.extras().dimension { + dimensions.push((n.id, (**d).clone())); + } + }); + for (id, old) in dimensions { + let mut next = old.clone(); + 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) { + replace(doc, id, next)?; + } + } + Ok(()) +} + +fn paint(c: [u8; 3]) -> Paint { + Paint::solid(Color::rgb8(c[0], c[1], c[2])) +} +fn line(a: Point, b: Point) -> BezPath { + let mut p = BezPath::new(); + p.move_to(a); + p.line_to(b); + p +} + +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()) }; + 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" => { + st.cap = vectorcraft_doc::LineCap::Round; + Some(Dash { pattern: vec![0.0, width * 3.0], ..Default::default() }) + } + _ => None, + }; + if arrows { + let head = match o.arrow_style.as_str() { + "filled" => Some(Arrowhead::Triangle), + "open" => Some(Arrowhead::TriangleOpen), + "tick" => Some(Arrowhead::Slash), + _ => None, + }; + st.start_arrow = head; + st.end_arrow = head; + st.arrow_scale = (o.arrow_scale, o.arrow_scale); + st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip; + } + } + a +} + +fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result>> { + d.validate().map_err(error)?; + let o = &d.options; + let label = d.label().map_err(error)?; + let mut paths: Vec<(BezPath, Appearance)> = 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" { + let start = (d.a - d.vertex).atan2(); + let sweep = d.angle(); + let r = d.offset.abs().max(12.0); + let on = |t: f64| d.vertex + Vec2::from_angle(start + sweep * t) * r; + let mut arc = BezPath::new(); + arc.move_to(on(0.0)); + for i in 1..=64 { + arc.line_to(on(i as f64 / 64.0)); + } + paths.push((arc, appearance(o, false, true))); + if !o.hide_extensions { + for p in [d.a, d.b] { + let dir = (p - d.vertex) / (p - d.vertex).hypot(); + paths.push((line(d.vertex + dir * o.extension_offset, d.vertex + dir * (r + o.extension_length)), appearance(o, true, false))); + } + } + text_at = d.vertex + Vec2::from_angle(start + sweep * 0.5) * (r + o.text_offset + o.font_size * 0.5); + } else { + let delta = d.b - d.a; + let u = delta / delta.hypot(); + let n = Vec2::new(-u.y, u.x); + let (a, b) = match d.mode.as_str() { + "radius" => (d.a, d.b), + "diameter" => (d.a - delta, d.b), + _ => (d.a + n * d.offset, d.b + n * d.offset), + }; + if d.mode == "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))); + } + } + 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; + 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(), + size: o.font_size, + fill: paint(o.text_color), + ..Default::default() + }; + let mut text = TextObject::point(Point::ZERO, &label, style); + text.para.justify = vectorcraft_doc::Justify::Center; + super::typecmd::refresh_bounds(&mut text); + text.xf = Affine::translate(text_at.to_vec2()) * Affine::rotate(angle); + // Split the centre line around the label rather than paint an opaque white patch over art. + if o.text_position == "center" + && d.mode != "angular" + && let Some((path, app)) = paths.pop() + { + let elems = path.elements(); + if let (Some(vectorcraft_geom::PathEl::MoveTo(a)), Some(vectorcraft_geom::PathEl::LineTo(b))) = (elems.first(), elems.get(1)) { + let delta = *b - *a; + let len = delta.hypot(); + let u = delta / len; + let bounds = text.cached_bounds.unwrap_or_else(|| text.estimate_bounds()); + let gap = (bounds.width() * 0.5 + 4.0).min(len * 0.45); + let mid = a.midpoint(*b); + let mut left = app.clone(); + if let Some(st) = left.stroke_mut() { + st.end_arrow = None; + } + let mut right = app; + if let Some(st) = right.stroke_mut() { + st.start_arrow = None; + } + paths.push((line(*a, mid - u * gap), left)); + paths.push((line(mid + u * gap, *b), right)); + } + } + let mut children = Vec::new(); + for (path, a) in paths { + let id = old.get(children.len()).copied().unwrap_or_else(|| doc.alloc_id()); + children.push(Arc::new(Node::path(id, PathData::from_bezpath(&path), a))); + } + let id = old.get(children.len()).copied().unwrap_or_else(|| doc.alloc_id()); + let mut node = Node::new(id, NodeKind::Text(Box::new(text))); + node.appearance = Appearance::basic(paint(o.text_color), Paint::None, 0.0).into(); + children.push(Arc::new(node)); + Ok(children) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 92626ec70..df9e70a08 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -12,6 +12,7 @@ pub mod colortheme; mod create; mod css; mod cut; +pub(crate) mod dimension; pub(crate) mod distortcmds; mod docinfo; mod docmenu; @@ -202,6 +203,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(recolor::specs()); v.extend(fileio::specs()); v.extend(create::specs()); + v.extend(dimension::specs()); v.extend(object::specs()); v.extend(path::specs()); v.extend(select::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1be483d11..2d906d5a0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1462,7 +1462,11 @@ impl Session { let before = st.doc.clone(); let before_sel = st.selection.clone(); let doc = Arc::make_mut(&mut st.doc); - let result = match f(doc, &mut st.selection) { + // Dimensions follow the art they measure. + let result = match f(doc, &mut st.selection).and_then(|v| { + cmd::dimension::refresh(&before, doc)?; + Ok(v) + }) { Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) { Err(e) => Err(e), Ok(()) => { diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index d67edda72..ecb7e2e2c 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -453,6 +453,17 @@ impl Session { if let Some(k) = p.get("key").and_then(Value::as_str) { values.insert(k.to_string(), p.get("value").cloned().unwrap_or(Value::Null)); } + if tool.unwrap_or(self.tool_id()) == "dimension" { + if let Some(style) = values.get("style") { + let options: vectorcraft_doc::dimension::DimensionOptions = serde_json::from_value(style.clone()).map_err(|e| e.to_string())?; + options.validate()?; + } + if let Some(mode) = values.get("mode") + && !mode.as_str().is_some_and(|m| ["linear", "angular", "radius", "diameter"].contains(&m)) + { + return Err("Unknown dimension mode".into()); + } + } Ok(self.set_tool_options(tool, &values)) } diff --git a/crates/engine/tests/dimensions.rs b/crates/engine/tests/dimensions.rs new file mode 100644 index 000000000..b828dbb2b --- /dev/null +++ b/crates/engine/tests/dimensions.rs @@ -0,0 +1,245 @@ +//! Linked measurements, history, portable artwork and hostile command input. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +use serde_json::{Value, json}; +use vectorcraft_engine::{ + Session, + doc::{Document, NodeId, NodeKind}, +}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":600,"height":500})).unwrap(); + s +} +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap() +} +fn edge(s: &mut Session) -> u64 { + run(s, "shape.line", json!({"x1":100,"y1":100,"x2":172,"y2":100}))["id"].as_u64().unwrap() +} +fn dim(s: &mut Session, node: u64) -> u64 { + run( + s, + "dimension.create", + json!({"source":{"kind":"segment","edge":{"node":node,"subpath":0,"segment":0}},"options":{"units":"in","precision":2,"scale":"1:10"}}), + )["id"] + .as_u64() + .unwrap() +} +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() +} +#[test] +fn dimension_length_scale_live_edit_undo_and_roundtrip() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + assert_eq!(text(&s, id), "10.00 in"); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":100})); + assert_eq!(text(&s, id), "20.00 in"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s, id), "10.00 in"); + run(&mut s, "edit.redo", json!({})); + 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(); + let mut reopened = Session::new(); + reopened.add_document(doc, None); + run(&mut reopened, "select.set", json!({"ids":[line]})); + run(&mut reopened, "object.scale", json!({"sx":50,"sy":100})); + assert_eq!(text(&reopened, id), "10.00 in"); + run(&mut reopened, "edit.clear", json!({})); + assert!(reopened.doc().unwrap().doc.node(NodeId(id)).is_none()); + run(&mut reopened, "edit.undo", json!({})); + assert_eq!(text(&reopened, id), "10.00 in"); +} +#[test] +fn dimension_selected_rectangle_and_styles_are_one_undo() { + 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()); +} +#[test] +fn dimension_angles_circles_and_curved_lengths() { + let mut s = session(); + let a = run( + &mut s, + "dimension.create", + json!({"mode":"angular","a":[100,0],"vertex":[0,0],"b":[0,100],"options":{"precision":1,"scale":"1:10"}}), + )["id"] + .as_u64() + .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"] + .as_u64() + .unwrap(); + assert_eq!(text(&s, id), want); + } + 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); +} +#[test] +fn dimension_rejects_bad_input_without_mutation() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let original = serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); + for p in [ + json!({}), + json!({"a":[0,0],"b":[0,0]}), + json!({"a":[0,0],"b":[1e100,0]}), + 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!({"source":{"kind":"segment","edge":{"node":line,"subpath":0,"segment":18446744073709551615u64}}}), + json!({"mode":"angular","source":{"kind":"segment","edge":{"node":line,"subpath":0,"segment":0}}}), + ] { + assert!(s.execute("dimension.create", &p).is_err(), "{p}"); + assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), original); + } + assert!(s.execute("dimension.update", &json!({"ids":[id],"options":{"fontSize":-1}})).is_err()); + assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), original); +} + +#[test] +fn dimension_gesture_highlights_preview_commits_and_cancels() { + use vectorcraft_engine::{ + ViewInfo, + tools::{Mods, PointerEvent, PointerKind, ToolKey}, + }; + let mut s = session(); + edge(&mut s); + let view = ViewInfo::default(); + s.select_tool("dimension", view).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(); + assert!(s.in_interaction()); + s.tool_key(ToolKey::Escape, Mods::default(), view).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(); + assert!(!s.in_interaction()); + assert!(s.doc().unwrap().doc.node_count() > before); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap().doc.node_count(), before); +} + +#[test] +fn dimension_anchor_links_and_native_file_survive_source_moves() { + let mut s = session(); + let first = edge(&mut s); + let second = edge(&mut s); + let id=run(&mut s,"dimension.create",json!({"a":[100,100],"b":[172,100],"source":{"kind":"points","a":{"node":first,"subpath":0,"anchor":0},"b":{"node":second,"subpath":0,"anchor":1},"vertex":null},"options":{"units":"pt","precision":0}}))["id"].as_u64().unwrap(); + assert_eq!(text(&s, id), "72 pt"); + let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let doc = vectorcraft_format::load(&bytes).unwrap(); + let mut s = Session::new(); + s.add_document(doc, None); + run(&mut s, "select.set", json!({"ids":[second]})); + run(&mut s, "object.move", json!({"dx":28,"dy":0})); + assert_eq!(text(&s, id), "100 pt"); +} + +#[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(); + 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 prefs = serde_json::to_value(&s.prefs).unwrap(); + let mut fresh = Session::new(); + fresh.prefs = serde_json::from_value(prefs).unwrap(); + assert_eq!(fresh.tool_options_of("dimension")["style"], s.tool_options_of("dimension")["style"]); +} + +#[test] +fn dimension_unlinked_geometry_scales_and_updates_its_label() { + let mut s = session(); + let id = run(&mut s, "dimension.create", json!({"a":[100,100],"b":[172,100],"options":{"units":"in","precision":0}}))["id"].as_u64().unwrap(); + run(&mut s, "object.scale", json!({"sx":200,"sy":200})); + assert_eq!(text(&s, id), "2 in"); +} + +#[test] +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(); + 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]})); + assert!(["45.0°", "135.0°"].contains(&text(&s, id).as_str())); + let circle = run(&mut s, "shape.ellipse", json!({"x":100,"y":100,"width":144,"height":144}))["id"].as_u64().unwrap(); + let id = run( + &mut s, + "dimension.create", + json!({"mode":"diameter","source":{"kind":"circle","node":circle},"options":{"units":"in","precision":0}}), + )["id"] + .as_u64() + .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"); + 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()); +} + +#[test] +fn dimension_style_update_keeps_links_and_can_be_undone() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + run( + &mut s, + "dimension.update", + json!({"ids":[id],"offset":-42,"options":{"units":"mm","precision":2,"hideUnits":true,"textPosition":"below","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(); + assert!(d.source.is_some()); + assert_eq!(d.offset, -42.0); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s, id), "10.00 in"); +} + +#[test] +fn dimension_cannot_measure_its_own_generated_art() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let child = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap()[0].id; + assert!(s.execute("dimension.create", &json!({"source":{"kind":"segment","edge":{"node":child,"subpath":0,"segment":0}}})).is_err()); +} diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs index df8699bff..9e91f12d7 100644 --- a/crates/tools/src/catalog.rs +++ b/crates/tools/src/catalog.rs @@ -102,6 +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("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 new file mode 100644 index 000000000..8d8961f6c --- /dev/null +++ b/crates/tools/src/dimension.rs @@ -0,0 +1,278 @@ +//! 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. +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}, +}; +use vectorcraft_geom::{BezPath, Point, Vec2}; + +#[derive(Default)] +pub struct DimensionTool { + mode: String, + style: DimensionOptions, + points: Vec, + point_refs: Vec>, + first_edge: Option, + pending: Option, + hover: Option, + at: Point, + began: bool, +} + +/// Nearest editable segment, with bounded work and no snapping to dimension artwork itself. +pub fn pick(cx: &ToolContext, p: Point) -> Option { + let mut best = None; + let mut dist = cx.tol(8.0); + let mut budget = 20_000usize; + fn visit(n: &Node, p: Point, budget: &mut usize, dist: &mut f64, best: &mut Option) { + if *budget == 0 || !n.visible || n.locked || n.extras().dimension.is_some() { + return; + } + if let Some(path) = n.path_data() { + for (si, sp) in path.subpaths.iter().enumerate() { + for i in 0..sp.segment_count() { + if *budget == 0 { + return; + } + *budget -= 1; + use vectorcraft_geom::kurbo::ParamCurveNearest; + let near = sp.segment(i).nearest(p, 1e-4); + if near.distance_sq < *dist * *dist { + *dist = near.distance_sq.sqrt(); + *best = Some(SegmentRef { node: n.id, subpath: si, segment: i }); + } + } + } + } + if let Some(ch) = n.children() { + for c in ch.iter().rev() { + visit(c, p, budget, dist, best); + } + } + } + for n in cx.doc.layers.iter().rev() { + visit(n, p, &mut budget, &mut dist, &mut best); + } + best +} + +impl DimensionTool { + fn point_source(&self, angular: bool) -> Option { + if self.point_refs.iter().all(Option::is_none) { + return None; + } + 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, 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 } + } + 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}); + if let Some(source) = &d.source { + p["source"] = json!(source); + } + p + } + 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 d.resolve(cx.doc).is_err() { + return vec![]; + } + let mut actions = vec![]; + if !self.began { + actions.push(Action::Begin("Create Dimension".into())); + self.began = true; + } + actions.push(Action::Preview("dimension.create".into(), Self::params(d))); + actions + } + fn reset(&mut self) { + self.pending = None; + self.points.clear(); + self.point_refs.clear(); + self.first_edge = None; + self.began = false; + } +} + +impl Tool for DimensionTool { + fn id(&self) -> &'static str { + "dimension" + } + fn busy(&self) -> bool { + self.pending.is_some() || !self.points.is_empty() || self.first_edge.is_some() + } + fn cursor(&self, _: &ToolContext, _: Point, _: Mods) -> Cursor { + Cursor::Crosshair + } + fn options(&self) -> Value { + json!({"mode":self.mode(),"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)) + { + self.mode = v.into(); + } + if key == "style" + && let Ok(o) = serde_json::from_value::(value.clone()) + && o.validate().is_ok() + { + self.style = o; + } + } + 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); + } + } + } + "radius" | "diameter" => { + if let Some(edge) = hit + && let Some((center, _)) = vectorcraft_doc::dimension::circle(cx.doc, edge.node) + { + candidate = Some(self.recipe(center, ev.pos, Point::ZERO, Some(DimensionSource::Circle { node: edge.node }))); + } + } + _ => { + 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); + } + 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))), + _ => {} + } + } + } + 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 { + let cancel = self.began; + self.reset(); + return if cancel { vec![Action::Cancel] } else { vec![] }; + } + vec![] + } + fn deactivate(&mut self, cx: &ToolContext) -> Vec { + self.key(cx, ToolKey::Escape, Mods::default()) + } + 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) + { + out.push(Overlay::Measure { p: self.at + Vec2::new(12.0, 12.0), text }); + } + } + for p in &self.points { + out.push(Overlay::Anchor { p: *p, color, filled: true, size: 6.0 }); + } + if let Some(p) = self.points.last() { + out.push(Overlay::Line { a: *p, b: self.at, color, dashed: true }); + } + out + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index ee47ef5e1..25ec1dce5 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -13,6 +13,7 @@ pub mod catalog; pub mod corners; pub mod cropimage; pub mod cut; +pub mod dimension; pub mod direct; pub mod distort; pub mod draw2; @@ -601,6 +602,7 @@ pub fn create(id: &str) -> Box { "directSelection" => Box::new(direct::DirectSelectionTool::new(false)), "groupSelection" => Box::new(direct::DirectSelectionTool::new(true)), "pen" => Box::new(pen::PenTool::default()), + "dimension" => Box::new(dimension::DimensionTool::default()), "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath" => Box::new(text::TypeTool::new(id)), "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)), // Not in the toolbar: `file.place.queue` loads it. diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index 9f1301c6c..77dfa1da7 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -19,6 +19,7 @@ pub const SYMBOLISM: &[&str] = &["symbolSprayer", "symbolShifter", "symbolScruncher", "symbolSizer", "symbolSpinner", "symbolStainer", "symbolScreener", "symbolStyler"]; const ROWS: &[Row] = &[ + (&["dimension"], None, &["mode", "style"]), // Global Brush Dimensions. (LIQUIFY, Some("liquify"), &["width", "height", "angle", "intensity", "usePressure", "showBrush"]), (LIQUIFY, None, &["detail", "simplify", "simplifyOn", "rate", "complexity", "horizontal", "vertical", "affectAnchors", "affectIn", "affectOut"]), diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index f9af2db70..5e921d29f 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -610,6 +610,12 @@ 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), + ("Esc", true), + (" cancels | Double-click the tool for options", false), + ], "selection" => &[ ("Click", true), (" the object to select | ", false), diff --git a/crates/ui-egui/src/dialogs/dimension.rs b/crates/ui-egui/src/dialogs/dimension.rs new file mode 100644 index 000000000..222e83a23 --- /dev/null +++ b/crates/ui-egui/src/dialogs/dimension.rs @@ -0,0 +1,300 @@ +//! Dimension options and compact mode strip. Values are drafts until Save. +use super::DialogSpec; +use crate::{VectorcraftApp, state::Dialog, theme::Tokens, widgets}; +use egui::{RichText, Ui}; +use serde_json::{Value, json}; +use vectorcraft_doc::dimension::DimensionOptions; + +pub const KIND: &str = "dimensionOptions"; +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Dimension Tool Options").into(), + body, + confirm, + ok: Some("Save"), + min_width: 520.0, + max_width: Some(560.0), + ..DialogSpec::FORM +}; + +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 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())); + 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())?; + o.validate()?; + Ok(o) +} + +fn choose(ui: &mut Ui, d: &mut Dialog, key: &str, label: &str, choices: &[(&str, &str)]) { + widgets::label_row(ui, tl!(label), 96.0, |ui| { + let current = d.str(key); + 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) + && let Some((value, _)) = choices.get(index) + { + d.fields.insert(key.into(), json!(value)); + } + }); +} +fn number(ui: &mut Ui, d: &mut Dialog, key: &str, label: &str, range: (f64, f64), suffix: &str) { + 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))); + } + }); +} +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 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 section(ui: &mut Ui, title: &str, body: impl FnOnce(&mut Ui)) { + let t = Tokens::get(ui.ctx()); + ui.group(|ui| { + ui.set_min_width(486.0); + ui.label(RichText::new(tl!(title)).color(t.text_dim).strong()); + ui.add_space(5.0); + body(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 { + 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)); + }); + ui.horizontal(|ui| { + widgets::label_row(ui, tl!("Scale:"), 96.0, |ui| { + let mut scale = d.str("scale"); + if ui.add(egui::TextEdit::singleline(&mut scale).desired_width(72.0)).changed() { + d.fields.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)); + } + } + }); + }); + check(ui, d, "hideUnits", "Hide Units"); + }); + ui.label(RichText::new(tl!("Drawing : real size — 1:10 means 1 cm on the page represents 10 cm.")).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"); + }); + 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), "%"); + }); + }); + 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"); + }); + ui.horizontal(|ui| { + number(ui, d, "extensionOffset", "Offset:", (0.0, 10000.0), "pt"); + number(ui, d, "extensionLength", "Extension:", (0.0, 10000.0), "pt"); + }); + check(ui, d, "hideExtensions", "Hide Extension Lines"); + }); + section(ui, "Dimension Text", |ui| { + widgets::label_row(ui, tl!("Font Family:"), 96.0, |ui| { + 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)) + { + d.fields.insert("fontFamily".into(), json!(family)); + d.fields.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 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) + && let Some(style) = styles.get(i) + { + d.fields.insert("fontStyle".into(), json!(style)); + } + }); + ui.horizontal(|ui| { + number(ui, d, "fontSize", "Font Size:", (1.0, 1000.0), "pt"); + color(ui, d, "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, d, "textAlignment", "Orientation:", &[("aligned", "Along dimension"), ("horizontal", "Horizontal")]); + }); + match read(d) { + Ok(o) => { + let sample = o.label(72.0 / 2.54, 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)); + } + 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"); + }); + 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 r = app.run("tool.setOption", json!({"tool":"dimension","values":{"style":o}}))?; + app.ui.dialog = None; + Ok(r) +} + +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")] { + if ui.selectable_label(mode == id, tl!(label)).clicked() { + // Switching away cancels previews and unfinished point selections. + if let Err(e) = app + .run("tool.select", json!({"tool":"selection"})) + .and_then(|_| app.run("tool.select", json!({"tool":"dimension"}))) + .and_then(|_| app.run("tool.setOption", json!({"tool":"dimension","values":{"mode":id}}))) + { + app.ui.status = e; + } + } + } + if ui.button(tl!("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. +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")) + .id(egui::Id::new("dimension-strip")) + .default_pos(egui::pos2(90.0, 120.0)) + .resizable(false) + .collapsible(false) + .show(ctx, |ui| { + 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.", + }; + ui.label(RichText::new(tl!(hint)).small().color(Tokens::get(ctx).text_dim)); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn dimension_dialog_fields_save_and_cancel() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("tool.options", json!({"tool":"dimension"})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); + for label in ["Dimension Tool Options", "Units & Scale", "Dimension Lines", "Extension Lines", "Dimension Text", "Save"] { + assert!(text.contains(label), "{label}: {text}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("scale".into(), json!("1:10")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.tool_options_of("dimension")["style"]["scale"], "1:10"); + 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"); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 22b023863..d6a3c7b88 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -17,6 +17,7 @@ mod color_picker; mod command; pub mod confirm; pub mod corners; +pub mod dimension; mod document_setup; pub mod dxf_import; pub mod dxf_options; @@ -212,6 +213,7 @@ macro_rules! registry { } registry! { + DimensionOptions: [dimension::KIND] => dimension::SPEC, NewDocument: [new_document::KIND] => new_document::SPEC, Shape: ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment", "rectangularGrid", "polarGrid"] => shapes::SPEC, Transform: ["move", "rotate", "scale", "reflect", "shear"] => transform::SPEC, diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 019b7294d..0bad4c320 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4808,6 +4808,45 @@ mark-style Japanese Japonské Softness Měkkost 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 + 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 +@msg Dimension point is outside the canvas Bod kóty leží mimo plátno +@msg Dimension size is outside its supported range Velikost kóty je mimo podporovaný rozsah +@msg First measured segment no longer exists První měřený segment už neexistuje +@msg Invalid dimension distance Neplatná vzdálenost kóty +@msg Invalid dimension font name Neplatný název písma kóty +@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 +@msg Scale ratio must be between 1:1000000 and 1000000:1 Poměr měřítka musí být mezi 1:1000000 a 1000000:1 +@msg Second measured segment no longer exists Druhý měřený segment už neexistuje +@msg Unknown dimension mode Neznámý režim kóty +@msg Unknown dimension style Neznámý styl kóty +@msg Unknown unit Neznámá jednotka # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Uzavře kruhové konce otevřených profilů. Boční řezy částečného otočení zůstávají otevřené. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9233e1a71..c1071c1cd 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4336,5 +4336,45 @@ theme Appearance Erscheinungsbild Ambient Light Umgebungslicht 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 + 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 +@msg Dimension point is outside the canvas Der Bemaßungspunkt liegt außerhalb der Arbeitsfläche +@msg Dimension size is outside its supported range Die Bemaßungsgröße liegt außerhalb des unterstützten Bereichs +@msg First measured segment no longer exists Das erste gemessene Segment existiert nicht mehr +@msg Invalid dimension distance Ungültiger Bemaßungsabstand +@msg Invalid dimension font name Ungültiger Schriftname für die Bemaßung +@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 +@msg Scale ratio must be between 1:1000000 and 1000000:1 Das Maßstabsverhältnis muss zwischen 1:1000000 und 1000000:1 liegen +@msg Second measured segment no longer exists Das zweite gemessene Segment existiert nicht mehr +@msg Unknown dimension mode Unbekannter Bemaßungsmodus +@msg Unknown dimension style Unbekannter Bemaßungsstil +@msg Unknown unit Unbekannte Einheit # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Schließt die kreisförmigen Enden offener Profile. Bei einer Teildrehung bleiben die Schnittseiten offen. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3878554fc..ed0f74ddb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4749,6 +4749,45 @@ mark-style Japanese Japonesas Softness Suavidad 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). +@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 +@msg Dimension point is outside the canvas El punto de la cota está fuera del lienzo +@msg Dimension size is outside its supported range El tamaño de la cota está fuera del intervalo admitido +@msg First measured segment no longer exists El primer segmento medido ya no existe +@msg Invalid dimension distance Distancia de cota no válida +@msg Invalid dimension font name Nombre de fuente de cota no válido +@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 +@msg Scale ratio must be between 1:1000000 and 1000000:1 La relación de escala debe estar entre 1:1000000 y 1000000:1 +@msg Second measured segment no longer exists El segundo segmento medido ya no existe +@msg Unknown dimension mode Modo de cota desconocido +@msg Unknown dimension style Estilo de cota desconocido +@msg Unknown unit Unidad desconocida # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Cierra los extremos circulares de los perfiles abiertos. Los giros parciales mantienen abiertos sus lados de corte. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f7b03083e..3fdd5eee9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4809,5 +4809,43 @@ theme Appearance Apparence Ambient Light Lumière ambiante 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 + 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 +@msg Dimension point is outside the canvas Le point de cote est en dehors de la zone de travail +@msg Dimension size is outside its supported range La taille de la cote est hors de la plage prise en charge +@msg First measured segment no longer exists Le premier segment mesuré n’existe plus +@msg Invalid dimension distance Distance de cote non valide +@msg Invalid dimension font name Nom de police de cote non valide +@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 +@msg Scale ratio must be between 1:1000000 and 1000000:1 Le rapport d’échelle doit être compris entre 1:1000000 et 1000000:1 +@msg Second measured segment no longer exists Le second segment mesuré n’existe plus +@msg Unknown dimension mode Mode de cote inconnu +@msg Unknown dimension style Style de cote inconnu +@msg Unknown unit Unité inconnue # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Ferme les extrémités circulaires des profils ouverts. Les rotations partielles conservent leurs faces de coupe ouvertes. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d3c5c046a..cb1f374b9 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4776,5 +4776,43 @@ imageTrace Outline Körvonal Ambient Light Környezeti fény 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 + 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 +@msg Dimension point is outside the canvas A méretvonal pontja a vásznon kívül esik +@msg Dimension size is outside its supported range A méret a támogatott tartományon kívül esik +@msg First measured segment no longer exists Az első mért szakasz már nem létezik +@msg Invalid dimension distance Érvénytelen méretezési távolság +@msg Invalid dimension font name Érvénytelen méretezési betűtípusnév +@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 +@msg Scale ratio must be between 1:1000000 and 1000000:1 A lépték aránya 1:1000000 és 1000000:1 között lehet +@msg Second measured segment no longer exists A második mért szakasz már nem létezik +@msg Unknown dimension mode Ismeretlen méretezési mód +@msg Unknown dimension style Ismeretlen méretezési stílus +@msg Unknown unit Ismeretlen mértékegység # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Nyitott profilok kör alakú végeinek lezárása. Részleges körforgatás esetén a vágott oldalak nyitva maradnak. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0815f179c..eb8827582 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4381,5 +4381,45 @@ lighting Light Luce Ambient Light Luce ambientale 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 + 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 +@msg Dimension point is outside the canvas Il punto della quota è fuori dalla tela +@msg Dimension size is outside its supported range La dimensione della quota è fuori dall’intervallo supportato +@msg First measured segment no longer exists Il primo segmento misurato non esiste più +@msg Invalid dimension distance Distanza della quota non valida +@msg Invalid dimension font name Nome del font della quota non valido +@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 +@msg Scale ratio must be between 1:1000000 and 1000000:1 Il rapporto di scala deve essere compreso tra 1:1000000 e 1000000:1 +@msg Second measured segment no longer exists Il secondo segmento misurato non esiste più +@msg Unknown dimension mode Modalità di quota sconosciuta +@msg Unknown dimension style Stile di quota sconosciuto +@msg Unknown unit Unità sconosciuta # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Chiude le estremità circolari dei profili aperti. Le rotazioni parziali mantengono aperti i lati di taglio. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index eae3a98b4..e1c72fcff 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4374,6 +4374,47 @@ lighting Light ライト Softness 柔らかさ 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). + 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 つ選択してください +@msg Dimension point is outside the canvas 寸法の点がカンバスの外にあります +@msg Dimension size is outside its supported range 寸法のサイズがサポート範囲外です +@msg First measured segment no longer exists 最初の測定セグメントはもう存在しません +@msg Invalid dimension distance 寸法の距離が無効です +@msg Invalid dimension font name 寸法のフォント名が無効です +@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) +@msg Scale ratio must be between 1:1000000 and 1000000:1 縮尺の比率は 1:1000000 から 1000000:1 の間にしてください +@msg Second measured segment no longer exists 2 番目の測定セグメントはもう存在しません +@msg Unknown dimension mode 寸法のモードが不明です +@msg Unknown dimension style 寸法のスタイルが不明です +@msg Unknown unit 単位が不明です # 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/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0cdf26eca..e84fdfe61 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4345,5 +4345,45 @@ lighting Light 광원 Ambient 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 선택한 모든 가장자리 + 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이 아닌 각도 변 두 개를 선택하십시오 +@msg Dimension point is outside the canvas 치수 점이 캔버스 밖에 있습니다 +@msg Dimension size is outside its supported range 치수 크기가 지원 범위를 벗어났습니다 +@msg First measured segment no longer exists 첫 번째 측정 세그먼트가 더 이상 없습니다 +@msg Invalid dimension distance 잘못된 치수 거리 +@msg Invalid dimension font name 잘못된 치수 글꼴 이름 +@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) +@msg Scale ratio must be between 1:1000000 and 1000000:1 축척 비율은 1:1000000에서 1000000:1 사이여야 합니다 +@msg Second measured segment no longer exists 두 번째 측정 세그먼트가 더 이상 없습니다 +@msg Unknown dimension mode 알 수 없는 치수 모드 +@msg Unknown dimension style 알 수 없는 치수 스타일 +@msg Unknown unit 알 수 없는 단위 # 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/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 94b26c614..c305fbfb4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4651,6 +4651,43 @@ mark-style Japanese Japońskie Softness Miękkość 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 + 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 +@msg Dimension point is outside the canvas Punkt wymiaru leży poza obszarem roboczym +@msg Dimension size is outside its supported range Rozmiar wymiaru jest poza obsługiwanym zakresem +@msg First measured segment no longer exists Pierwszy mierzony odcinek już nie istnieje +@msg Invalid dimension distance Nieprawidłowa odległość wymiaru +@msg Invalid dimension font name Nieprawidłowa nazwa czcionki wymiaru +@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 +@msg Scale ratio must be between 1:1000000 and 1000000:1 Współczynnik skali musi mieścić się między 1:1000000 a 1000000:1 +@msg Second measured segment no longer exists Drugi mierzony odcinek już nie istnieje +@msg Unknown dimension mode Nieznany tryb wymiaru +@msg Unknown dimension style Nieznany styl wymiaru +@msg Unknown unit Nieznana jednostka # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Zamknij okrągłe końce otwartych profili. Przy częściowym obrocie boki przekroju pozostają otwarte. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index df7236bb2..d10903801 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3763,6 +3763,10 @@ axis Both Ambos Softness Suavidade 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 # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Fecha as extremidades circulares dos perfis abertos. Giros parciais mantêm as faces de corte abertas. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c2169243b..8b5ac2194 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4377,5 +4377,45 @@ lighting Light Свет Ambient 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 Все выделенные края + 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 Выберите две стороны угла ненулевой длины +@msg Dimension point is outside the canvas Точка размера находится за пределами холста +@msg Dimension size is outside its supported range Размер выходит за поддерживаемые пределы +@msg First measured segment no longer exists Первый измеряемый сегмент больше не существует +@msg Invalid dimension distance Недопустимое расстояние размера +@msg Invalid dimension font name Недопустимое имя шрифта размера +@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 +@msg Scale ratio must be between 1:1000000 and 1000000:1 Соотношение масштаба должно быть от 1:1000000 до 1000000:1 +@msg Second measured segment no longer exists Второй измеряемый сегмент больше не существует +@msg Unknown dimension mode Неизвестный режим размера +@msg Unknown dimension style Неизвестный стиль размера +@msg Unknown unit Неизвестная единица # 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/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 98c660baf..c1febb513 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4361,5 +4361,45 @@ lighting Light Світло Ambient 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 Усі виділені краї + 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 Виберіть дві сторони кута ненульової довжини +@msg Dimension point is outside the canvas Точка розміру знаходиться за межами полотна +@msg Dimension size is outside its supported range Розмір виходить за підтримувані межі +@msg First measured segment no longer exists Перший виміряний сегмент більше не існує +@msg Invalid dimension distance Неприпустима відстань розміру +@msg Invalid dimension font name Неприпустима назва шрифту розміру +@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 +@msg Scale ratio must be between 1:1000000 and 1000000:1 Співвідношення масштабу має бути від 1:1000000 до 1000000:1 +@msg Second measured segment no longer exists Другий виміряний сегмент більше не існує +@msg Unknown dimension mode Невідомий режим розміру +@msg Unknown dimension style Невідомий стиль розміру +@msg Unknown unit Невідома одиниця # 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/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f69845065..8824ebaa6 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4362,6 +4362,43 @@ theme Appearance Giao diện Softness Độ mềm 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 + 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 +@msg Dimension point is outside the canvas Điểm kích thước nằm ngoài vùng vẽ +@msg Dimension size is outside its supported range Kích thước nằm ngoài phạm vi được hỗ trợ +@msg First measured segment no longer exists Đoạn được đo thứ nhất không còn tồn tại +@msg Invalid dimension distance Khoảng cách kích thước không hợp lệ +@msg Invalid dimension font name Tên phông chữ của kích thước không hợp lệ +@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 +@msg Scale ratio must be between 1:1000000 and 1000000:1 Tỷ lệ phải nằm trong khoảng 1:1000000 đến 1000000:1 +@msg Second measured segment no longer exists Đoạn được đo thứ hai không còn tồn tại +@msg Unknown dimension mode Chế độ kích thước không xác định +@msg Unknown dimension style Kiểu kích thước không xác định +@msg Unknown unit Đơn vị không xác định # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Đóng các đầu tròn của biên dạng mở. Khi xoay một phần, các mặt cắt bên vẫn mở. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5eedc8b56..552d879fb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3957,6 +3957,22 @@ lighting Light 光源 Softness 柔和度 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). # 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 d305d8162..2a06ad98c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3741,6 +3741,24 @@ lighting Light 光源 Softness 柔和度 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). + X: X: + Y: Y: # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 封閉開放輪廓的圓形端部。部分旋轉的切割側面保持開放。 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7f32ab483..0dea3fcee 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2396,6 +2396,7 @@ 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/toolbar.rs b/crates/ui-egui/src/toolbar.rs index e6b31a241..9b72dfd8b 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -58,6 +58,7 @@ pub const BASIC: &[(&str, &[&[&str]])] = &[ &["width", "warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"], &["rotate", "reflect", "scale", "shear", "reshape", "freeTransform", "puppetWarp"], &["shapeBuilder", "livePaintBucket", "livePaintSelection", "blend"], + &["dimension"], ], ), ("Type", &[&["areaType", "typeOnPath", "verticalAreaType", "verticalTypeOnPath"], &["type", "verticalType", "touchType"]]), @@ -129,6 +130,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Before the flyout: one torn off this frame floats from the next (its bar mustn't be in two // layers in one frame). floating(app, ui.ctx()); + crate::dialogs::dimension::floating(app, ui.ctx()); flyout(app, ui.ctx()); crate::edit_toolbar::drawer(app, ui.ctx()); } @@ -324,6 +326,7 @@ fn flyout_press(ui: &Ui, resp: &egui::Response, rect: egui::Rect) -> Option Result { match tool { + "dimension" => crate::dialogs::dimension::open(app), "hand" => app.run("view.fitArtboard", json!({})), "zoom" => app.run("view.actualSize", json!({})), "rotate" | "scale" | "reflect" | "shear" | "selection" | "directSelection" | "groupSelection" => { @@ -372,6 +375,9 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result