diff --git a/ASSETS.md b/ASSETS.md
index d4804d903..11e5602ea 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -117,6 +117,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-cube.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dash-align.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dash-exact.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-dimension.svg` | Osama Amin | Original work, drawn for VectorCraft: a dimension line with arrowheads between two extension lines | MIT OR Apache-2.0 | |
| `assets/icons/dc-dir-off.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dir-on.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-direct.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
diff --git a/assets/icons/dc-dimension.svg b/assets/icons/dc-dimension.svg
new file mode 100644
index 000000000..9a00f9d89
--- /dev/null
+++ b/assets/icons/dc-dimension.svg
@@ -0,0 +1 @@
+
diff --git a/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..5ca27311e
--- /dev/null
+++ b/crates/doc/src/dimension.rs
@@ -0,0 +1,573 @@
+//! Persistent dimension recipes. Children are ordinary paths and type for all renderers/exporters.
+use crate::{Document, NodeId, Unit};
+use serde::{Deserialize, Serialize};
+use vectorcraft_geom::{Affine, Point, Vec2};
+
+/// What a dimension measures.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum DimensionMode {
+ #[default]
+ Linear,
+ Angular,
+ Radius,
+ Diameter,
+}
+
+impl DimensionMode {
+ pub fn id(self) -> &'static str {
+ match self {
+ Self::Linear => "linear",
+ Self::Angular => "angular",
+ Self::Radius => "radius",
+ Self::Diameter => "diameter",
+ }
+ }
+}
+
+/// The unit a length is labelled in.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum LengthUnit {
+ #[default]
+ Pt,
+ Pc,
+ Px,
+ In,
+ Mm,
+ Cm,
+ M,
+ Ft,
+ Yd,
+}
+
+impl LengthUnit {
+ pub const ALL: [LengthUnit; 9] = [
+ LengthUnit::In,
+ LengthUnit::Mm,
+ LengthUnit::Pt,
+ LengthUnit::Pc,
+ LengthUnit::Px,
+ LengthUnit::Cm,
+ LengthUnit::M,
+ LengthUnit::Ft,
+ LengthUnit::Yd,
+ ];
+ pub fn unit(self) -> Unit {
+ match self {
+ Self::Pt => Unit::Points,
+ Self::Pc => Unit::Picas,
+ Self::Px => Unit::Pixels,
+ Self::In => Unit::Inches,
+ Self::Mm => Unit::Millimeters,
+ Self::Cm => Unit::Centimeters,
+ Self::M => Unit::Meters,
+ Self::Ft => Unit::Feet,
+ Self::Yd => Unit::Yards,
+ }
+ }
+ /// The suffix written after a length.
+ pub fn suffix(self) -> &'static str {
+ match self {
+ Self::Pt => "pt",
+ Self::Pc => "pc",
+ Self::Px => "px",
+ Self::In => "in",
+ Self::Mm => "mm",
+ Self::Cm => "cm",
+ Self::M => "m",
+ Self::Ft => "ft",
+ Self::Yd => "yd",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum LineType {
+ #[default]
+ Solid,
+ Dashed,
+ Dotted,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum ArrowStyle {
+ #[default]
+ Filled,
+ Open,
+ Tick,
+ None,
+}
+
+/// Where the label sits across the dimension line.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum TextPosition {
+ /// On the side of the dimension line away from the measured object.
+ #[default]
+ Away,
+ /// Between the dimension line and the measured object.
+ Between,
+}
+
+/// Where the label sits along the dimension line.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum TextAlignment {
+ Left,
+ #[default]
+ Center,
+ Right,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct DimensionOptions {
+ pub units: LengthUnit,
+ pub precision: usize,
+ /// Drawing : real size. 1:10 multiplies the drawn length by ten.
+ pub scale: String,
+ pub hide_units: bool,
+ pub line_weight: f64,
+ pub line_type: LineType,
+ pub line_color: [u8; 3],
+ pub arrow_style: ArrowStyle,
+ pub arrow_scale: f64,
+ pub extension_weight: f64,
+ pub extension_type: LineType,
+ 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: TextPosition,
+ pub text_alignment: TextAlignment,
+ pub text_offset: f64,
+}
+
+impl Default for DimensionOptions {
+ fn default() -> Self {
+ // The document's own type defaults: a font every build has, black, in points.
+ let type_style = crate::CharStyle::default();
+ Self {
+ units: LengthUnit::Pt,
+ precision: 2,
+ scale: "1:1".into(),
+ hide_units: false,
+ line_weight: 0.5,
+ line_type: LineType::Solid,
+ line_color: [0, 0, 0],
+ arrow_style: ArrowStyle::Filled,
+ arrow_scale: 100.0,
+ extension_weight: 0.5,
+ extension_type: LineType::Solid,
+ extension_color: [0, 0, 0],
+ extension_offset: 4.0,
+ extension_length: 6.0,
+ hide_extensions: false,
+ font_family: type_style.font_family,
+ font_style: type_style.font_style,
+ font_size: type_style.size,
+ text_color: [0, 0, 0],
+ text_position: TextPosition::Away,
+ text_alignment: TextAlignment::Center,
+ text_offset: 4.0,
+ }
+ }
+}
+
+impl DimensionOptions {
+ /// Options given to a command or a tool: unknown keys are refused there, while files load
+ /// tolerantly so a newer version's options don't stop a document opening.
+ pub fn from_params(v: serde_json::Value) -> Result {
+ if let (Some(given), Ok(serde_json::Value::Object(known))) = (v.as_object(), serde_json::to_value(Self::default()))
+ && let Some(k) = given.keys().find(|k| !known.contains_key(*k))
+ {
+ return Err(format!("Unknown dimension option {k}"));
+ }
+ let o: Self = serde_json::from_value(v).map_err(|e| e.to_string())?;
+ o.validate()?;
+ Ok(o)
+ }
+ 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.precision > 6 {
+ return Err("Choose a 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 self.font_family.is_empty() || self.font_family.len() > 256 || self.font_style.len() > 128 {
+ return Err("Invalid dimension font name".into());
+ }
+ Ok(())
+ }
+ /// `value` in points for a length, in degrees for an angle.
+ pub fn label(&self, value: f64, angle: bool) -> Result {
+ self.validate()?;
+ let value = if angle { value } else { value * self.factor()? / self.units.unit().points() };
+ if !value.is_finite() {
+ return Err("Non-finite dimension".into());
+ }
+ let suffix = if self.hide_units {
+ ""
+ } else if angle {
+ "°"
+ } else {
+ self.units.suffix()
+ };
+ 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")]
+pub struct SegmentRef {
+ pub node: NodeId,
+ pub subpath: usize,
+ pub segment: usize,
+}
+
+impl SegmentRef {
+ /// The segment's two end points.
+ pub fn endpoints(&self, doc: &Document) -> Option<(Point, Point)> {
+ let sp = doc.node(self.node)?.path_data()?.subpaths.get(self.subpath)?;
+ if self.segment >= sp.segment_count() {
+ return None;
+ }
+ let seg = sp.segment(self.segment);
+ Some((seg.p0, seg.p3))
+ }
+ /// Whether the segment is a straight line.
+ pub fn is_straight(&self, doc: &Document) -> bool {
+ doc.node(self.node)
+ .and_then(|n| n.path_data())
+ .and_then(|p| p.subpaths.get(self.subpath))
+ .is_some_and(|sp| self.segment < sp.segment_count() && sp.segment_is_line(self.segment))
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "camelCase")]
+pub enum DimensionSource {
+ Segment {
+ edge: SegmentRef,
+ },
+ Angle {
+ first: SegmentRef,
+ second: SegmentRef,
+ },
+ Circle {
+ node: NodeId,
+ },
+ /// One segment that is a perfect arc.
+ Arc {
+ edge: SegmentRef,
+ },
+ Points {
+ a: Option,
+ b: Option,
+ vertex: Option,
+ },
+}
+
+impl DimensionSource {
+ pub fn nodes(&self) -> Vec {
+ match self {
+ Self::Segment { edge } | Self::Arc { edge } => vec![edge.node],
+ Self::Angle { first, second } => vec![first.node, second.node],
+ Self::Circle { node } => vec![*node],
+ Self::Points { a, b, vertex } => [a, b, vertex].into_iter().filter_map(|p| p.as_ref().map(|r| r.node)).collect(),
+ }
+ }
+ /// 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")]
+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")]
+pub struct Dimension {
+ pub mode: DimensionMode,
+ 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,
+ /// How far a linked dimension was moved on its own since the last refresh: the refresh turns
+ /// it into a new placement unless the measured art moved too.
+ #[serde(skip)]
+ pub moved: Option,
+}
+
+impl Dimension {
+ /// A point on the drawn dimension line (linear), arc (angular) or radius line.
+ pub fn handle(&self) -> Point {
+ match self.mode {
+ DimensionMode::Linear => self.a.midpoint(self.b) + self.normal() * self.offset,
+ DimensionMode::Angular => self.vertex + self.bisector() * self.offset.abs().max(12.0),
+ DimensionMode::Radius | DimensionMode::Diameter => self.b,
+ }
+ }
+ /// The left normal of a → b.
+ pub fn normal(&self) -> Vec2 {
+ let u = self.b - self.a;
+ let len = u.hypot();
+ if len > 1e-9 { Vec2::new(-u.y, u.x) / len } else { Vec2::new(0.0, 1.0) }
+ }
+ fn bisector(&self) -> Vec2 {
+ let start = (self.a - self.vertex).atan2();
+ Vec2::from_angle(start + self.angle() * 0.5)
+ }
+ /// Places the dimension through `p`: the line's distance from the art (linear), the arc's
+ /// radius (angular) or the label's direction (radial).
+ pub fn place(&mut self, p: Point) {
+ match self.mode {
+ DimensionMode::Linear => self.offset = (p - self.a).dot(self.normal()),
+ DimensionMode::Angular => self.offset = p.distance(self.vertex).max(12.0),
+ DimensionMode::Radius | DimensionMode::Diameter => {
+ if p.distance(self.a) > 1e-6 {
+ let r = self.a.distance(self.b);
+ let u = p - self.a;
+ self.b = self.a + u / u.hypot() * r;
+ }
+ }
+ }
+ }
+ pub fn transform(&mut self, xf: Affine) {
+ if self.source.is_some() {
+ // The art it measures decides where its ends are: record the move, and the refresh
+ // after this edit turns it into a new placement unless the art moved with it.
+ let at = self.handle();
+ self.moved = Some((xf * at) - at + self.moved.unwrap_or_default());
+ return;
+ }
+ self.a = xf * self.a;
+ self.b = xf * self.b;
+ self.vertex = xf * self.vertex;
+ if self.mode == DimensionMode::Linear {
+ self.length = self.a.distance(self.b);
+ }
+ }
+ 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) = edge.endpoints(doc).ok_or("Measured segment no longer exists")?;
+ self.a = a;
+ self.b = b;
+ self.length = a.distance(b);
+ }
+ Some(DimensionSource::Angle { first, second }) => {
+ for edge in [first, second] {
+ if !edge.is_straight(doc) {
+ return Err("Angles require two straight segments".into());
+ }
+ }
+ let (a, b) = first.endpoints(doc).ok_or("First measured segment no longer exists")?;
+ let (c, d) = second.endpoints(doc).ok_or("Second measured segment no longer exists")?;
+ let 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 circle or a perfect arc")?;
+ self.set_radial(center, radius);
+ }
+ Some(DimensionSource::Arc { edge }) => {
+ let (center, radius) = arc(doc, edge).ok_or("Choose a circle or a perfect arc")?;
+ self.set_radial(center, radius);
+ }
+ None => {}
+ }
+ self.validate()
+ }
+ fn set_radial(&mut self, center: Point, radius: f64) {
+ let direction = self.b - self.a;
+ let dir = if direction.hypot() > 1e-9 { direction / direction.hypot() } else { Vec2::new(1.0, 0.0) };
+ self.a = center;
+ self.b = center + dir * radius;
+ self.length = radius;
+ }
+ pub fn validate(&self) -> Result<(), String> {
+ self.options.validate()?;
+ for p in [self.a, self.b, self.vertex] {
+ 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 self.a.distance(self.b) < 1e-6 {
+ return Err("Choose two different points".into());
+ }
+ if self.mode == DimensionMode::Angular && (self.a.distance(self.vertex) < 1e-6 || self.b.distance(self.vertex) < 1e-6) {
+ return Err("Choose two nonzero angle arms".into());
+ }
+ Ok(())
+ }
+ 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 = match self.mode {
+ DimensionMode::Angular => self.angle().abs().to_degrees(),
+ DimensionMode::Radius => self.a.distance(self.b),
+ DimensionMode::Diameter => self.a.distance(self.b) * 2.0,
+ DimensionMode::Linear => self.a.distance(self.b),
+ };
+ self.options.label(value, self.mode == DimensionMode::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))
+}
+
+/// A curved segment that follows one circle (a perfect arc): its centre and radius.
+pub fn arc(doc: &Document, edge: &SegmentRef) -> Option<(Point, f64)> {
+ use vectorcraft_geom::kurbo::ParamCurve;
+ let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?;
+ if edge.segment >= sp.segment_count() || sp.segment_is_line(edge.segment) {
+ return None;
+ }
+ let seg = sp.segment(edge.segment);
+ let (p, q, r) = (seg.p0, seg.eval(0.5), seg.p3);
+ // The circle through the ends and the middle of the segment.
+ let d = 2.0 * (p.x * (q.y - r.y) + q.x * (r.y - p.y) + r.x * (p.y - q.y));
+ if d.abs() < 1e-9 {
+ return None;
+ }
+ let sq = |a: Point| a.x * a.x + a.y * a.y;
+ let center = Point::new(
+ (sq(p) * (q.y - r.y) + sq(q) * (r.y - p.y) + sq(r) * (p.y - q.y)) / d,
+ (sq(p) * (r.x - q.x) + sq(q) * (p.x - r.x) + sq(r) * (q.x - p.x)) / d,
+ );
+ let radius = center.distance(p);
+ if !radius.is_finite() || !(1e-6..=1e8).contains(&radius) {
+ return None;
+ }
+ for t in [0.125, 0.25, 0.375, 0.625, 0.75, 0.875] {
+ if (seg.eval(t).distance(center) - radius).abs() > radius * 0.002 {
+ return None;
+ }
+ }
+ Some((center, radius))
+}
+
+/// The two straight segments that meet at a corner anchor, if both are straight.
+pub fn corner(doc: &Document, node: NodeId, subpath: usize, anchor: usize) -> Option<(SegmentRef, SegmentRef)> {
+ let sp = doc.node(node)?.path_data()?.subpaths.get(subpath)?;
+ let count = sp.segment_count();
+ let n = sp.anchors.len();
+ if count < 2 || anchor >= n {
+ return None;
+ }
+ // Segment i runs from anchor i to anchor i + 1 (wrapping on closed paths).
+ let before = if anchor > 0 {
+ anchor - 1
+ } else if sp.closed {
+ count - 1
+ } else {
+ return None;
+ };
+ let after = anchor;
+ if after >= count {
+ return None;
+ }
+ let first = SegmentRef { node, subpath, segment: before };
+ let second = SegmentRef { node, subpath, segment: after };
+ (first.is_straight(doc) && second.is_straight(doc)).then_some((first, second))
+}
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index 7d7a33e52..9c2670329 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;
@@ -686,6 +687,10 @@ pub struct Document {
/// state, never saved.
#[serde(skip)]
pub puppet: Option>,
+ /// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this
+ /// is set, so documents without any pay nothing for them.
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub dimensions: bool,
/// Where each node is in the tree, for lookups by id ([`id_index`]): a cache, never saved.
#[serde(skip)]
index: id_index::IdIndex,
@@ -766,6 +771,7 @@ impl Document {
print_setup: None,
assets: vec![],
puppet: None,
+ dimensions: false,
index: Default::default(),
};
let id = d.alloc_id();
@@ -1399,6 +1405,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..efc3e5fc7
--- /dev/null
+++ b/crates/engine/src/cmd/dimension.rs
@@ -0,0 +1,369 @@
+//! 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::{ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, TextAlignment, TextPosition};
+use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, Dash, Document, Node, NodeId, NodeKind, TextObject};
+use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
+
+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}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}}, offset?: pt, options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits",
+ has_doc,
+ create
+ ),
+ cmd!(
+ "dimension.update",
+ "Update Dimensions",
+ [],
+ None,
+ "{ids?: [dimension ids] (default selection), options?: dimension options, offset?: pt} restyle or move existing dimensions → {ids}",
+ has_doc,
+ update
+ ),
+ cmd!(
+ "dimension.applyToAll",
+ "Apply to All",
+ [],
+ None,
+ "{id?: dimension id (default the first selected dimension)} gives every dimension in the document that dimension's style (its scale too), as one undo step → {ids}",
+ has_doc,
+ apply_to_all
+ ),
+ ]
+}
+
+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!({})));
+ DimensionOptions::from_params(value).map_err(error)
+}
+
+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),
+ }
+}
+
+fn recipe(s: &Session, p: &Value) -> Result {
+ let mode: DimensionMode =
+ p.get("mode").map(|v| serde_json::from_value(v.clone()).map_err(|_| error("Unknown dimension mode"))).transpose()?.unwrap_or_default();
+ let source: Option = p.get("source").map(|v| serde_json::from_value(v.clone()).map_err(error)).transpose()?;
+ if let Some(source) = &source {
+ let doc = &s.doc()?.doc;
+ 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"));
+ }
+ }
+ let compatible = matches!(
+ (mode, source),
+ (DimensionMode::Linear, DimensionSource::Segment { .. })
+ | (DimensionMode::Angular, DimensionSource::Angle { .. })
+ | (DimensionMode::Radius | DimensionMode::Diameter, DimensionSource::Circle { .. } | DimensionSource::Arc { .. })
+ | (DimensionMode::Linear | DimensionMode::Angular, DimensionSource::Points { .. })
+ );
+ if !compatible {
+ return Err(error("Dimension mode does not match its source"));
+ }
+ }
+ let a = if source.is_none() || p.get("a").is_some() { point(p, "a")? } else { Point::ZERO };
+ let b = if source.is_none() || p.get("b").is_some() { point(p, "b")? } else { Point::new(1.0, 0.0) };
+ let vertex = if mode == DimensionMode::Angular && (source.is_none() || matches!(source, Some(DimensionSource::Points { .. }))) {
+ point(p, "vertex")?
+ } else {
+ Point::ZERO
+ };
+ let mut d = Dimension { mode, a, b, vertex, offset: distance(p, 30.0)?, length: a.distance(b), options: options(s, p)?, source, moved: None };
+ d.resolve(&s.doc()?.doc).map_err(error)?;
+ Ok(d)
+}
+
+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 };
+ doc.dimensions = true;
+ 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 dimension_of(doc: &Document, id: NodeId) -> Result {
+ doc.node(id).and_then(|n| n.extras().dimension.as_deref()).cloned().ok_or_else(|| error("Select only dimensions"))
+}
+
+fn update(s: &mut Session, p: &Value) -> Result {
+ 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() > 10_000 {
+ return Err(error("Select dimensions to update"));
+ }
+ let o = if p.get("options").is_some() { Some(options(s, p)?) } else { None };
+ s.edit("Update Dimensions", |doc, _| {
+ for id in &ids {
+ if !doc.is_editable(*id) {
+ return Err(error("Dimension is locked or hidden"));
+ }
+ let mut dim = dimension_of(doc, *id)?;
+ 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 apply_to_all(s: &mut Session, p: &Value) -> Result {
+ let st = s.doc()?;
+ let from = match p.get("id") {
+ Some(v) => NodeId(v.as_u64().ok_or_else(|| error("id must be a dimension id"))?),
+ None => *st
+ .selection
+ .objects
+ .iter()
+ .find(|id| st.doc.node(**id).is_some_and(|n| n.extras().dimension.is_some()))
+ .ok_or_else(|| error("Select a dimension first"))?,
+ };
+ let o = dimension_of(&st.doc, from)?.options;
+ let mut ids = vec![];
+ st.doc.walk(|n| {
+ if n.extras().dimension.is_some() && st.doc.is_editable(n.id) {
+ ids.push(n.id);
+ }
+ });
+ s.edit("Apply to All", |doc, _| {
+ for id in &ids {
+ let mut dim = dimension_of(doc, *id)?;
+ dim.options = o.clone();
+ dim.resolve(doc).map_err(error)?;
+ replace(doc, *id, dim)?;
+ }
+ Ok(())
+ })?;
+ Ok(json!({"ids":ids}))
+}
+
+fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> {
+ let old: Vec = doc.node(id).and_then(Node::children).map(|ch| ch.iter().map(|n| n.id).collect()).unwrap_or_default();
+ let children = artwork(doc, &dim, &old)?;
+ 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 every edit, so dimensions and their undo history stay in step with the art they
+/// measure. Returns at once in documents without dimensions.
+pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> {
+ if !doc.dimensions {
+ return Ok(());
+ }
+ let mut dimensions = Vec::new();
+ doc.walk(|n| {
+ if let Some(d) = &n.extras().dimension {
+ dimensions.push((n.id, (**d).clone()));
+ }
+ });
+ doc.dimensions = !dimensions.is_empty();
+ for (id, old) in dimensions {
+ let mut next = old.clone();
+ let moved = next.moved.take();
+ if next.source.as_ref().is_some_and(|s| s.topology_changed(before, doc)) || next.resolve(doc).is_err() {
+ // A deleted/degenerate source has no valid measurement. Remove its annotation in
+ // this same undo step instead of keeping a silently wrong number.
+ doc.remove(id)?;
+ continue;
+ }
+ // Moved on its own (the art it measures stayed put): it keeps its new place.
+ if let Some(v) = moved
+ && (next.a, next.b, next.vertex) == (old.a, old.b, old.vertex)
+ {
+ next.place(next.handle() + v);
+ if next.resolve(doc).is_err() {
+ next = old.clone();
+ next.moved = None;
+ }
+ }
+ if next != old || before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next) {
+ replace(doc, id, next)?;
+ }
+ }
+ 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) } else { (o.line_color, o.line_weight, o.line_type) };
+ let mut a = Appearance::basic(Paint::None, paint(color), width);
+ if let Some(st) = a.stroke_mut() {
+ st.dash = match kind {
+ LineType::Dashed => Some(Dash { pattern: vec![width * 5.0, width * 3.0], ..Default::default() }),
+ LineType::Dotted => {
+ st.cap = vectorcraft_doc::LineCap::Round;
+ Some(Dash { pattern: vec![0.0, width * 3.0], ..Default::default() })
+ }
+ LineType::Solid => None,
+ };
+ if arrows {
+ let head = match o.arrow_style {
+ ArrowStyle::Filled => Some(Arrowhead::Triangle),
+ ArrowStyle::Open => Some(Arrowhead::TriangleOpen),
+ ArrowStyle::Tick => Some(Arrowhead::Slash),
+ ArrowStyle::None => None,
+ };
+ st.start_arrow = head;
+ st.end_arrow = head;
+ st.arrow_scale = (o.arrow_scale, o.arrow_scale);
+ st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip;
+ }
+ }
+ a
+}
+
+/// How far along the line (0 start, 0.5 middle, 1 end) the label sits, kept clear of the arrows.
+fn along(o: &DimensionOptions) -> f64 {
+ match o.text_alignment {
+ TextAlignment::Left => 0.2,
+ TextAlignment::Center => 0.5,
+ TextAlignment::Right => 0.8,
+ }
+}
+
+fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result>> {
+ d.validate().map_err(error)?;
+ let o = &d.options;
+ let label = d.label().map_err(error)?;
+ let mut paths: Vec<(BezPath, Appearance)> = Vec::new();
+ let mut angle = 0.0;
+ let text_at;
+ // Away from the object: past the dimension line; between: on the object's side of it.
+ let away = o.text_position == TextPosition::Away;
+ if d.mode == DimensionMode::Angular {
+ let start = (d.a - d.vertex).atan2();
+ let sweep = d.angle();
+ let r = d.offset.abs().max(12.0);
+ 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)));
+ }
+ }
+ let dir = Vec2::from_angle(start + sweep * along(o));
+ let gap = o.text_offset + o.font_size * 0.5;
+ text_at = d.vertex + dir * if away { r + gap } else { (r - gap).max(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 {
+ DimensionMode::Radius => (d.a, d.b),
+ DimensionMode::Diameter => (d.a - delta, d.b),
+ _ => (d.a + n * d.offset, d.b + n * d.offset),
+ };
+ // The side of the line facing away from the object.
+ let outward = match d.mode {
+ DimensionMode::Linear => {
+ if d.offset < 0.0 {
+ -n
+ } else {
+ n
+ }
+ }
+ _ => n,
+ };
+ if d.mode == DimensionMode::Linear && !o.hide_extensions {
+ for (p, q) in [(d.a, a), (d.b, b)] {
+ paths.push((line(p + outward * o.extension_offset, q + outward * o.extension_length), appearance(o, true, false)));
+ }
+ }
+ angle = u.atan2();
+ if !(-std::f64::consts::FRAC_PI_2..=std::f64::consts::FRAC_PI_2).contains(&angle) {
+ angle += std::f64::consts::PI;
+ }
+ let shift = o.text_offset + o.font_size * 0.5;
+ text_at = a.lerp(b, along(o)) + outward * if away { shift } else { -shift };
+ paths.push((line(a, b), appearance(o, false, true)));
+ }
+ 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);
+ // Centre the label's box on its anchor point, along the line.
+ let bounds = text.cached_bounds.unwrap_or_else(|| text.estimate_bounds());
+ let middle = Affine::translate(-bounds.center().to_vec2());
+ text.xf = Affine::translate(text_at.to_vec2()) * Affine::rotate(angle) * middle;
+ 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..a2cf5c956 100644
--- a/crates/engine/src/tooling.rs
+++ b/crates/engine/src/tooling.rs
@@ -453,6 +453,16 @@ 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") {
+ vectorcraft_doc::dimension::DimensionOptions::from_params(style.clone())?;
+ }
+ if let Some(mode) = values.get("mode")
+ && !mode.as_str().is_some_and(|m| ["linear", "angular", "radial", "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..f65a292c7
--- /dev/null
+++ b/crates/engine/tests/dimensions.rs
@@ -0,0 +1,391 @@
+//! 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, ViewInfo,
+ doc::{Document, NodeId, NodeKind},
+ tools::{Mods, PointerEvent, PointerKind, ToolKey},
+};
+
+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()
+}
+fn recipe(s: &Session, id: u64) -> vectorcraft_doc::dimension::Dimension {
+ (**s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().dimension.as_ref().unwrap()).clone()
+}
+fn ev(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
+ s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap();
+}
+
+#[test]
+fn dimension_length_scale_live_edit_undo_and_roundtrip() {
+ let mut s = session();
+ 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();
+ assert!(doc.dimensions, "saved with the document");
+ let mut reopened = Session::new();
+ reopened.add_document(doc, None);
+ run(&mut reopened, "select.set", json!({"ids":[line]}));
+ 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");
+}
+
+/// Documents without dimensions skip the refresh, and lose the flag when the last goes.
+#[test]
+fn documents_without_dimensions_skip_the_refresh() {
+ let mut s = session();
+ let line = edge(&mut s);
+ assert!(!s.doc().unwrap().doc.dimensions);
+ let id = dim(&mut s, line);
+ assert!(s.doc().unwrap().doc.dimensions);
+ run(&mut s, "select.set", json!({"ids":[id]}));
+ run(&mut s, "edit.clear", json!({}));
+ run(&mut s, "select.set", json!({"ids":[line]}));
+ run(&mut s, "object.move", json!({"dx":1,"dy":0}));
+ assert!(!s.doc().unwrap().doc.dimensions);
+}
+
+#[test]
+fn apply_to_all_restyles_every_dimension_in_one_undo_step() {
+ let mut s = session();
+ let line = edge(&mut s);
+ let first = dim(&mut s, line);
+ let second = run(&mut s, "dimension.create", json!({"a":[0,0],"b":[72,0],"options":{"units":"pt","precision":0}}))["id"].as_u64().unwrap();
+ assert_eq!(text(&s, second), "72 pt");
+ run(&mut s, "dimension.applyToAll", json!({"id":first}));
+ assert_eq!(text(&s, second), "10.00 in", "the first dimension's style, its scale too");
+ run(&mut s, "edit.undo", json!({}));
+ assert_eq!(text(&s, second), "72 pt");
+ assert!(s.execute("dimension.applyToAll", &json!({"id":line})).is_err(), "not a dimension");
+}
+
+#[test]
+fn dimension_angles_circles_arcs_and_curves() {
+ let mut s = session();
+ let a = run(
+ &mut s,
+ "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", "1.0 in"), ("diameter", "2.0 in")] {
+ let id = run(
+ &mut s,
+ "dimension.create",
+ json!({"mode":mode,"source":{"kind":"circle","node":circle},"options":{"units":"in","precision":1}}),
+ )["id"]
+ .as_u64()
+ .unwrap();
+ assert_eq!(text(&s, id), want);
+ }
+ // One quarter of the circle is a perfect arc.
+ let id = run(
+ &mut s,
+ "dimension.create",
+ json!({"mode":"radius","source":{"kind":"arc","edge":{"node":circle,"subpath":0,"segment":0}},"options":{"units":"in","precision":1}}),
+ )["id"]
+ .as_u64()
+ .unwrap();
+ assert_eq!(text(&s, id), "1.0 in");
+ // A curve that isn't an arc: no radius, and its length is measured end to end.
+ let curve = run(&mut s, "path.create", json!({"d":"M 0 0 C 0 100 100 100 100 0"}))["id"].as_u64().unwrap();
+ let arc = json!({"mode":"radius","source":{"kind":"arc","edge":{"node":curve,"subpath":0,"segment":0}}});
+ assert!(s.execute("dimension.create", &arc).is_err());
+ let id = run(
+ &mut s,
+ "dimension.create",
+ json!({"source":{"kind":"segment","edge":{"node":curve,"subpath":0,"segment":0}},"options":{"units":"pt","precision":0}}),
+ )["id"]
+ .as_u64()
+ .unwrap();
+ assert_eq!(text(&s, id), "100 pt");
+}
+
+#[test]
+fn dimension_rejects_bad_input_without_mutation() {
+ let mut s = session();
+ 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!({"a":[0,0],"b":[10,0],"options":{"units":"furlongs"}}),
+ json!({"a":[0,0],"b":[10,0],"options":{"textPosition":"sideways"}}),
+ json!({"a":[0,0],"b":[10,0],"mode":"spiral"}),
+ json!({"source":{"kind":"segment","edge":{"node":line,"subpath":0,"segment":18446744073709551615u64}}}),
+ json!({"mode":"angular","source":{"kind":"segment","edge":{"node":line,"subpath":0,"segment":0}}}),
+ ] {
+ 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);
+}
+
+/// Hover a segment and drag outwards: releasing places the dimension; Escape cancels the drag.
+#[test]
+fn dragging_out_from_a_segment_places_its_dimension() {
+ let mut s = session();
+ edge(&mut s);
+ s.select_tool("dimension", ViewInfo::default()).unwrap();
+ let before = s.doc().unwrap().doc.node_count();
+ ev(&mut s, PointerKind::Move, 130.0, 100.0);
+ ev(&mut s, PointerKind::Down, 130.0, 100.0);
+ ev(&mut s, PointerKind::Drag, 130.0, 70.0);
+ assert!(s.in_interaction());
+ s.tool_key(ToolKey::Escape, Mods::default(), ViewInfo::default()).unwrap();
+ assert_eq!(s.doc().unwrap().doc.node_count(), before);
+ assert!(!s.in_interaction());
+ ev(&mut s, PointerKind::Up, 130.0, 70.0);
+ ev(&mut s, PointerKind::Down, 130.0, 100.0);
+ ev(&mut s, PointerKind::Drag, 130.0, 80.0);
+ ev(&mut s, PointerKind::Drag, 130.0, 60.0);
+ ev(&mut s, PointerKind::Up, 130.0, 60.0);
+ assert!(!s.in_interaction());
+ assert!(s.doc().unwrap().doc.node_count() > before);
+ let id = s.doc().unwrap().selection.objects[0].0;
+ assert_eq!(text(&s, id), "72.00 pt");
+ assert!((recipe(&s, id).offset.abs() - 40.0).abs() < 1e-6, "placed where it was released");
+ run(&mut s, "edit.undo", json!({}));
+ assert_eq!(s.doc().unwrap().doc.node_count(), before);
+}
+
+/// Plain clicks pick two points, and a third click places the dimension.
+#[test]
+fn clicking_two_points_then_placing_measures_between_them() {
+ let mut s = session();
+ s.select_tool("dimension", ViewInfo::default()).unwrap();
+ for (x, y) in [(300.0, 300.0), (372.0, 300.0)] {
+ ev(&mut s, PointerKind::Down, x, y);
+ ev(&mut s, PointerKind::Up, x, y);
+ }
+ ev(&mut s, PointerKind::Move, 336.0, 330.0);
+ assert!(s.in_interaction());
+ ev(&mut s, PointerKind::Down, 336.0, 330.0);
+ ev(&mut s, PointerKind::Up, 336.0, 330.0);
+ assert!(!s.in_interaction());
+ let id = s.doc().unwrap().selection.objects[0].0;
+ assert_eq!(text(&s, id), "72.00 pt");
+}
+
+/// Dragging out from a corner of two straight segments measures the angle between them.
+#[test]
+fn dragging_out_from_a_corner_measures_its_angle() {
+ let mut s = session();
+ run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":50}));
+ s.select_tool("dimension", ViewInfo::default()).unwrap();
+ s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"mode":"angular"}})).unwrap();
+ ev(&mut s, PointerKind::Move, 100.0, 100.0);
+ ev(&mut s, PointerKind::Down, 100.0, 100.0);
+ ev(&mut s, PointerKind::Drag, 120.0, 120.0);
+ ev(&mut s, PointerKind::Drag, 130.0, 130.0);
+ ev(&mut s, PointerKind::Up, 130.0, 130.0);
+ let id = s.doc().unwrap().selection.objects[0].0;
+ assert_eq!(text(&s, id), "90.00°");
+}
+
+/// Dragging out from a circle gives its radius; dragging inwards, its diameter.
+#[test]
+fn dragging_from_a_circle_gives_its_radius_or_diameter() {
+ let mut s = session();
+ run(&mut s, "shape.ellipse", json!({"x":100,"y":100,"width":144,"height":144}));
+ s.select_tool("dimension", ViewInfo::default()).unwrap();
+ s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"mode":"radial","style":{"units":"in","precision":0}}})).unwrap();
+ for ((x0, y0), (x1, y1), want) in [((244.0, 172.0), (280.0, 172.0), "1 in"), ((244.0, 172.0), (200.0, 172.0), "2 in")] {
+ ev(&mut s, PointerKind::Move, x0, y0);
+ ev(&mut s, PointerKind::Down, x0, y0);
+ ev(&mut s, PointerKind::Drag, (x0 + x1) / 2.0, y0);
+ ev(&mut s, PointerKind::Drag, x1, y1);
+ ev(&mut s, PointerKind::Up, x1, y1);
+ let id = s.doc().unwrap().selection.objects[0].0;
+ assert_eq!(text(&s, id), want);
+ }
+}
+
+#[test]
+fn dimension_anchor_links_and_native_file_survive_source_moves() {
+ let mut s = session();
+ 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");
+}
+
+/// A linked dimension moved on its own keeps its new place when its art changes later; moved with
+/// its art, it keeps its distance from it.
+#[test]
+fn a_moved_linked_dimension_stays_where_it_was_dropped() {
+ let mut s = session();
+ let line = edge(&mut s);
+ let id = dim(&mut s, line);
+ let offset = recipe(&s, id).offset;
+ run(&mut s, "select.set", json!({"ids":[id]}));
+ run(&mut s, "object.move", json!({"dx":0,"dy":-20}));
+ let moved = recipe(&s, id).offset;
+ assert!((moved - offset).abs() - 20.0 < 1e-6 && (moved - offset).abs() > 19.0, "{offset} → {moved}");
+ run(&mut s, "select.set", json!({"ids":[line]}));
+ run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
+ assert!((recipe(&s, id).offset - moved).abs() < 1e-6, "no jump back");
+ run(&mut s, "select.set", json!({"ids":[line, id]}));
+ run(&mut s, "object.move", json!({"dx":0,"dy":50}));
+ assert!((recipe(&s, id).offset - moved).abs() < 1e-6, "moved together");
+}
+
+#[test]
+fn dimension_options_are_persistent_and_invalid_updates_report_errors() {
+ let mut s = session();
+ s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"style":{"scale":"10:1","units":"mm","precision":3},"mode":"radial"}})).unwrap();
+ assert_eq!(s.tool_options_of("dimension")["style"]["scale"], "10:1");
+ assert!(s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"style":{"scale":"1:0"}}})).is_err());
+ let o: vectorcraft_doc::dimension::DimensionOptions = serde_json::from_value(s.tool_options_of("dimension")["style"].clone()).unwrap();
+ assert_eq!(o.label(72.0, false).unwrap(), "2.540 mm");
+ let picas: vectorcraft_doc::dimension::DimensionOptions = serde_json::from_value(json!({"units":"pc","precision":1})).unwrap();
+ assert_eq!(picas.label(72.0, false).unwrap(), "6.0 pc");
+ let prefs = serde_json::to_value(&s.prefs).unwrap();
+ let mut fresh = Session::new();
+ fresh.prefs = serde_json::from_value(prefs).unwrap();
+ 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}},"options":{"precision":1}}))["id"].as_u64().unwrap();
+ assert_eq!(text(&s, id), "90.0°");
+ run(&mut s, "select.set", json!({"ids":[second]}));
+ run(&mut s, "object.rotate", json!({"angle":45,"origin":[100,100]}));
+ 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":"between","textAlignment":"right","lineColor":[10,20,30]}}),
+ );
+ assert_eq!(text(&s, id), "25.40");
+ let d = recipe(&s, id);
+ 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());
+}
+
+#[test]
+fn unknown_dimension_options_are_refused_by_commands_but_a_newer_file_still_opens() {
+ let mut s = session();
+ let line = edge(&mut s);
+ let source = json!({"kind":"segment","edge":{"node":line,"subpath":0,"segment":0}});
+ let bad = s.execute("dimension.create", &json!({"source":source,"options":{"units":"in","color":"red"}}));
+ assert!(bad.unwrap_err().to_string().contains("Unknown dimension option color"));
+ assert!(s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"style":{"precison":2}}})).is_err());
+ let id = dim(&mut s, line);
+ let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false);
+ let mut v: Value = serde_json::from_slice(&saved).unwrap();
+ // A later version's dimension carries fields this one doesn't know.
+ fn add_unknown(v: &mut Value) {
+ match v {
+ Value::Object(m) => {
+ if let Some(Value::Object(d)) = m.get_mut("dimension") {
+ d.insert("later".into(), json!(1));
+ if let Some(Value::Object(o)) = d.get_mut("options") {
+ o.insert("later".into(), json!(true));
+ }
+ }
+ m.values_mut().for_each(add_unknown);
+ }
+ Value::Array(a) => a.iter_mut().for_each(add_unknown),
+ _ => {}
+ }
+ }
+ add_unknown(&mut v);
+ let doc = vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap();
+ assert!(doc.node(NodeId(id)).unwrap().extras().dimension.is_some());
+}
diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs
index df8699bff..7c1fe218f 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-dimension")],
&[
t("symbolSprayer", "Symbol Sprayer Tool", Some("Shift+S"), "tool-symbol"),
t("symbolShifter", "Symbol Shifter Tool", None, "tool-symbol"),
diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs
new file mode 100644
index 000000000..eb482a036
--- /dev/null
+++ b/crates/tools/src/dimension.rs
@@ -0,0 +1,400 @@
+//! The Dimension tool. Hover a segment (linear), a corner (angular) or a circle or perfect arc
+//! (radial) and drag outwards: releasing places the linked dimension. Plain clicks pick what to
+//! measure instead: two points (linear) or two straight lines (angular), then a click places it;
+//! a click on a circle or arc gives its radius. Escape cancels without leaving any artwork.
+use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
+use serde_json::{Value, json};
+use vectorcraft_doc::{
+ Node,
+ dimension::{self, Dimension, DimensionMode, DimensionOptions, DimensionSource, PointRef, SegmentRef},
+};
+use vectorcraft_geom::{BezPath, Point, Vec2};
+
+/// The three kinds of dimension the tool draws.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum Kind {
+ #[default]
+ Linear,
+ Angular,
+ Radial,
+}
+
+impl Kind {
+ pub fn id(self) -> &'static str {
+ match self {
+ Kind::Linear => "linear",
+ Kind::Angular => "angular",
+ Kind::Radial => "radial",
+ }
+ }
+ /// `radius` and `diameter` are the radial kind.
+ pub fn parse(s: &str) -> Option {
+ match s {
+ "linear" => Some(Kind::Linear),
+ "angular" => Some(Kind::Angular),
+ "radial" | "radius" | "diameter" => Some(Kind::Radial),
+ _ => None,
+ }
+ }
+}
+
+/// What the pointer is over.
+#[derive(Clone, Debug, PartialEq)]
+enum Target {
+ Segment(SegmentRef),
+ Corner { first: SegmentRef, second: SegmentRef, at: Point },
+ Radial { source: DimensionSource, center: Point, radius: f64 },
+}
+
+#[derive(Default)]
+pub struct DimensionTool {
+ kind: Kind,
+ style: DimensionOptions,
+ /// Where the button went down, and what was under it.
+ press: Option<(Point, Option)>,
+ /// Plain-click picks: points (linear) or straight lines (angular).
+ points: Vec<(Point, Option)>,
+ lines: Vec,
+ /// A dimension being placed: previews follow the pointer.
+ pending: Option,
+ dragging: bool,
+ 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
+}
+
+/// The anchor of `edge` within reach of `p`, if any.
+fn anchor_near(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option<(usize, Point)> {
+ let sp = cx.doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?;
+ let next = if edge.segment + 1 < sp.anchors.len() { edge.segment + 1 } else { 0 };
+ [edge.segment, next]
+ .into_iter()
+ .filter_map(|i| Some((i, sp.anchors.get(i)?.p)))
+ .filter(|(_, a)| a.distance(p) <= cx.tol(8.0))
+ .min_by(|(_, a), (_, b)| a.distance(p).total_cmp(&b.distance(p)))
+}
+
+impl DimensionTool {
+ fn target(&self, cx: &ToolContext, p: Point) -> Option {
+ let edge = pick(cx, p)?;
+ match self.kind {
+ Kind::Linear => Some(Target::Segment(edge)),
+ Kind::Angular => {
+ let (anchor, at) = anchor_near(cx, &edge, p)?;
+ let (first, second) = dimension::corner(cx.doc, edge.node, edge.subpath, anchor)?;
+ Some(Target::Corner { first, second, at })
+ }
+ Kind::Radial => {
+ if let Some((center, radius)) = dimension::circle(cx.doc, edge.node) {
+ return Some(Target::Radial { source: DimensionSource::Circle { node: edge.node }, center, radius });
+ }
+ let (center, radius) = dimension::arc(cx.doc, &edge)?;
+ Some(Target::Radial { source: DimensionSource::Arc { edge }, center, radius })
+ }
+ }
+ }
+ fn recipe(&self, mode: DimensionMode, a: Point, b: Point, vertex: Point, source: Option) -> Dimension {
+ Dimension { mode, a, b, vertex, offset: 0.0, length: a.distance(b), options: self.style.clone(), source, moved: None }
+ }
+ /// The dimension a target gives when dragged from.
+ fn dimension_for(&self, target: &Target, p: Point) -> Option {
+ let mut d = match target {
+ Target::Segment(edge) => self.recipe(
+ DimensionMode::Linear,
+ Point::ZERO,
+ Point::new(1.0, 0.0),
+ Point::ZERO,
+ Some(DimensionSource::Segment { edge: edge.clone() }),
+ ),
+ Target::Corner { first, second, .. } => self.recipe(
+ DimensionMode::Angular,
+ Point::ZERO,
+ Point::new(1.0, 0.0),
+ Point::ZERO,
+ Some(DimensionSource::Angle { first: first.clone(), second: second.clone() }),
+ ),
+ Target::Radial { source, center, radius } => {
+ // Dragged outwards: the radius; inwards: the diameter.
+ let mode = if p.distance(*center) >= *radius { DimensionMode::Radius } else { DimensionMode::Diameter };
+ self.recipe(mode, *center, p, Point::ZERO, Some(source.clone()))
+ }
+ };
+ d.offset = 30.0;
+ Some(d)
+ }
+ fn params(d: &Dimension) -> Value {
+ let mut p = json!({"mode":d.mode,"a":[d.a.x,d.a.y],"b":[d.b.x,d.b.y],"vertex":[d.vertex.x,d.vertex.y],"offset":d.offset,"options":d.options});
+ 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 matches!(d.mode, DimensionMode::Radius | DimensionMode::Diameter)
+ && let Some(Target::Radial { center, radius, .. }) = self.press.as_ref().and_then(|(_, t)| t.as_ref())
+ {
+ d.mode = if p.distance(*center) >= *radius { DimensionMode::Radius } else { DimensionMode::Diameter };
+ }
+ d.place(p);
+ if d.resolve(cx.doc).is_err() {
+ return vec![];
+ }
+ 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
+ }
+ /// Places the pending dimension at `p` and commits it.
+ fn place(&mut self, cx: &ToolContext, p: Point) -> Vec {
+ let mut out = self.preview(cx, p);
+ if self.began {
+ out.push(Action::Commit);
+ }
+ self.reset();
+ out
+ }
+ fn reset(&mut self) {
+ self.pending = None;
+ self.press = None;
+ self.points.clear();
+ self.lines.clear();
+ self.dragging = false;
+ self.began = false;
+ }
+ /// A plain click: picks a point or a line, or measures a circle's radius.
+ fn click(&mut self, cx: &ToolContext, p: Point, target: Option, mods: Mods) -> Vec {
+ match self.kind {
+ Kind::Linear => {
+ let (mut at, _) = crate::guides::snap_pick(cx, p);
+ let mut reference = None;
+ if let Some(edge) = pick(cx, p)
+ && let Some((anchor, point)) = anchor_near(cx, &edge, p)
+ {
+ at = point;
+ reference = Some(PointRef { node: edge.node, subpath: edge.subpath, anchor });
+ }
+ if mods.shift
+ && let Some((last, _)) = self.points.last()
+ {
+ at = *last + vectorcraft_geom::constrain_angle_from(at - *last, 45.0, cx.constrain_angle);
+ reference = reference.filter(|r| r.position(cx.doc) == Some(at));
+ }
+ self.points.push((at, reference));
+ if let [(a, ra), (b, rb)] = self.points.as_slice() {
+ let source = (ra.is_some() || rb.is_some()).then(|| DimensionSource::Points { a: ra.clone(), b: rb.clone(), vertex: None });
+ let mut d = self.recipe(DimensionMode::Linear, *a, *b, Point::ZERO, source);
+ if d.resolve(cx.doc).is_ok() {
+ self.pending = Some(d);
+ }
+ self.points.clear();
+ }
+ vec![]
+ }
+ Kind::Angular => {
+ let Some(edge) = pick(cx, p).filter(|e| e.is_straight(cx.doc)) else { return vec![] };
+ if self.lines.first().is_some_and(|first| *first == edge) {
+ return vec![];
+ }
+ self.lines.push(edge);
+ if let [first, second] = self.lines.as_slice() {
+ let mut d = self.recipe(
+ DimensionMode::Angular,
+ Point::ZERO,
+ Point::new(1.0, 0.0),
+ Point::ZERO,
+ Some(DimensionSource::Angle { first: first.clone(), second: second.clone() }),
+ );
+ if d.resolve(cx.doc).is_ok() {
+ d.offset = 30.0;
+ self.pending = Some(d);
+ }
+ self.lines.clear();
+ }
+ vec![]
+ }
+ Kind::Radial => {
+ let Some(target @ Target::Radial { .. }) = target else { return vec![] };
+ let Some(mut d) = self.dimension_for(&target, p) else { return vec![] };
+ d.mode = DimensionMode::Radius;
+ self.pending = Some(d);
+ self.press = Some((p, Some(target)));
+ self.place(cx, p)
+ }
+ }
+ }
+}
+
+impl Tool for DimensionTool {
+ fn id(&self) -> &'static str {
+ "dimension"
+ }
+ fn busy(&self) -> bool {
+ self.pending.is_some() || !self.points.is_empty() || !self.lines.is_empty()
+ }
+ fn cursor(&self, _: &ToolContext, _: Point, _: Mods) -> Cursor {
+ Cursor::Crosshair
+ }
+ fn options(&self) -> Value {
+ json!({"mode":self.kind.id(),"style":self.style})
+ }
+ fn set_option(&mut self, key: &str, value: &Value) {
+ if key == "mode"
+ && let Some(kind) = value.as_str().and_then(Kind::parse)
+ {
+ self.kind = kind;
+ }
+ 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;
+ match ev.kind {
+ PointerKind::Move => {
+ if self.pending.is_some() {
+ return self.preview(cx, ev.pos);
+ }
+ self.hover = self.target(cx, ev.pos);
+ vec![]
+ }
+ PointerKind::Down => {
+ if self.pending.is_some() && !self.dragging {
+ // The second click of a picked measurement places it.
+ return self.place(cx, ev.pos);
+ }
+ self.press = Some((ev.pos, self.target(cx, ev.pos)));
+ vec![]
+ }
+ PointerKind::Drag => {
+ if self.pending.is_none()
+ && let Some((start, Some(target))) = self.press.clone()
+ && start.distance(ev.pos) > cx.tol(3.0)
+ && self.points.is_empty()
+ && self.lines.is_empty()
+ {
+ self.pending = self.dimension_for(&target, ev.pos);
+ self.dragging = self.pending.is_some();
+ }
+ if self.dragging { self.preview(cx, ev.pos) } else { vec![] }
+ }
+ PointerKind::Up => {
+ if self.dragging {
+ return self.place(cx, ev.pos);
+ }
+ match self.press.take() {
+ Some((start, target)) if self.pending.is_none() => self.click(cx, start, target, ev.mods),
+ _ => vec![],
+ }
+ }
+ _ => 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![];
+ let segment = |edge: &SegmentRef, out: &mut Vec| {
+ if let Some(sp) = cx.doc.node(edge.node).and_then(Node::path_data).and_then(|p| p.subpaths.get(edge.subpath))
+ && edge.segment < sp.segment_count()
+ {
+ let c = sp.segment(edge.segment);
+ let mut path = BezPath::new();
+ path.move_to(c.p0);
+ path.curve_to(c.p1, c.p2, c.p3);
+ out.push(Overlay::Path { path, color, width: 3.0, dashed: false });
+ }
+ };
+ if self.pending.is_none() {
+ match &self.hover {
+ Some(Target::Segment(edge)) => {
+ segment(edge, &mut out);
+ if let Some((a, b)) = edge.endpoints(cx.doc) {
+ for p in [a, b] {
+ out.push(Overlay::Anchor { p, color, filled: true, size: 6.0 });
+ }
+ if let Ok(text) = self.style.label(a.distance(b), false) {
+ out.push(Overlay::Measure { p: self.at + Vec2::new(12.0, 12.0), text });
+ }
+ }
+ }
+ Some(Target::Corner { first, second, at }) => {
+ segment(first, &mut out);
+ segment(second, &mut out);
+ out.push(Overlay::Anchor { p: *at, color, filled: true, size: 7.0 });
+ }
+ Some(Target::Radial { center, radius, .. }) => {
+ out.push(Overlay::Anchor { p: *center, color, filled: false, size: 6.0 });
+ let toward = self.at - *center;
+ if toward.hypot() > 1e-9 {
+ out.push(Overlay::Line { a: *center, b: *center + toward / toward.hypot() * *radius, color, dashed: true });
+ }
+ }
+ None => {}
+ }
+ }
+ for (p, _) in &self.points {
+ out.push(Overlay::Anchor { p: *p, color, filled: true, size: 6.0 });
+ }
+ for edge in &self.lines {
+ segment(edge, &mut out);
+ }
+ if let Some((p, _)) = self.points.last() {
+ out.push(Overlay::Line { a: *p, b: self.at, color, dashed: true });
+ }
+ out
+ }
+}
diff --git a/crates/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..ac6c0cfb7 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" => &[
+ ("Drag", true),
+ (" out from a highlighted edge 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..e494928bb
--- /dev/null
+++ b/crates/ui-egui/src/dialogs/dimension.rs
@@ -0,0 +1,360 @@
+//! Dimension Tool Options, the tool's control strip, and the Properties panel's Dimension section.
+//! Dialog values are drafts until Save, which sets the style of new dimensions.
+use super::DialogSpec;
+use crate::{VectorcraftApp, state::Dialog, theme::Tokens, widgets};
+use egui::{RichText, Ui};
+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
+};
+
+/// (value, label) of each choice.
+type Choices = &'static [(&'static str, &'static str)];
+const UNITS: Choices = &[
+ ("in", "Inches"),
+ ("mm", "Millimeters"),
+ ("pt", "Points"),
+ ("pc", "Picas"),
+ ("px", "Pixels"),
+ ("cm", "Centimeters"),
+ ("m", "Meters"),
+ ("ft", "Feet"),
+ ("yd", "Yards"),
+];
+const LINE_TYPES: Choices = &[("solid", "Solid"), ("dashed", "Dashed"), ("dotted", "Dotted")];
+const ARROWS: Choices = &[("filled", "Filled"), ("open", "Open"), ("tick", "Tick"), ("none", "None")];
+const POSITIONS: Choices = &[("away", "Away from the Line"), ("between", "Between Line and Object")];
+const ALIGNMENTS: Choices = &[("left", "Left"), ("center", "Center"), ("right", "Right")];
+
+pub fn open(app: &mut VectorcraftApp) -> Result {
+ if app.session.tool_id() == "dimension" && app.session.tool_busy() {
+ app.run("tool.select", json!({"tool":"selection"}))?;
+ app.run("tool.select", json!({"tool":"dimension"}))?;
+ }
+ let style = app.session.tool_options_of("dimension")["style"].clone();
+ let o: DimensionOptions = serde_json::from_value(style).unwrap_or_default();
+ let fields = serde_json::to_value(o).map_err(|e| e.to_string())?.as_object().cloned().unwrap_or_default();
+ app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
+ Ok(json!({"dialog":KIND}))
+}
+
+fn read(fields: &serde_json::Map) -> Result {
+ let o: DimensionOptions = serde_json::from_value(Value::Object(fields.clone())).map_err(|e| e.to_string())?;
+ o.validate()?;
+ Ok(o)
+}
+
+/// A dropdown row: `fields[key]` is one of `choices`' values.
+fn choose(ui: &mut Ui, fields: &mut serde_json::Map, key: &str, label: &str, choices: Choices) -> bool {
+ let mut changed = false;
+ widgets::label_row(ui, tl!(label), 96.0, |ui| {
+ let current = fields.get(key).and_then(Value::as_str).unwrap_or_default().to_owned();
+ let text = choices.iter().find(|(v, _)| *v == current).map_or(current.as_str(), |(_, label)| *label);
+ if let Some(index) =
+ widgets::dropdown_names(ui, ("dimension", key), tl!(text), &choices.iter().map(|(_, s)| tl!(*s)).collect::>(), 150.0)
+ && let Some((value, _)) = choices.get(index)
+ {
+ fields.insert(key.into(), json!(value));
+ changed = true;
+ }
+ });
+ changed
+}
+fn number(ui: &mut Ui, fields: &mut serde_json::Map, key: &str, label: &str, range: (f64, f64), suffix: &str) -> bool {
+ let mut changed = false;
+ widgets::label_row(ui, tl!(label), 96.0, |ui| {
+ let value = fields.get(key).and_then(Value::as_f64).unwrap_or_default();
+ if let Some(v) = widgets::plain_field(ui, ("dimension", key), value, suffix, 2, 90.0) {
+ fields.insert(key.into(), json!(v.clamp(range.0, range.1)));
+ changed = true;
+ }
+ });
+ changed
+}
+fn check(ui: &mut Ui, fields: &mut serde_json::Map, key: &str, label: &str) -> bool {
+ let mut value = fields.get(key).and_then(Value::as_bool).unwrap_or_default();
+ let changed = ui.checkbox(&mut value, tl!(label)).changed();
+ if changed {
+ fields.insert(key.into(), json!(value));
+ }
+ changed
+}
+fn color(ui: &mut Ui, fields: &mut serde_json::Map, key: &str) -> bool {
+ let mut value: [u8; 3] = serde_json::from_value(fields.get(key).cloned().unwrap_or(Value::Null)).unwrap_or([0, 0, 0]);
+ let changed = ui.push_id(key, |ui| ui.color_edit_button_srgb(&mut value).changed()).inner;
+ if changed {
+ fields.insert(key.into(), json!(value));
+ }
+ changed
+}
+fn precision(ui: &mut Ui, fields: &mut serde_json::Map, id: &str) -> bool {
+ let before = fields.get("precision").and_then(Value::as_u64).unwrap_or(2) as u32;
+ let mut value = before;
+ ui.label(tl!("Precision:"));
+ egui::ComboBox::from_id_salt(id).selected_text(value.to_string()).width(55.0).show_ui(ui, |ui| {
+ for n in 0..=6 {
+ ui.selectable_value(&mut value, n, n.to_string());
+ }
+ });
+ fields.insert("precision".into(), json!(value));
+ value != before
+}
+fn section(ui: &mut Ui, title: &str, body: impl FnOnce(&mut Ui)) {
+ let t = Tokens::get(ui.ctx());
+ 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);
+}
+
+fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
+ let f = &mut d.fields;
+ section(ui, "Units & Scale", |ui| {
+ ui.horizontal(|ui| {
+ choose(ui, f, "units", "Units:", UNITS);
+ precision(ui, f, "dimension-precision");
+ });
+ ui.horizontal(|ui| {
+ widgets::label_row(ui, tl!("Scale:"), 96.0, |ui| {
+ let mut scale = f.get("scale").and_then(Value::as_str).unwrap_or_default().to_owned();
+ if ui.add(egui::TextEdit::singleline(&mut scale).desired_width(72.0)).changed() {
+ f.insert("scale".into(), json!(scale));
+ }
+ egui::ComboBox::from_id_salt("dimension-scales").selected_text(tl!("Presets")).width(76.0).show_ui(ui, |ui| {
+ for scale in ["1:1", "1:2", "1:5", "1:10", "1:20", "1:50", "1:100", "1:200", "2:1", "5:1", "10:1"] {
+ if ui.selectable_label(false, scale).clicked() {
+ f.insert("scale".into(), json!(scale));
+ }
+ }
+ });
+ });
+ check(ui, f, "hideUnits", "Hide Units");
+ });
+ ui.label(RichText::new(tl!("Drawing : real size — 1:10 labels 1 unit on the page as 10.")).small());
+ });
+ section(ui, "Dimension Lines", |ui| {
+ ui.horizontal(|ui| {
+ number(ui, f, "lineWeight", "Line Weight:", (0.1, 100.0), "pt");
+ choose(ui, f, "lineType", "Line Type:", LINE_TYPES);
+ color(ui, f, "lineColor");
+ });
+ ui.horizontal(|ui| {
+ choose(ui, f, "arrowStyle", "Arrow Style:", ARROWS);
+ number(ui, f, "arrowScale", "Arrow Scale:", (1.0, 1000.0), "%");
+ });
+ });
+ section(ui, "Extension Lines", |ui| {
+ ui.horizontal(|ui| {
+ number(ui, f, "extensionWeight", "Line Weight:", (0.1, 100.0), "pt");
+ choose(ui, f, "extensionType", "Line Type:", LINE_TYPES);
+ color(ui, f, "extensionColor");
+ });
+ ui.horizontal(|ui| {
+ number(ui, f, "extensionOffset", "Offset:", (0.0, 10000.0), "pt");
+ number(ui, f, "extensionLength", "Extension:", (0.0, 10000.0), "pt");
+ });
+ check(ui, f, "hideExtensions", "Hide Extension Lines");
+ });
+ section(ui, "Dimension Text", |ui| {
+ widgets::label_row(ui, tl!("Font Family:"), 96.0, |ui| {
+ let family = f.get("fontFamily").and_then(Value::as_str).unwrap_or_default().to_owned();
+ if let Some(crate::font_menu::FontPick::Chosen(family, style)) =
+ crate::font_menu::font_menu(ui, "dimension-font", &family, 280.0, Some("123.45"), crate::font_menu::MenuLook::of(app))
+ {
+ f.insert("fontFamily".into(), json!(family));
+ f.insert("fontStyle".into(), json!(style.unwrap_or_else(|| "Regular".into())));
+ }
+ });
+ widgets::label_row(ui, tl!("Font Style:"), 96.0, |ui| {
+ let family = f.get("fontFamily").and_then(Value::as_str).unwrap_or_default().to_owned();
+ let current = f.get("fontStyle").and_then(Value::as_str).unwrap_or_default().to_owned();
+ let styles = vectorcraft_text::FontDb::global().styles(&family);
+ let names: Vec<&str> = styles.iter().map(|s| s.as_str()).collect();
+ if let Some(i) = widgets::dropdown_names(ui, "dimension-font-style", ¤t, &names, 180.0)
+ && let Some(style) = styles.get(i)
+ {
+ f.insert("fontStyle".into(), json!(style));
+ }
+ });
+ ui.horizontal(|ui| {
+ number(ui, f, "fontSize", "Font Size:", (1.0, 1000.0), "pt");
+ color(ui, f, "textColor");
+ });
+ ui.horizontal(|ui| {
+ choose(ui, f, "textPosition", "Position:", POSITIONS);
+ number(ui, f, "textOffset", "Offset:", (0.0, 10000.0), "pt");
+ });
+ choose(ui, f, "textAlignment", "Alignment:", ALIGNMENTS);
+ });
+ match read(f) {
+ Ok(o) => {
+ let sample = o.label(72.0, false).unwrap_or_default();
+ let t = Tokens::get(ui.ctx());
+ ui.label(RichText::new(format!("{}: {sample}", tl!("Preview (1 inch on the page)"))).color(t.text));
+ }
+ Err(e) => {
+ ui.colored_label(Tokens::get(ui.ctx()).text, e);
+ }
+ }
+ if ui.button(tl!("Reset")).clicked()
+ && let Ok(Value::Object(fields)) = serde_json::to_value(DimensionOptions::default())
+ {
+ d.fields = fields;
+ }
+ false
+}
+
+fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result {
+ let o = read(&d.fields)?;
+ let r = app.run("tool.setOption", json!({"tool":"dimension","values":{"style":o}}))?;
+ app.ui.dialog = None;
+ Ok(r)
+}
+
+/// The Dimension tool's strip: the kind of dimension, and Tool Options.
+pub fn controls(app: &mut VectorcraftApp, ui: &mut Ui) {
+ let mode = app.session.tool_options()["mode"].as_str().unwrap_or("linear").to_owned();
+ for (id, label) in [("linear", "Linear"), ("angular", "Angular"), ("radial", "Radial")] {
+ if ui.selectable_label(mode == id, tl!(label)).clicked() {
+ // Switching away cancels previews and unfinished picks.
+ if let Err(e) = app
+ .run("tool.select", json!({"tool":"selection"}))
+ .and_then(|_| app.run("tool.select", json!({"tool":"dimension"})))
+ .and_then(|_| app.run("tool.setOption", json!({"tool":"dimension","values":{"mode":id}})))
+ {
+ app.ui.status = e;
+ }
+ }
+ }
+ if ui.button(tl!("Tool Options…")).clicked()
+ && let Err(e) = open(app)
+ {
+ 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!("Dimension Tool"))
+ .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" => "Drag out from a corner, or click two straight lines, then click to place.",
+ "radial" | "radius" | "diameter" => "Drag out from a circle or arc for its radius, inwards for its diameter.",
+ _ => "Drag out from a segment, or click two points, then click to place.",
+ };
+ ui.label(RichText::new(tl!(hint)).small().color(Tokens::get(ctx).text_dim));
+ });
+}
+
+/// The selected dimension, if one is selected alone: its id and style.
+pub fn selected(app: &VectorcraftApp) -> Option<(u64, DimensionOptions)> {
+ let st = app.session.active()?;
+ let [id] = st.selection.objects.as_slice() else { return None };
+ let d = st.doc.node(*id)?.extras().dimension.as_deref()?;
+ Some((id.0, d.options.clone()))
+}
+
+/// The Properties panel's Dimension section: the selected dimension's style, Apply to All and Set
+/// as Default. Its scale is set in the Tool Options.
+pub fn properties_section(app: &mut VectorcraftApp, ui: &mut Ui) {
+ let Some((id, o)) = selected(app) else { return };
+ let Ok(Value::Object(mut f)) = serde_json::to_value(&o) else { return };
+ widgets::section_header(ui, tl!("Dimension"));
+ let mut changed = false;
+ ui.horizontal(|ui| {
+ changed |= choose(ui, &mut f, "units", "Units:", UNITS);
+ });
+ ui.horizontal(|ui| changed |= precision(ui, &mut f, "props-dimension-precision"));
+ ui.horizontal(|ui| {
+ changed |= number(ui, &mut f, "lineWeight", "Line Weight:", (0.1, 100.0), "pt");
+ changed |= color(ui, &mut f, "lineColor");
+ });
+ ui.horizontal(|ui| changed |= choose(ui, &mut f, "arrowStyle", "Arrow Style:", ARROWS));
+ ui.horizontal(|ui| {
+ changed |= number(ui, &mut f, "fontSize", "Font Size:", (1.0, 1000.0), "pt");
+ changed |= color(ui, &mut f, "textColor");
+ });
+ ui.horizontal(|ui| changed |= choose(ui, &mut f, "textPosition", "Position:", POSITIONS));
+ ui.horizontal(|ui| changed |= choose(ui, &mut f, "textAlignment", "Alignment:", ALIGNMENTS));
+ if changed {
+ match read(&f) {
+ Ok(o) => {
+ if let Err(e) = app.run("dimension.update", json!({"ids":[id],"options":o})) {
+ app.ui.status = e;
+ }
+ }
+ Err(e) => app.ui.status = e,
+ }
+ }
+ let w = (ui.available_width() - 6.0) / 2.0;
+ ui.horizontal(|ui| {
+ if widgets::flat_button(ui, tl!("Apply to All"), w).clicked()
+ && let Err(e) = app.run("dimension.applyToAll", json!({"id":id}))
+ {
+ app.ui.status = e;
+ }
+ if widgets::flat_button(ui, tl!("Set as Default"), w).clicked()
+ && let Err(e) = app.run("tool.setOption", json!({"tool":"dimension","values":{"style":o}}))
+ {
+ app.ui.status = e;
+ }
+ });
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[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"));
+ d.fields.insert("units".into(), json!("pc"));
+ super::super::confirm(&mut app).unwrap();
+ assert_eq!(app.session.tool_options_of("dimension")["style"]["scale"], "1:10");
+ assert_eq!(app.session.tool_options_of("dimension")["style"]["units"], "pc");
+ open(&mut app).unwrap();
+ app.ui.dialog.as_mut().unwrap().fields.insert("scale".into(), json!("1:0"));
+ assert!(super::super::confirm(&mut app).is_err());
+ app.ui.dialog = None;
+ assert_eq!(app.session.tool_options_of("dimension")["style"]["scale"], "1:10");
+ }
+
+ /// A selected dimension shows its style in Properties, with Apply to All and Set as Default.
+ #[test]
+ fn properties_show_the_selected_dimension() {
+ let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
+ app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
+ app.run("dimension.create", json!({"a":[10,10],"b":[110,10]})).unwrap();
+ let text = crate::tests_labels::painted_text(&mut app, properties_section);
+ for label in ["Dimension", "Units:", "Apply to All", "Set as Default"] {
+ assert!(text.contains(label), "{label}: {text}");
+ }
+ }
+}
diff --git a/crates/ui-egui/src/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..5a223e467 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -4808,6 +4808,51 @@ 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)
+ Display Zobrazení
+# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
+ Dimension Rozměr
+ Drawing : real size — 1:10 labels 1 unit on the page as 10. Výkres : skutečná velikost — 1:10 označí 1 jednotku na stránce jako 10.
+ Preview (1 inch on the page) Náhled (1 palec na stránce)
+ Set as Default Nastavit jako výchozí
+ out from a highlighted edge to place its dimension | ze zvýrazněné hrany ven a umístíte její kótu |
+ cancels | Double-click the tool for options zruší | Dvojitým kliknutím na nástroj otevřete volby
+ Angular Úhlová
+ Away from the Line Dál od čáry
+ Between Line and Object Mezi čárou a objektem
+ Drag out from a segment, or click two points, then click to place. Táhněte ven od úsečky, nebo klikněte na dva body a potom klikněním umístěte.
+ Drag out from a corner, or click two straight lines, then click to place. Táhněte ven od rohu, nebo klikněte na dvě rovné čáry a potom klikněním umístěte.
+ Drag out from a circle or arc for its radius, inwards for its diameter. Táhněte od kruhu nebo oblouku ven pro poloměr, dovnitř pro průměr.
+@msg Choose a circle or a perfect arc Vyberte kruh nebo dokonalý oblouk
+@msg Choose a precision from 0 to 6 Zvolte přesnost od 0 do 6
+ Create Dimension Vytvořit kótu
+ Dimension Tool Nástroj Kóta
+ Dimension Tool Options Volby nástroje Kóta
+ Font Family: Rodina písma:
+ Font Style: Řez písma:
+ Precision: Přesnost:
+ Update Dimensions Aktualizovat kóty
+@msg Angles require two straight segments Úhly vyžadují dva přímé segmenty
+@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 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 option {k} Neznámá volba kóty {k}
+@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..597e7056f 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -4336,5 +4336,52 @@ 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).
+ Dimension Bemaßung
+ Drawing : real size — 1:10 labels 1 unit on the page as 10. Zeichnung : reale Größe — 1:10 beschriftet 1 Einheit auf der Seite als 10.
+ Preview (1 inch on the page) Vorschau (1 Zoll auf der Seite)
+ Set as Default Als Standard festlegen
+ out from a highlighted edge to place its dimension | von einer hervorgehobenen Kante nach außen, um ihre Bemaßung zu platzieren |
+ cancels | Double-click the tool for options bricht ab | Doppelklick auf das Werkzeug für Optionen
+ Linear Linear
+ Angular Winkel
+ Away from the Line Abseits der Linie
+ Between Line and Object Zwischen Linie und Objekt
+ Drag out from a segment, or click two points, then click to place. Von einem Segment nach außen ziehen oder zwei Punkte anklicken, dann zum Platzieren klicken.
+ Drag out from a corner, or click two straight lines, then click to place. Von einer Ecke nach außen ziehen oder zwei gerade Linien anklicken, dann zum Platzieren klicken.
+ Drag out from a circle or arc for its radius, inwards for its diameter. Von einem Kreis oder Bogen nach außen ziehen für den Radius, nach innen für den Durchmesser.
+@msg Choose a circle or a perfect arc Wählen Sie einen Kreis oder einen perfekten Bogen
+@msg Choose a precision from 0 to 6 Wählen Sie eine Genauigkeit von 0 bis 6
+ Create Dimension Bemaßung erstellen
+ Dimension Tool Bemaßungswerkzeug
+ Dimension Tool Options Optionen für Bemaßungswerkzeug
+ Display Anzeige
+ Font Family: Schriftfamilie:
+ Font Style: Schriftschnitt:
+ Precision: Genauigkeit:
+ Update Dimensions Bemaßungen aktualisieren
+ X: X:
+ Y: Y:
+@msg Angles require two straight segments Winkel erfordern zwei gerade Segmente
+@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 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 option {k} Unbekannte Bemaßungsoption {k}
+@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..8538bbf99 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -4749,6 +4749,51 @@ mark-style Japanese Japonesas
Softness Suavidad
Ambient Light Luz ambiental
Light behind the object Luz detrás del objeto
+# Dimension tool (linked dimension annotations)
+ Create Dimension Crear cota
+ Dimension Tool Herramienta Cota
+ Dimension Tool Options Opciones de la herramienta Cota
+ Font Family: Familia de fuentes:
+ Font Style: Estilo de fuente:
+ Precision: Precisión:
+ Update Dimensions Actualizar cotas
+ Display Visualización
+# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
+ Dimension Cota
+ Drawing : real size — 1:10 labels 1 unit on the page as 10. Dibujo : tamaño real — 1:10 rotula 1 unidad de la página como 10.
+ Preview (1 inch on the page) Vista previa (1 pulgada en la página)
+ Set as Default Establecer como predeterminado
+ out from a highlighted edge to place its dimension | hacia fuera desde un borde resaltado para colocar su cota |
+ cancels | Double-click the tool for options cancela | Doble clic en la herramienta para ver las opciones
+ Angular Angular
+ Away from the Line Lejos de la línea
+ Between Line and Object Entre la línea y el objeto
+ Drag out from a segment, or click two points, then click to place. Arrastre hacia fuera desde un segmento, o haga clic en dos puntos y luego haga clic para colocar.
+ Drag out from a corner, or click two straight lines, then click to place. Arrastre hacia fuera desde una esquina, o haga clic en dos líneas rectas y luego haga clic para colocar.
+ Drag out from a circle or arc for its radius, inwards for its diameter. Arrastre desde un círculo o arco hacia fuera para el radio, hacia dentro para el diámetro.
+@msg Choose a circle or a perfect arc Elija un círculo o un arco perfecto
+@msg Choose a precision from 0 to 6 Elija una precisión de 0 a 6
+@msg Angles require two straight segments Los ángulos requieren dos segmentos rectos
+@msg Choose 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 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 option {k} Opción de cota desconocida {k}
+@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..2eb9d59ab 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -4809,5 +4809,49 @@ 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).
+ Dimension Cote
+ Drawing : real size — 1:10 labels 1 unit on the page as 10. Dessin : taille réelle — 1:10 étiquette 1 unité de la page comme 10.
+ Preview (1 inch on the page) Aperçu (1 pouce sur la page)
+ Set as Default Définir par défaut
+ out from a highlighted edge to place its dimension | vers l'extérieur depuis un bord mis en évidence pour placer sa cote |
+ cancels | Double-click the tool for options annule | Double-clic sur l'outil pour les options
+ Angular Angulaire
+ Away from the Line À l'écart de la ligne
+ Between Line and Object Entre la ligne et l'objet
+ Drag out from a segment, or click two points, then click to place. Faites glisser vers l'extérieur depuis un segment, ou cliquez sur deux points, puis cliquez pour placer.
+ Drag out from a corner, or click two straight lines, then click to place. Faites glisser vers l'extérieur depuis un angle, ou cliquez sur deux lignes droites, puis cliquez pour placer.
+ Drag out from a circle or arc for its radius, inwards for its diameter. Faites glisser depuis un cercle ou un arc vers l'extérieur pour le rayon, vers l'intérieur pour le diamètre.
+@msg Choose a circle or a perfect arc Choisissez un cercle ou un arc parfait
+@msg Choose a precision from 0 to 6 Choisissez une précision de 0 à 6
+ Create Dimension Créer une cote
+ Dimension Tool Outil Cotation
+ Dimension Tool Options Options de l’outil Cotation
+ Display Affichage
+ Font Family: Famille de polices :
+ Font Style: Style de police :
+ Precision: Précision :
+ Update Dimensions Mettre à jour les cotes
+@msg Angles require two straight segments Les angles nécessitent deux segments droits
+@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 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 option {k} Option de cote inconnue {k}
+@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..26655c258 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -4776,5 +4776,49 @@ 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).
+ Dimension Méret
+ Drawing : real size — 1:10 labels 1 unit on the page as 10. Rajz : valós méret — 1:10 esetén az oldalon 1 egység 10-ként jelenik meg.
+ Preview (1 inch on the page) Előnézet (1 hüvelyk az oldalon)
+ Set as Default Beállítás alapértelmezettként
+ out from a highlighted edge to place its dimension | egy kiemelt élről kifelé a méret elhelyezéséhez |
+ cancels | Double-click the tool for options megszakítja | Dupla kattintás az eszközre a beállításokhoz
+ Angular Szög
+ Away from the Line A vonaltól távol
+ Between Line and Object A vonal és az objektum között
+ Drag out from a segment, or click two points, then click to place. Húzzon kifelé egy szakaszról, vagy kattintson két pontra, majd kattintson az elhelyezéshez.
+ Drag out from a corner, or click two straight lines, then click to place. Húzzon kifelé egy sarokról, vagy kattintson két egyenes vonalra, majd kattintson az elhelyezéshez.
+ Drag out from a circle or arc for its radius, inwards for its diameter. Húzzon egy körről vagy ívről kifelé a sugárhoz, befelé az átmérőhöz.
+@msg Choose a circle or a perfect arc Válasszon egy kört vagy egy tökéletes ívet
+@msg Choose a precision from 0 to 6 Válasszon 0 és 6 közötti pontosságot
+ Create Dimension Méretvonal létrehozása
+ Dimension Tool Méretezés eszköz
+ Dimension Tool Options Méretezés eszköz beállításai
+ Display Megjelenítés
+ Font Family: Betűcsalád:
+ Font Style: Betűstílus:
+ Precision: Pontosság:
+ Update Dimensions Méretek frissítése
+@msg Angles require two straight segments A szöghöz két egyenes szakasz kell
+@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 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 option {k} Ismeretlen méretezési beállítás: {k}
+@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..674ebb6ae 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -4381,5 +4381,52 @@ 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).
+ Dimension Quota
+ Drawing : real size — 1:10 labels 1 unit on the page as 10. Disegno : dimensione reale — 1:10 indica 1 unità sulla pagina come 10.
+ Preview (1 inch on the page) Anteprima (1 pollice sulla pagina)
+ Set as Default Imposta come predefinito
+ out from a highlighted edge to place its dimension | verso l'esterno da un bordo evidenziato per posizionarne la quota |
+ cancels | Double-click the tool for options annulla | Doppio clic sullo strumento per le opzioni
+ Linear Lineare
+ Angular Angolare
+ Away from the Line Lontano dalla linea
+ Between Line and Object Tra linea e oggetto
+ Drag out from a segment, or click two points, then click to place. Trascina verso l'esterno da un segmento, oppure fai clic su due punti, poi fai clic per posizionare.
+ Drag out from a corner, or click two straight lines, then click to place. Trascina verso l'esterno da un angolo, oppure fai clic su due linee rette, poi fai clic per posizionare.
+ Drag out from a circle or arc for its radius, inwards for its diameter. Trascina da un cerchio o un arco verso l'esterno per il raggio, verso l'interno per il diametro.
+@msg Choose a circle or a perfect arc Scegli un cerchio o un arco perfetto
+@msg Choose a precision from 0 to 6 Scegli una precisione da 0 a 6
+ Create Dimension Crea quota
+ Dimension Tool Strumento Quota
+ Dimension Tool Options Opzioni strumento Quota
+ Display Visualizzazione
+ Font Family: Famiglia di font:
+ Font Style: Stile font:
+ Precision: Precisione:
+ Update Dimensions Aggiorna quote
+ X: X:
+ Y: Y:
+@msg Angles require two straight segments Gli angoli richiedono due segmenti rettilinei
+@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 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 option {k} Opzione di quota sconosciuta {k}
+@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..aea1c8059 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -4374,6 +4374,54 @@ lighting Light ライト
Softness 柔らかさ
Ambient Light 環境光
Light behind the object オブジェクトの背後のライト
+# Dimension tool (linked dimension annotations)
+ Create Dimension 寸法を作成
+ Dimension Tool 寸法ツール
+ Dimension Tool Options 寸法ツールオプション
+ Font Family: フォントファミリー:
+ Font Style: フォントスタイル:
+ Precision: 精度:
+ Update Dimensions 寸法を更新
+ Display 表示
+# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
+ Dimension 寸法
+ Drawing : real size — 1:10 labels 1 unit on the page as 10. 図面 : 実寸 — 1:10 では、ページ上の 1 単位を 10 と表示します。
+ Preview (1 inch on the page) プレビュー(ページ上の 1 インチ)
+ Set as Default デフォルトに設定
+ out from a highlighted edge to place its dimension | でハイライトされた辺から外側へ寸法を配置 |
+ cancels | Double-click the tool for options でキャンセル | ツールをダブルクリックしてオプション
+ Linear 直線
+ Angular 角度
+ Away from the Line 寸法線から離す
+ Between Line and Object 寸法線とオブジェクトの間
+ Drag out from a segment, or click two points, then click to place. セグメントから外側へドラッグするか、2 点をクリックしてからクリックして配置します。
+ Drag out from a corner, or click two straight lines, then click to place. 角から外側へドラッグするか、2 本の直線をクリックしてからクリックして配置します。
+ Drag out from a circle or arc for its radius, inwards for its diameter. 円または円弧から外側へドラッグすると半径、内側へドラッグすると直径になります。
+@msg Choose a circle or a perfect arc 円または正確な円弧を選択してください
+@msg Choose a precision from 0 to 6 精度は 0〜6 で選択してください
+ X: X:
+ Y: Y:
+@msg Angles require two straight segments 角度には直線セグメントが 2 つ必要です
+@msg Choose 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 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 option {k} 不明な寸法オプション {k}
+@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..0365abac3 100644
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -4345,5 +4345,52 @@ lighting Light 광원
Ambient Light 주변광
Light behind the object 개체 뒤의 조명
+# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
+ Dimension 치수
+ Drawing : real size — 1:10 labels 1 unit on the page as 10. 도면 : 실제 크기 — 1:10은 페이지의 1단위를 10으로 표시합니다.
+ Preview (1 inch on the page) 미리 보기 (페이지의 1인치)
+ Set as Default 기본값으로 설정
+ out from a highlighted edge to place its dimension | 강조된 가장자리에서 바깥쪽으로 끌어 치수 배치 |
+ cancels | Double-click the tool for options 취소 | 도구를 두 번 클릭하면 옵션
+ Linear 선형
+ Angular 각도
+ Away from the Line 선에서 떨어짐
+ Between Line and Object 선과 개체 사이
+ Drag out from a segment, or click two points, then click to place. 세그먼트에서 바깥쪽으로 끌거나, 두 점을 클릭한 다음 클릭하여 배치합니다.
+ Drag out from a corner, or click two straight lines, then click to place. 모서리에서 바깥쪽으로 끌거나, 두 직선을 클릭한 다음 클릭하여 배치합니다.
+ Drag out from a circle or arc for its radius, inwards for its diameter. 원이나 호에서 바깥쪽으로 끌면 반지름, 안쪽으로 끌면 지름입니다.
+@msg Choose a circle or a perfect arc 원 또는 완전한 호를 선택하십시오
+@msg Choose a precision from 0 to 6 정밀도를 0에서 6 사이로 선택하십시오
+ Create Dimension 치수 만들기
+ Dimension Tool 치수 도구
+ Dimension Tool Options 치수 도구 옵션
+ Display 표시
+ Font Family: 글꼴 패밀리:
+ Font Style: 글꼴 스타일:
+ Precision: 정밀도:
+ Update Dimensions 치수 업데이트
+ X: X:
+ Y: Y:
+@msg Angles require two straight segments 각도에는 직선 세그먼트 두 개가 필요합니다
+@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 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 option {k} 알 수 없는 치수 옵션 {k}
+@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..98e0c3a15 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -4651,6 +4651,49 @@ 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).
+ Dimension Wymiar
+ Drawing : real size — 1:10 labels 1 unit on the page as 10. Rysunek : rozmiar rzeczywisty — 1:10 opisuje 1 jednostkę na stronie jako 10.
+ Preview (1 inch on the page) Podgląd (1 cal na stronie)
+ Set as Default Ustaw jako domyślne
+ out from a highlighted edge to place its dimension | od podświetlonej krawędzi na zewnątrz, aby umieścić jej wymiar |
+ cancels | Double-click the tool for options anuluje | Kliknij dwukrotnie narzędzie, aby wyświetlić opcje
+ Angular Kątowy
+ Away from the Line Z dala od linii
+ Between Line and Object Między linią a obiektem
+ Drag out from a segment, or click two points, then click to place. Przeciągnij na zewnątrz od odcinka albo kliknij dwa punkty, a następnie kliknij, aby umieścić.
+ Drag out from a corner, or click two straight lines, then click to place. Przeciągnij na zewnątrz od narożnika albo kliknij dwie proste linie, a następnie kliknij, aby umieścić.
+ Drag out from a circle or arc for its radius, inwards for its diameter. Przeciągnij od okręgu lub łuku na zewnątrz, aby uzyskać promień, do wewnątrz — średnicę.
+@msg Choose a circle or a perfect arc Wybierz okrąg lub idealny łuk
+@msg Choose a precision from 0 to 6 Wybierz dokładność od 0 do 6
+ Create Dimension Utwórz wymiar
+ Dimension Tool Narzędzie Wymiar
+ Dimension Tool Options Opcje narzędzia Wymiar
+ Display Wyświetlanie
+ Font Family: Rodzina czcionek:
+ Font Style: Styl czcionki:
+ Precision: Dokładność:
+ Update Dimensions Aktualizuj wymiary
+@msg Angles require two straight segments Kąty wymagają dwóch prostych odcinków
+@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 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 option {k} Nieznana opcja wymiaru {k}
+@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..4a60aed9f 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -3763,6 +3763,9 @@ axis Both Ambos
Softness Suavidade
Ambient Light Luz ambiente
Light behind the object Luz atrás do objeto
-
+# Dimension tool (linked dimension annotations)
+ 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..0eb092e65 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -4377,5 +4377,52 @@ lighting Light Свет
Ambient Light Окружающий свет
Light behind the object Свет позади объекта
+# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
+ Dimension Размер
+ Drawing : real size — 1:10 labels 1 unit on the page as 10. Чертёж : реальный размер — при 1:10 1 единица на странице подписывается как 10.
+ Preview (1 inch on the page) Просмотр (1 дюйм на странице)
+ Set as Default Установить по умолчанию
+ out from a highlighted edge to place its dimension | наружу от подсвеченного края, чтобы разместить его размер |
+ cancels | Double-click the tool for options отменяет | Двойной щелчок по инструменту открывает параметры
+ Linear Линейный
+ Angular Угловой
+ Away from the Line Вдали от линии
+ Between Line and Object Между линией и объектом
+ Drag out from a segment, or click two points, then click to place. Перетащите наружу от сегмента или щёлкните две точки, затем щёлкните, чтобы разместить.
+ Drag out from a corner, or click two straight lines, then click to place. Перетащите наружу от угла или щёлкните две прямые линии, затем щёлкните, чтобы разместить.
+ Drag out from a circle or arc for its radius, inwards for its diameter. Перетащите от окружности или дуги наружу — радиус, внутрь — диаметр.
+@msg Choose a circle or a perfect arc Выберите окружность или правильную дугу
+@msg Choose a precision from 0 to 6 Выберите точность от 0 до 6
+ Create Dimension Создать размер
+ Dimension Tool Инструмент «Размер»
+ Dimension Tool Options Параметры инструмента «Размер»
+ Display Отображение
+ Font Family: Семейство шрифтов:
+ Font Style: Начертание:
+ Precision: Точность:
+ Update Dimensions Обновить размеры
+ X: X:
+ Y: Y:
+@msg Angles require two straight segments Для угла нужны два прямых сегмента
+@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 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 option {k} Неизвестный параметр размера {k}
+@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..5d5902e11 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -4361,5 +4361,52 @@ lighting Light Світло
Ambient Light Навколишнє світло
Light behind the object Світло позаду об'єкта
+# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
+ Dimension Розмір
+ Drawing : real size — 1:10 labels 1 unit on the page as 10. Креслення : реальний розмір — при 1:10 1 одиниця на сторінці підписується як 10.
+ Preview (1 inch on the page) Попередній перегляд (1 дюйм на сторінці)
+ Set as Default Встановити за замовчуванням
+ out from a highlighted edge to place its dimension | назовні від підсвіченого краю, щоб розмістити його розмір |
+ cancels | Double-click the tool for options скасовує | Подвійне клацання по інструменту відкриває параметри
+ Linear Лінійний
+ Angular Кутовий
+ Away from the Line Далі від лінії
+ Between Line and Object Між лінією та об'єктом
+ Drag out from a segment, or click two points, then click to place. Перетягніть назовні від сегмента або клацніть дві точки, потім клацніть, щоб розмістити.
+ Drag out from a corner, or click two straight lines, then click to place. Перетягніть назовні від кута або клацніть дві прямі лінії, потім клацніть, щоб розмістити.
+ Drag out from a circle or arc for its radius, inwards for its diameter. Перетягніть від кола або дуги назовні — радіус, усередину — діаметр.
+@msg Choose a circle or a perfect arc Виберіть коло або правильну дугу
+@msg Choose a precision from 0 to 6 Виберіть точність від 0 до 6
+ Create Dimension Створити розмір
+ Dimension Tool Інструмент «Розмір»
+ Dimension Tool Options Параметри інструмента «Розмір»
+ Display Відображення
+ Font Family: Сімейство шрифтів:
+ Font Style: Накреслення:
+ Precision: Точність:
+ Update Dimensions Оновити розміри
+ X: X:
+ Y: Y:
+@msg Angles require two straight segments Для кута потрібні два прямі сегменти
+@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 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 option {k} Невідомий параметр розміру {k}
+@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..9d0dd9292 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -4362,6 +4362,50 @@ 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).
+ Dimension Kích thước
+ Drawing : real size — 1:10 labels 1 unit on the page as 10. Bản vẽ : kích thước thực — 1:10 ghi 1 đơn vị trên trang thành 10.
+ Preview (1 inch on the page) Xem trước (1 inch trên trang)
+ Set as Default Đặt làm mặc định
+ out from a highlighted edge to place its dimension | ra ngoài từ một cạnh được tô sáng để đặt kích thước của nó |
+ cancels | Double-click the tool for options để hủy | Nhấp đúp vào công cụ để mở tùy chọn
+ Linear Tuyến tính
+ Angular Góc
+ Away from the Line Cách xa đường
+ Between Line and Object Giữa đường và đối tượng
+ Drag out from a segment, or click two points, then click to place. Kéo ra ngoài từ một đoạn, hoặc nhấp hai điểm, rồi nhấp để đặt.
+ Drag out from a corner, or click two straight lines, then click to place. Kéo ra ngoài từ một góc, hoặc nhấp hai đường thẳng, rồi nhấp để đặt.
+ Drag out from a circle or arc for its radius, inwards for its diameter. Kéo từ đường tròn hoặc cung ra ngoài để lấy bán kính, vào trong để lấy đường kính.
+@msg Choose a circle or a perfect arc Hãy chọn một đường tròn hoặc một cung hoàn hảo
+@msg Choose a precision from 0 to 6 Hãy chọn độ chính xác từ 0 đến 6
+ Create Dimension Tạo kích thước
+ Dimension 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ị
+ Font Family: Họ phông chữ:
+ Font Style: Kiểu phông chữ:
+ Precision: Độ chính xác:
+ 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 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 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 option {k} Tùy chọn kích thước không xác định {k}
+@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..9aa3f05da 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -3957,6 +3957,31 @@ lighting Light 光源
Softness 柔和度
Ambient Light 环境光
Light behind the object 光源位于对象后方
+# Dimension tool (linked dimension annotations)
+ Create Dimension 创建尺寸标注
+ Dimension Tool 尺寸标注工具
+ Dimension Tool Options 尺寸标注工具选项
+ Font Family: 字体系列:
+ Font Style: 字体样式:
+ Precision: 精度:
+ Update Dimensions 更新尺寸标注
+ Display 显示
+# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
+ Dimension 尺寸
+ Drawing : real size — 1:10 labels 1 unit on the page as 10. 图纸 : 实际尺寸 — 1:10 会将页面上的 1 个单位标注为 10。
+ Preview (1 inch on the page) 预览(页面上 1 英寸)
+ Set as Default 设为默认
+ out from a highlighted edge to place its dimension | 从高亮的边向外拖动以放置其尺寸 |
+ cancels | Double-click the tool for options 取消 | 双击工具打开选项
+ Linear 线性
+ Angular 角度
+ Away from the Line 远离尺寸线
+ Between Line and Object 在尺寸线与对象之间
+ Drag out from a segment, or click two points, then click to place. 从线段向外拖动,或单击两个点,然后单击以放置。
+ Drag out from a corner, or click two straight lines, then click to place. 从角向外拖动,或单击两条直线,然后单击以放置。
+ Drag out from a circle or arc for its radius, inwards for its diameter. 从圆或圆弧向外拖动得到半径,向内拖动得到直径。
+@msg Choose a circle or a perfect arc 请选择圆或标准圆弧
+@msg Choose a precision from 0 to 6 请选择 0 到 6 之间的精度
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 封闭开放轮廓的圆形端部。部分旋转的切割侧面保持开放。
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index d305d8162..60a51adba 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -3741,6 +3741,33 @@ lighting Light 光源
Softness 柔和度
Ambient Light 環境光
Light behind the object 光源位於物件後方
+# Dimension tool (linked dimension annotations)
+ Create Dimension 建立尺寸標註
+ Dimension Tool 尺寸標註工具
+ Dimension Tool Options 尺寸標註工具選項
+ Font Family: 字體系列:
+ Font Style: 字體樣式:
+ Precision: 精確度:
+ Update Dimensions 更新尺寸標註
+ Display 顯示
+# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
+ Dimension 尺寸
+ Drawing : real size — 1:10 labels 1 unit on the page as 10. 圖紙 : 實際尺寸 — 1:10 會將頁面上的 1 個單位標示為 10。
+ Preview (1 inch on the page) 預覽(頁面上 1 英寸)
+ Set as Default 設為預設
+ out from a highlighted edge to place its dimension | 從醒目提示的邊緣向外拖曳以放置其尺寸 |
+ cancels | Double-click the tool for options 取消 | 按兩下工具開啟選項
+ Linear 線性
+ Angular 角度
+ Away from the Line 遠離尺寸線
+ Between Line and Object 在尺寸線與物件之間
+ Drag out from a segment, or click two points, then click to place. 從線段向外拖曳,或按一下兩個點,然後按一下以放置。
+ Drag out from a corner, or click two straight lines, then click to place. 從角向外拖曳,或按一下兩條直線,然後按一下以放置。
+ Drag out from a circle or arc for its radius, inwards for its diameter. 從圓形或圓弧向外拖曳得到半徑,向內拖曳得到直徑。
+@msg Choose a circle or a perfect arc 請選擇圓形或標準圓弧
+@msg Choose a precision from 0 to 6 請選擇 0 到 6 之間的精度
+ X: X:
+ Y: Y:
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 封閉開放輪廓的圓形端部。部分旋轉的切割側面保持開放。
diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs
index 016b47227..7f4022b4c 100644
--- a/crates/ui-egui/src/icon_data.rs
+++ b/crates/ui-egui/src/icon_data.rs
@@ -64,6 +64,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("dc-cube", include_bytes!("../../../assets/icons/dc-cube.svg")),
("dc-dash-align", include_bytes!("../../../assets/icons/dc-dash-align.svg")),
("dc-dash-exact", include_bytes!("../../../assets/icons/dc-dash-exact.svg")),
+ ("dc-dimension", include_bytes!("../../../assets/icons/dc-dimension.svg")),
("dc-dir-off", include_bytes!("../../../assets/icons/dc-dir-off.svg")),
("dc-dir-on", include_bytes!("../../../assets/icons/dc-dir-on.svg")),
("dc-direct", include_bytes!("../../../assets/icons/dc-direct.svg")),
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
index 20b8cf279..fb4ea148c 100644
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -82,6 +82,7 @@ pub fn tool_icon(name: &str) -> &'static str {
"tool-gradient" => "dc-gradient",
"tool-eyedropper" => "pipette",
"tool-measure" => "dc-measure",
+ "tool-dimension" => "dc-dimension",
"tool-blend" => "dc-blend",
"tool-symbol" => "dc-symbol-sprayer",
"tool-graph" => "chart-column",
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index 5e50b5fd8..beadd2084 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -60,6 +60,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
type_sections(app, ui);
divider(ui);
}
+ if crate::dialogs::dimension::selected(app).is_some() {
+ crate::dialogs::dimension::properties_section(app, ui);
+ divider(ui);
+ }
appearance_section(app, ui);
divider(ui);
section_header(ui, tl!("Align"));
diff --git a/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