From 8aea665c4e225fd1c9beb81e04428da506ebc21c Mon Sep 17 00:00:00 2001
From: Osama Amin <341642285+osamaamin2011@users.noreply.github.com>
Date: Wed, 14 Oct 2026 13:30:24 +0300
Subject: [PATCH] Dimension tool: the documented gestures, arcs, picas and text
positions; Properties section; review fixes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Drag out from a segment, corner, circle or arc to place a dimension; plain clicks pick two points or two lines. Radial dimensions on perfect arcs, picas, Away/Between and Left/Center/Right text placement. The Properties panel edits a selected dimension, with Apply to All (dimension.applyToAll) and Set as Default. Dimension Selected Paths, the Arc/R/Ø prefixes and the custom defaults are left out. The refresh returns at once in documents without dimensions (Document::dimensions); a linked dimension moved on its own keeps its place; modes, units, line types, arrows and text placement are enums; the tool has its own icon.
---
ASSETS.md | 1 +
assets/icons/dc-dimension.svg | 1 +
crates/doc/src/dimension.rs | 402 ++++++++++++++++++------
crates/doc/src/lib.rs | 5 +
crates/engine/src/cmd/dimension.rs | 298 ++++++++----------
crates/engine/src/tooling.rs | 2 +-
crates/engine/tests/dimensions.rs | 210 ++++++++++---
crates/tools/src/catalog.rs | 2 +-
crates/tools/src/dimension.rs | 392 +++++++++++++++--------
crates/ui-egui/src/chrome.rs | 6 +-
crates/ui-egui/src/dialogs/dimension.rs | 302 +++++++++++-------
crates/ui-egui/src/i18n/cs.tsv | 23 +-
crates/ui-egui/src/i18n/de.tsv | 24 +-
crates/ui-egui/src/i18n/es.tsv | 23 +-
crates/ui-egui/src/i18n/fr.tsv | 23 +-
crates/ui-egui/src/i18n/hu.tsv | 23 +-
crates/ui-egui/src/i18n/it.tsv | 24 +-
crates/ui-egui/src/i18n/ja.tsv | 24 +-
crates/ui-egui/src/i18n/ko.tsv | 24 +-
crates/ui-egui/src/i18n/pl.tsv | 23 +-
crates/ui-egui/src/i18n/pt-br.tsv | 1 -
crates/ui-egui/src/i18n/ru.tsv | 24 +-
crates/ui-egui/src/i18n/uk.tsv | 24 +-
crates/ui-egui/src/i18n/vi.tsv | 24 +-
crates/ui-egui/src/i18n/zh-hans.tsv | 21 +-
crates/ui-egui/src/i18n/zh-hant.tsv | 21 +-
crates/ui-egui/src/icon_data.rs | 1 +
crates/ui-egui/src/icons.rs | 1 +
crates/ui-egui/src/menus.rs | 1 -
crates/ui-egui/src/panels/properties.rs | 4 +
docs/dimensions.md | 88 +++---
31 files changed, 1299 insertions(+), 743 deletions(-)
create mode 100644 assets/icons/dc-dimension.svg
diff --git a/ASSETS.md b/ASSETS.md
index d4804d903..11e5602ea 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -117,6 +117,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-cube.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dash-align.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dash-exact.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-dimension.svg` | Osama Amin | Original work, drawn for VectorCraft: a dimension line with arrowheads between two extension lines | MIT OR Apache-2.0 | |
| `assets/icons/dc-dir-off.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dir-on.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-direct.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
diff --git a/assets/icons/dc-dimension.svg b/assets/icons/dc-dimension.svg
new file mode 100644
index 000000000..9a00f9d89
--- /dev/null
+++ b/assets/icons/dc-dimension.svg
@@ -0,0 +1 @@
+
diff --git a/crates/doc/src/dimension.rs b/crates/doc/src/dimension.rs
index 9e0762ed5..81713baf1 100644
--- a/crates/doc/src/dimension.rs
+++ b/crates/doc/src/dimension.rs
@@ -1,23 +1,142 @@
//! Persistent dimension recipes. Children are ordinary paths and type for all renderers/exporters.
use crate::{Document, NodeId, Unit};
use serde::{Deserialize, Serialize};
-use vectorcraft_geom::{Affine, Point};
+use vectorcraft_geom::{Affine, Point, Vec2};
+
+/// What a dimension measures.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum DimensionMode {
+ #[default]
+ Linear,
+ Angular,
+ Radius,
+ Diameter,
+}
+
+impl DimensionMode {
+ pub fn id(self) -> &'static str {
+ match self {
+ Self::Linear => "linear",
+ Self::Angular => "angular",
+ Self::Radius => "radius",
+ Self::Diameter => "diameter",
+ }
+ }
+}
+
+/// The unit a length is labelled in.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum LengthUnit {
+ #[default]
+ Pt,
+ Pc,
+ Px,
+ In,
+ Mm,
+ Cm,
+ M,
+ Ft,
+ Yd,
+}
+
+impl LengthUnit {
+ pub const ALL: [LengthUnit; 9] = [
+ LengthUnit::In,
+ LengthUnit::Mm,
+ LengthUnit::Pt,
+ LengthUnit::Pc,
+ LengthUnit::Px,
+ LengthUnit::Cm,
+ LengthUnit::M,
+ LengthUnit::Ft,
+ LengthUnit::Yd,
+ ];
+ pub fn unit(self) -> Unit {
+ match self {
+ Self::Pt => Unit::Points,
+ Self::Pc => Unit::Picas,
+ Self::Px => Unit::Pixels,
+ Self::In => Unit::Inches,
+ Self::Mm => Unit::Millimeters,
+ Self::Cm => Unit::Centimeters,
+ Self::M => Unit::Meters,
+ Self::Ft => Unit::Feet,
+ Self::Yd => Unit::Yards,
+ }
+ }
+ /// The suffix written after a length.
+ pub fn suffix(self) -> &'static str {
+ match self {
+ Self::Pt => "pt",
+ Self::Pc => "pc",
+ Self::Px => "px",
+ Self::In => "in",
+ Self::Mm => "mm",
+ Self::Cm => "cm",
+ Self::M => "m",
+ Self::Ft => "ft",
+ Self::Yd => "yd",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum LineType {
+ #[default]
+ Solid,
+ Dashed,
+ Dotted,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum ArrowStyle {
+ #[default]
+ Filled,
+ Open,
+ Tick,
+ None,
+}
+
+/// Where the label sits across the dimension line.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum TextPosition {
+ /// On the side of the dimension line away from the measured object.
+ #[default]
+ Away,
+ /// Between the dimension line and the measured object.
+ Between,
+}
+
+/// Where the label sits along the dimension line.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum TextAlignment {
+ Left,
+ #[default]
+ Center,
+ Right,
+}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
pub struct DimensionOptions {
- pub units: String,
+ pub units: LengthUnit,
pub precision: usize,
/// Drawing : real size. 1:10 multiplies the drawn length by ten.
pub scale: String,
pub hide_units: bool,
pub line_weight: f64,
- pub line_type: String,
+ pub line_type: LineType,
pub line_color: [u8; 3],
- pub arrow_style: String,
+ pub arrow_style: ArrowStyle,
pub arrow_scale: f64,
pub extension_weight: f64,
- pub extension_type: String,
+ pub extension_type: LineType,
pub extension_color: [u8; 3],
pub extension_offset: f64,
pub extension_length: f64,
@@ -26,54 +145,43 @@ pub struct DimensionOptions {
pub font_style: String,
pub font_size: f64,
pub text_color: [u8; 3],
- pub text_position: String,
- pub text_alignment: String,
+ pub text_position: TextPosition,
+ pub text_alignment: TextAlignment,
pub text_offset: f64,
}
impl Default for DimensionOptions {
fn default() -> Self {
+ // The document's own type defaults: a font every build has, black, in points.
+ let type_style = crate::CharStyle::default();
Self {
- units: "cm".into(),
- precision: 1,
+ units: LengthUnit::Pt,
+ precision: 2,
scale: "1:1".into(),
hide_units: false,
- line_weight: 1.0,
- line_type: "solid".into(),
- line_color: [224, 55, 65],
- arrow_style: "filled".into(),
+ line_weight: 0.5,
+ line_type: LineType::Solid,
+ line_color: [0, 0, 0],
+ arrow_style: ArrowStyle::Filled,
arrow_scale: 100.0,
extension_weight: 0.5,
- extension_type: "solid".into(),
- extension_color: [224, 55, 65],
- extension_offset: 5.0,
- extension_length: 9.0,
+ extension_type: LineType::Solid,
+ extension_color: [0, 0, 0],
+ extension_offset: 4.0,
+ extension_length: 6.0,
hide_extensions: false,
- font_family: "Arial".into(),
- font_style: "Regular".into(),
- font_size: 14.0,
- text_color: [224, 55, 65],
- text_position: "above".into(),
- text_alignment: "aligned".into(),
- text_offset: 9.0,
+ font_family: type_style.font_family,
+ font_style: type_style.font_style,
+ font_size: type_style.size,
+ text_color: [0, 0, 0],
+ text_position: TextPosition::Away,
+ text_alignment: TextAlignment::Center,
+ text_offset: 4.0,
}
}
}
impl DimensionOptions {
- pub fn unit(&self) -> Option {
- Some(match self.units.as_str() {
- "pt" => Unit::Points,
- "px" => Unit::Pixels,
- "mm" => Unit::Millimeters,
- "cm" => Unit::Centimeters,
- "m" => Unit::Meters,
- "in" => Unit::Inches,
- "ft" => Unit::Feet,
- "yd" => Unit::Yards,
- _ => return None,
- })
- }
pub fn factor(&self) -> Result {
let (a, b) = self.scale.split_once(':').ok_or("Scale must be drawing:real, for example 1:10")?;
let parse = |s: &str| s.trim().parse::().ok().filter(|n| n.is_finite() && *n > 0.0 && *n <= 1e9);
@@ -87,8 +195,8 @@ impl DimensionOptions {
}
pub fn validate(&self) -> Result<(), String> {
self.factor()?;
- if self.unit().is_none() || self.precision > 6 {
- return Err("Choose a length unit and precision from 0 to 6".into());
+ if self.precision > 6 {
+ return Err("Choose a precision from 0 to 6".into());
}
for (v, min, max) in [
(self.line_weight, 0.1, 100.0),
@@ -103,22 +211,15 @@ impl DimensionOptions {
return Err("Dimension size is outside its supported range".into());
}
}
- if !["solid", "dashed", "dotted"].contains(&self.line_type.as_str())
- || !["solid", "dashed", "dotted"].contains(&self.extension_type.as_str())
- || !["filled", "open", "tick", "none"].contains(&self.arrow_style.as_str())
- || !["above", "center", "below"].contains(&self.text_position.as_str())
- || !["aligned", "horizontal"].contains(&self.text_alignment.as_str())
- {
- return Err("Unknown dimension style".into());
- }
if self.font_family.is_empty() || self.font_family.len() > 256 || self.font_style.len() > 128 {
return Err("Invalid dimension font name".into());
}
Ok(())
}
- pub fn label(&self, points: f64, angle: bool) -> Result {
+ /// `value` in points for a length, in degrees for an angle.
+ pub fn label(&self, value: f64, angle: bool) -> Result {
self.validate()?;
- let value = if angle { points } else { points * self.factor()? / self.unit().ok_or("Unknown unit")?.points() };
+ let value = if angle { value } else { value * self.factor()? / self.units.unit().points() };
if !value.is_finite() {
return Err("Non-finite dimension".into());
}
@@ -127,7 +228,7 @@ impl DimensionOptions {
} else if angle {
"°"
} else {
- self.units.as_str()
+ self.units.suffix()
};
let sep = if suffix.is_empty() || angle { "" } else { " " };
Ok(format!("{:.*}{sep}{suffix}", self.precision, value))
@@ -143,30 +244,52 @@ pub struct SegmentRef {
}
impl SegmentRef {
- pub fn endpoints(&self, doc: &Document) -> Option<(Point, Point, f64)> {
+ /// The segment's two end points.
+ pub fn endpoints(&self, doc: &Document) -> Option<(Point, Point)> {
let sp = doc.node(self.node)?.path_data()?.subpaths.get(self.subpath)?;
if self.segment >= sp.segment_count() {
return None;
}
let seg = sp.segment(self.segment);
- use vectorcraft_geom::kurbo::ParamCurveArclen;
- Some((seg.p0, seg.p3, if sp.segment_is_line(self.segment) { seg.p0.distance(seg.p3) } else { seg.arclen(1e-5) }))
+ Some((seg.p0, seg.p3))
+ }
+ /// Whether the segment is a straight line.
+ pub fn is_straight(&self, doc: &Document) -> bool {
+ doc.node(self.node)
+ .and_then(|n| n.path_data())
+ .and_then(|p| p.subpaths.get(self.subpath))
+ .is_some_and(|sp| self.segment < sp.segment_count() && sp.segment_is_line(self.segment))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
pub enum DimensionSource {
- Segment { edge: SegmentRef },
- Angle { first: SegmentRef, second: SegmentRef },
- Circle { node: NodeId },
- Points { a: Option, b: Option, vertex: Option },
+ Segment {
+ edge: SegmentRef,
+ },
+ Angle {
+ first: SegmentRef,
+ second: SegmentRef,
+ },
+ Circle {
+ node: NodeId,
+ },
+ /// One segment that is a perfect arc.
+ Arc {
+ edge: SegmentRef,
+ },
+ Points {
+ a: Option,
+ b: Option,
+ vertex: Option,
+ },
}
impl DimensionSource {
pub fn nodes(&self) -> Vec {
match self {
- Self::Segment { edge } => vec![edge.node],
+ Self::Segment { edge } | Self::Arc { edge } => vec![edge.node],
Self::Angle { first, second } => vec![first.node, second.node],
Self::Circle { node } => vec![*node],
Self::Points { a, b, vertex } => [a, b, vertex].into_iter().filter_map(|p| p.as_ref().map(|r| r.node)).collect(),
@@ -205,8 +328,7 @@ impl PointRef {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Dimension {
- /// linear, angular, radius or diameter.
- pub mode: String,
+ pub mode: DimensionMode,
pub a: Point,
pub b: Point,
/// Angular vertex. For radius/diameter a is the centre and b lies on the circle.
@@ -215,15 +337,58 @@ pub struct Dimension {
pub length: f64,
pub options: DimensionOptions,
pub source: Option,
+ /// How far a linked dimension was moved on its own since the last refresh: the refresh turns
+ /// it into a new placement unless the measured art moved too.
+ #[serde(skip)]
+ pub moved: Option,
}
impl Dimension {
+ /// A point on the drawn dimension line (linear), arc (angular) or radius line.
+ pub fn handle(&self) -> Point {
+ match self.mode {
+ DimensionMode::Linear => self.a.midpoint(self.b) + self.normal() * self.offset,
+ DimensionMode::Angular => self.vertex + self.bisector() * self.offset.abs().max(12.0),
+ DimensionMode::Radius | DimensionMode::Diameter => self.b,
+ }
+ }
+ /// The left normal of a → b.
+ pub fn normal(&self) -> Vec2 {
+ let u = self.b - self.a;
+ let len = u.hypot();
+ if len > 1e-9 { Vec2::new(-u.y, u.x) / len } else { Vec2::new(0.0, 1.0) }
+ }
+ fn bisector(&self) -> Vec2 {
+ let start = (self.a - self.vertex).atan2();
+ Vec2::from_angle(start + self.angle() * 0.5)
+ }
+ /// Places the dimension through `p`: the line's distance from the art (linear), the arc's
+ /// radius (angular) or the label's direction (radial).
+ pub fn place(&mut self, p: Point) {
+ match self.mode {
+ DimensionMode::Linear => self.offset = (p - self.a).dot(self.normal()),
+ DimensionMode::Angular => self.offset = p.distance(self.vertex).max(12.0),
+ DimensionMode::Radius | DimensionMode::Diameter => {
+ if p.distance(self.a) > 1e-6 {
+ let r = self.a.distance(self.b);
+ let u = p - self.a;
+ self.b = self.a + u / u.hypot() * r;
+ }
+ }
+ }
+ }
pub fn transform(&mut self, xf: Affine) {
- // Linked recipes are resolved again from the source by the engine.
- if self.source.is_none() {
- self.a = xf * self.a;
- self.b = xf * self.b;
- self.vertex = xf * self.vertex;
+ if self.source.is_some() {
+ // The art it measures decides where its ends are: record the move, and the refresh
+ // after this edit turns it into a new placement unless the art moved with it.
+ let at = self.handle();
+ self.moved = Some((xf * at) - at + self.moved.unwrap_or_default());
+ return;
+ }
+ self.a = xf * self.a;
+ self.b = xf * self.b;
+ self.vertex = xf * self.vertex;
+ if self.mode == DimensionMode::Linear {
self.length = self.a.distance(self.b);
}
}
@@ -238,24 +403,19 @@ impl Dimension {
self.length = self.a.distance(self.b);
}
Some(DimensionSource::Segment { edge }) => {
- let (a, b, length) = edge.endpoints(doc).ok_or("Measured segment no longer exists")?;
+ let (a, b) = edge.endpoints(doc).ok_or("Measured segment no longer exists")?;
self.a = a;
self.b = b;
- self.length = length;
+ self.length = a.distance(b);
}
Some(DimensionSource::Angle { first, second }) => {
for edge in [first, second] {
- let sp = doc
- .node(edge.node)
- .and_then(|n| n.path_data())
- .and_then(|p| p.subpaths.get(edge.subpath))
- .ok_or("Measured path no longer exists")?;
- if edge.segment >= sp.segment_count() || !sp.segment_is_line(edge.segment) {
+ if !edge.is_straight(doc) {
return Err("Angles require two straight segments".into());
}
}
- let (a, b, _) = first.endpoints(doc).ok_or("First measured segment no longer exists")?;
- let (c, d, _) = second.endpoints(doc).ok_or("Second measured segment no longer exists")?;
+ let (a, b) = first.endpoints(doc).ok_or("First measured segment no longer exists")?;
+ let (c, d) = second.endpoints(doc).ok_or("Second measured segment no longer exists")?;
let u = b - a;
let v = d - c;
let den = u.cross(v);
@@ -268,17 +428,24 @@ impl Dimension {
self.b = if c.distance(p) > d.distance(p) { c } else { d };
}
Some(DimensionSource::Circle { node }) => {
- let (center, radius) = circle(doc, *node).ok_or("Choose a circular path")?;
- let direction = self.b - self.a;
- let dir = if direction.hypot() > 1e-9 { direction / direction.hypot() } else { vectorcraft_geom::Vec2::new(1.0, 0.0) };
- self.a = center;
- self.b = center + dir * radius;
- self.length = radius;
+ let (center, radius) = circle(doc, *node).ok_or("Choose a circle or a perfect arc")?;
+ self.set_radial(center, radius);
+ }
+ Some(DimensionSource::Arc { edge }) => {
+ let (center, radius) = arc(doc, edge).ok_or("Choose a circle or a perfect arc")?;
+ self.set_radial(center, radius);
}
None => {}
}
self.validate()
}
+ fn set_radial(&mut self, center: Point, radius: f64) {
+ let direction = self.b - self.a;
+ let dir = if direction.hypot() > 1e-9 { direction / direction.hypot() } else { Vec2::new(1.0, 0.0) };
+ self.a = center;
+ self.b = center + dir * radius;
+ self.length = radius;
+ }
pub fn validate(&self) -> Result<(), String> {
self.options.validate()?;
for p in [self.a, self.b, self.vertex] {
@@ -289,13 +456,10 @@ impl Dimension {
if !self.offset.is_finite() || self.offset.abs() > 1e6 || !self.length.is_finite() || self.length < 0.0 || self.length > 1e9 {
return Err("Invalid dimension distance".into());
}
- if !["linear", "angular", "radius", "diameter"].contains(&self.mode.as_str()) {
- return Err("Unknown dimension mode".into());
- }
if self.a.distance(self.b) < 1e-6 {
return Err("Choose two different points".into());
}
- if self.mode == "angular" && (self.a.distance(self.vertex) < 1e-6 || self.b.distance(self.vertex) < 1e-6) {
+ if self.mode == DimensionMode::Angular && (self.a.distance(self.vertex) < 1e-6 || self.b.distance(self.vertex) < 1e-6) {
return Err("Choose two nonzero angle arms".into());
}
Ok(())
@@ -306,13 +470,13 @@ impl Dimension {
u.cross(v).atan2(u.dot(v))
}
pub fn label(&self) -> Result {
- let (value, prefix) = match self.mode.as_str() {
- "angular" => (self.angle().abs().to_degrees(), ""),
- "radius" => (self.a.distance(self.b), "R "),
- "diameter" => (self.a.distance(self.b) * 2.0, "Ø "),
- _ => (self.length, if self.length > self.a.distance(self.b) + 0.01 { "Arc " } else { "" }),
+ let value = match self.mode {
+ DimensionMode::Angular => self.angle().abs().to_degrees(),
+ DimensionMode::Radius => self.a.distance(self.b),
+ DimensionMode::Diameter => self.a.distance(self.b) * 2.0,
+ DimensionMode::Linear => self.a.distance(self.b),
};
- Ok(format!("{prefix}{}", self.options.label(value, self.mode == "angular")?))
+ self.options.label(value, self.mode == DimensionMode::Angular)
}
}
@@ -339,3 +503,59 @@ pub fn circle(doc: &Document, id: NodeId) -> Option<(Point, f64)> {
}
Some((center, radius))
}
+
+/// A curved segment that follows one circle (a perfect arc): its centre and radius.
+pub fn arc(doc: &Document, edge: &SegmentRef) -> Option<(Point, f64)> {
+ use vectorcraft_geom::kurbo::ParamCurve;
+ let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?;
+ if edge.segment >= sp.segment_count() || sp.segment_is_line(edge.segment) {
+ return None;
+ }
+ let seg = sp.segment(edge.segment);
+ let (p, q, r) = (seg.p0, seg.eval(0.5), seg.p3);
+ // The circle through the ends and the middle of the segment.
+ let d = 2.0 * (p.x * (q.y - r.y) + q.x * (r.y - p.y) + r.x * (p.y - q.y));
+ if d.abs() < 1e-9 {
+ return None;
+ }
+ let sq = |a: Point| a.x * a.x + a.y * a.y;
+ let center = Point::new(
+ (sq(p) * (q.y - r.y) + sq(q) * (r.y - p.y) + sq(r) * (p.y - q.y)) / d,
+ (sq(p) * (r.x - q.x) + sq(q) * (p.x - r.x) + sq(r) * (q.x - p.x)) / d,
+ );
+ let radius = center.distance(p);
+ if !radius.is_finite() || !(1e-6..=1e8).contains(&radius) {
+ return None;
+ }
+ for t in [0.125, 0.25, 0.375, 0.625, 0.75, 0.875] {
+ if (seg.eval(t).distance(center) - radius).abs() > radius * 0.002 {
+ return None;
+ }
+ }
+ Some((center, radius))
+}
+
+/// The two straight segments that meet at a corner anchor, if both are straight.
+pub fn corner(doc: &Document, node: NodeId, subpath: usize, anchor: usize) -> Option<(SegmentRef, SegmentRef)> {
+ let sp = doc.node(node)?.path_data()?.subpaths.get(subpath)?;
+ let count = sp.segment_count();
+ let n = sp.anchors.len();
+ if count < 2 || anchor >= n {
+ return None;
+ }
+ // Segment i runs from anchor i to anchor i + 1 (wrapping on closed paths).
+ let before = if anchor > 0 {
+ anchor - 1
+ } else if sp.closed {
+ count - 1
+ } else {
+ return None;
+ };
+ let after = anchor;
+ if after >= count {
+ return None;
+ }
+ let first = SegmentRef { node, subpath, segment: before };
+ let second = SegmentRef { node, subpath, segment: after };
+ (first.is_straight(doc) && second.is_straight(doc)).then_some((first, second))
+}
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index 17c7def44..9c2670329 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -687,6 +687,10 @@ pub struct Document {
/// state, never saved.
#[serde(skip)]
pub puppet: Option>,
+ /// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this
+ /// is set, so documents without any pay nothing for them.
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub dimensions: bool,
/// Where each node is in the tree, for lookups by id ([`id_index`]): a cache, never saved.
#[serde(skip)]
index: id_index::IdIndex,
@@ -767,6 +771,7 @@ impl Document {
print_setup: None,
assets: vec![],
puppet: None,
+ dimensions: false,
index: Default::default(),
};
let id = d.alloc_id();
diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs
index a083ee823..2e3ffae73 100644
--- a/crates/engine/src/cmd/dimension.rs
+++ b/crates/engine/src/cmd/dimension.rs
@@ -3,7 +3,7 @@ use super::*;
use serde_json::{Value, json};
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
-use vectorcraft_doc::dimension::{Dimension, DimensionOptions, DimensionSource, SegmentRef};
+use vectorcraft_doc::dimension::{ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, TextAlignment, TextPosition};
use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, Dash, Document, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
@@ -14,28 +14,28 @@ pub fn specs() -> Vec {
"Create Dimension",
[],
None,
- "{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle,first: edge,second: edge}|{kind: circle,node}, offset?: pt, options?: {units,precision,scale,hideUnits,lineWeight,lineType,lineColor,arrowStyle,arrowScale,extensionWeight,extensionType,extensionColor,extensionOffset,extensionLength,hideExtensions,fontFamily,fontStyle,fontSize,textColor,textPosition,textAlignment,textOffset}} → {id,label}; scale is drawing:real; linked measurements follow source edits",
+ "{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}}, offset?: pt, options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits",
has_doc,
create
),
- cmd!(
- "dimension.selection",
- "Dimension Selected Paths",
- ["Object", "Dimensions"],
- None,
- "{offset?: pt = 30, options?: dimension options} add a linked dimension to every segment of the selected paths (up to 1000), as one undo step → {ids}",
- has_selection,
- selection
- ),
cmd!(
"dimension.update",
"Update Dimensions",
[],
None,
- "{ids?: [dimension ids] (default selection), options?: dimension options, offset?: pt} edit existing dimensions → {ids}",
+ "{ids?: [dimension ids] (default selection), options?: dimension options, offset?: pt} restyle or move existing dimensions → {ids}",
has_doc,
update
),
+ cmd!(
+ "dimension.applyToAll",
+ "Apply to All",
+ [],
+ None,
+ "{id?: dimension id (default the first selected dimension)} gives every dimension in the document that dimension's style (its scale too), as one undo step → {ids}",
+ has_doc,
+ apply_to_all
+ ),
]
}
@@ -65,30 +65,9 @@ fn distance(p: &Value, fallback: f64) -> Result {
}
}
-/// Return the signed winding area of a closed path subpath. Positive area means
-/// the path interior is to the left of its directed segments, which lets the
-/// automatic dimension command put its positive (left-normal) offset outside.
-fn subpath_area(path: &PathData, subpath: usize) -> Option {
- use vectorcraft_geom::kurbo::ParamCurve;
- let sp = path.subpaths.get(subpath)?;
- if !sp.closed || sp.segment_count() == 0 {
- return None;
- }
- let mut area = 0.0;
- for index in 0..sp.segment_count() {
- let segment = sp.segment(index);
- let mut previous = segment.eval(0.0);
- for step in 1..=16 {
- let current = segment.eval(step as f64 / 16.0);
- area += previous.x * current.y - current.x * previous.y;
- previous = current;
- }
- }
- (area.is_finite() && area.abs() > 1e-9).then_some(area * 0.5)
-}
-
fn recipe(s: &Session, p: &Value) -> Result {
- let mode = p.get("mode").map_or(Ok("linear"), |v| v.as_str().ok_or_else(|| error("Invalid dimension mode")))?;
+ let mode: DimensionMode =
+ p.get("mode").map(|v| serde_json::from_value(v.clone()).map_err(|_| error("Unknown dimension mode"))).transpose()?.unwrap_or_default();
let source: Option = p.get("source").map(|v| serde_json::from_value(v.clone()).map_err(error)).transpose()?;
if let Some(source) = &source {
let doc = &s.doc()?.doc;
@@ -97,26 +76,25 @@ fn recipe(s: &Session, p: &Value) -> Result {
return Err(error("Choose original artwork, not a dimension's generated paths"));
}
}
- }
- if let Some(source) = &source {
let compatible = matches!(
(mode, source),
- ("linear", DimensionSource::Segment { .. })
- | ("angular", DimensionSource::Angle { .. })
- | ("radius" | "diameter", DimensionSource::Circle { .. })
+ (DimensionMode::Linear, DimensionSource::Segment { .. })
+ | (DimensionMode::Angular, DimensionSource::Angle { .. })
+ | (DimensionMode::Radius | DimensionMode::Diameter, DimensionSource::Circle { .. } | DimensionSource::Arc { .. })
+ | (DimensionMode::Linear | DimensionMode::Angular, DimensionSource::Points { .. })
);
- if !compatible && !matches!((mode, source), ("linear" | "angular", DimensionSource::Points { .. })) {
+ if !compatible {
return Err(error("Dimension mode does not match its source"));
}
}
let a = if source.is_none() || p.get("a").is_some() { point(p, "a")? } else { Point::ZERO };
let b = if source.is_none() || p.get("b").is_some() { point(p, "b")? } else { Point::new(1.0, 0.0) };
- let vertex = if mode == "angular" && (source.is_none() || matches!(source, Some(DimensionSource::Points { .. }))) {
+ let vertex = if mode == DimensionMode::Angular && (source.is_none() || matches!(source, Some(DimensionSource::Points { .. }))) {
point(p, "vertex")?
} else {
Point::ZERO
};
- let mut d = Dimension { mode: mode.into(), a, b, vertex, offset: distance(p, 30.0)?, length: a.distance(b), options: options(s, p)?, source };
+ let mut d = Dimension { mode, a, b, vertex, offset: distance(p, 30.0)?, length: a.distance(b), options: options(s, p)?, source, moved: None };
d.resolve(&s.doc()?.doc).map_err(error)?;
Ok(d)
}
@@ -126,6 +104,7 @@ fn insert(doc: &mut Document, parent: Option, dim: Dimension) -> Result<
let mut group = Node::new(id, NodeKind::Group { children: vec![], clip: false });
group.name = Some(format!("Dimension · {}", dim.label().map_err(error)?));
group.kind = NodeKind::Group { children: artwork(doc, &dim, &[])?, clip: false };
+ doc.dimensions = true;
group.extras_mut().dimension = Some(Box::new(dim));
doc.insert(parent, usize::MAX, group)?;
Ok(id)
@@ -144,79 +123,8 @@ fn create(s: &mut Session, p: &Value) -> Result {
Ok(json!({"id":id.0,"label":label}))
}
-fn selection(s: &mut Session, p: &Value) -> Result {
- let o = options(s, p)?;
- let offset = distance(p, 30.0)?;
- let st = s.doc()?;
- let mut refs = Vec::new();
- fn gather(n: &Node, refs: &mut Vec) -> Result<()> {
- if !n.visible || n.locked || n.extras().dimension.is_some() {
- return Ok(());
- }
- if let Some(path) = n.path_data() {
- for (si, sp) in path.subpaths.iter().enumerate() {
- for seg in 0..sp.segment_count() {
- if refs.len() >= 1000 {
- return Err(error("Select at most 1000 segments at a time"));
- }
- if sp.segment(seg).p0.distance(sp.segment(seg).p3) > 1e-6 {
- refs.push(SegmentRef { node: n.id, subpath: si, segment: seg });
- }
- }
- }
- }
- if let Some(children) = n.children() {
- for c in children {
- gather(c, refs)?;
- }
- }
- Ok(())
- }
- for id in &st.selection.objects {
- if !st.doc.is_editable(*id) {
- continue;
- }
- if let Some(n) = st.doc.node(*id) {
- gather(n, &mut refs)?;
- }
- }
- refs.sort_by_key(|r| (r.node, r.subpath, r.segment));
- refs.dedup();
- if refs.is_empty() {
- return Err(error("Select one or more paths first"));
- }
- let parent = st.insertion_parent();
- let offsets: Vec = refs
- .iter()
- .map(|edge| {
- st.doc
- .node(edge.node)
- .and_then(Node::path_data)
- .and_then(|path| subpath_area(path, edge.subpath))
- .map_or(offset, |area| -area.signum() * offset.abs())
- })
- .collect();
- s.note_journal("options", serde_json::to_value(&o).map_err(error)?);
- let ids = s.edit("Dimension Selected Paths", |doc, sel| {
- let mut ids = Vec::new();
- for (edge, edge_offset) in refs.into_iter().zip(offsets) {
- let mut dim = Dimension {
- mode: "linear".into(),
- a: Point::ZERO,
- b: Point::ZERO,
- vertex: Point::ZERO,
- offset: edge_offset,
- length: 0.0,
- options: o.clone(),
- source: Some(DimensionSource::Segment { edge }),
- };
- dim.resolve(doc).map_err(error)?;
- ids.push(insert(doc, parent, dim)?);
- }
- sel.set(ids.clone());
- Ok(ids)
- })?;
- Ok(json!({"ids":ids}))
+fn dimension_of(doc: &Document, id: NodeId) -> Result {
+ doc.node(id).and_then(|n| n.extras().dimension.as_deref()).cloned().ok_or_else(|| error("Select only dimensions"))
}
fn update(s: &mut Session, p: &Value) -> Result {
@@ -224,8 +132,8 @@ fn update(s: &mut Session, p: &Value) -> Result {
Some(v) => serde_json::from_value(v.clone()).map_err(error)?,
None => s.doc()?.selection.objects.clone(),
};
- if ids.is_empty() || ids.len() > 1000 {
- return Err(error("Select dimensions to update (at most 1000)"));
+ if ids.is_empty() || ids.len() > 10_000 {
+ return Err(error("Select dimensions to update"));
}
let o = if p.get("options").is_some() { Some(options(s, p)?) } else { None };
s.edit("Update Dimensions", |doc, _| {
@@ -233,8 +141,7 @@ fn update(s: &mut Session, p: &Value) -> Result {
if !doc.is_editable(*id) {
return Err(error("Dimension is locked or hidden"));
}
- let mut dim =
- doc.node(*id).and_then(|n| n.extras().dimension.as_deref()).cloned().ok_or_else(|| error("Select only dimension groups"))?;
+ let mut dim = dimension_of(doc, *id)?;
if let Some(o) = &o {
dim.options = o.clone();
}
@@ -247,6 +154,36 @@ fn update(s: &mut Session, p: &Value) -> Result {
Ok(json!({"ids":ids}))
}
+fn apply_to_all(s: &mut Session, p: &Value) -> Result {
+ let st = s.doc()?;
+ let from = match p.get("id") {
+ Some(v) => NodeId(v.as_u64().ok_or_else(|| error("id must be a dimension id"))?),
+ None => *st
+ .selection
+ .objects
+ .iter()
+ .find(|id| st.doc.node(**id).is_some_and(|n| n.extras().dimension.is_some()))
+ .ok_or_else(|| error("Select a dimension first"))?,
+ };
+ let o = dimension_of(&st.doc, from)?.options;
+ let mut ids = vec![];
+ st.doc.walk(|n| {
+ if n.extras().dimension.is_some() && st.doc.is_editable(n.id) {
+ ids.push(n.id);
+ }
+ });
+ s.edit("Apply to All", |doc, _| {
+ for id in &ids {
+ let mut dim = dimension_of(doc, *id)?;
+ dim.options = o.clone();
+ dim.resolve(doc).map_err(error)?;
+ replace(doc, *id, dim)?;
+ }
+ Ok(())
+ })?;
+ Ok(json!({"ids":ids}))
+}
+
fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> {
let old: Vec = doc.node(id).and_then(Node::children).map(|ch| ch.iter().map(|n| n.id).collect()).unwrap_or_default();
let children = artwork(doc, &dim, &old)?;
@@ -258,21 +195,39 @@ fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> {
Ok(())
}
-/// Called inside the source edit transaction, so its dimensions and undo history stay in sync.
+/// Called inside every edit, so dimensions and their undo history stay in step with the art they
+/// measure. Returns at once in documents without dimensions.
pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> {
+ if !doc.dimensions {
+ return Ok(());
+ }
let mut dimensions = Vec::new();
doc.walk(|n| {
if let Some(d) = &n.extras().dimension {
dimensions.push((n.id, (**d).clone()));
}
});
+ doc.dimensions = !dimensions.is_empty();
for (id, old) in dimensions {
let mut next = old.clone();
+ let moved = next.moved.take();
if next.source.as_ref().is_some_and(|s| s.topology_changed(before, doc)) || next.resolve(doc).is_err() {
// A deleted/degenerate source has no valid measurement. Remove its annotation in
// this same undo step instead of keeping a silently wrong number.
doc.remove(id)?;
- } else if next != old || before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next) {
+ continue;
+ }
+ // Moved on its own (the art it measures stayed put): it keeps its new place.
+ if let Some(v) = moved
+ && (next.a, next.b, next.vertex) == (old.a, old.b, old.vertex)
+ {
+ next.place(next.handle() + v);
+ if next.resolve(doc).is_err() {
+ next = old.clone();
+ next.moved = None;
+ }
+ }
+ if next != old || before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next) {
replace(doc, id, next)?;
}
}
@@ -291,23 +246,23 @@ fn line(a: Point, b: Point) -> BezPath {
fn appearance(o: &DimensionOptions, ext: bool, arrows: bool) -> Appearance {
let (color, width, kind) =
- if ext { (o.extension_color, o.extension_weight, o.extension_type.as_str()) } else { (o.line_color, o.line_weight, o.line_type.as_str()) };
+ if ext { (o.extension_color, o.extension_weight, o.extension_type) } else { (o.line_color, o.line_weight, o.line_type) };
let mut a = Appearance::basic(Paint::None, paint(color), width);
if let Some(st) = a.stroke_mut() {
st.dash = match kind {
- "dashed" => Some(Dash { pattern: vec![width * 5.0, width * 3.0], ..Default::default() }),
- "dotted" => {
+ LineType::Dashed => Some(Dash { pattern: vec![width * 5.0, width * 3.0], ..Default::default() }),
+ LineType::Dotted => {
st.cap = vectorcraft_doc::LineCap::Round;
Some(Dash { pattern: vec![0.0, width * 3.0], ..Default::default() })
}
- _ => None,
+ LineType::Solid => None,
};
if arrows {
- let head = match o.arrow_style.as_str() {
- "filled" => Some(Arrowhead::Triangle),
- "open" => Some(Arrowhead::TriangleOpen),
- "tick" => Some(Arrowhead::Slash),
- _ => None,
+ let head = match o.arrow_style {
+ ArrowStyle::Filled => Some(Arrowhead::Triangle),
+ ArrowStyle::Open => Some(Arrowhead::TriangleOpen),
+ ArrowStyle::Tick => Some(Arrowhead::Slash),
+ ArrowStyle::None => None,
};
st.start_arrow = head;
st.end_arrow = head;
@@ -318,6 +273,15 @@ fn appearance(o: &DimensionOptions, ext: bool, arrows: bool) -> Appearance {
a
}
+/// How far along the line (0 start, 0.5 middle, 1 end) the label sits, kept clear of the arrows.
+fn along(o: &DimensionOptions) -> f64 {
+ match o.text_alignment {
+ TextAlignment::Left => 0.2,
+ TextAlignment::Center => 0.5,
+ TextAlignment::Right => 0.8,
+ }
+}
+
fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result>> {
d.validate().map_err(error)?;
let o = &d.options;
@@ -325,13 +289,9 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result = Vec::new();
let mut angle = 0.0;
let text_at;
- let side = if d.offset < 0.0 { -1.0 } else { 1.0 };
- let text_shift = match o.text_position.as_str() {
- "below" => o.text_offset + o.font_size,
- "center" => o.font_size * 0.35,
- _ => -o.text_offset,
- };
- if d.mode == "angular" {
+ // Away from the object: past the dimension line; between: on the object's side of it.
+ let away = o.text_position == TextPosition::Away;
+ if d.mode == DimensionMode::Angular {
let start = (d.a - d.vertex).atan2();
let sweep = d.angle();
let r = d.offset.abs().max(12.0);
@@ -348,32 +308,42 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result (d.a, d.b),
- "diameter" => (d.a - delta, d.b),
+ let (a, b) = match d.mode {
+ DimensionMode::Radius => (d.a, d.b),
+ DimensionMode::Diameter => (d.a - delta, d.b),
_ => (d.a + n * d.offset, d.b + n * d.offset),
};
- if d.mode == "linear" && !o.hide_extensions {
+ // The side of the line facing away from the object.
+ let outward = match d.mode {
+ DimensionMode::Linear => {
+ if d.offset < 0.0 {
+ -n
+ } else {
+ n
+ }
+ }
+ _ => n,
+ };
+ if d.mode == DimensionMode::Linear && !o.hide_extensions {
for (p, q) in [(d.a, a), (d.b, b)] {
- paths.push((line(p + n * (o.extension_offset * side), q + n * (o.extension_length * side)), appearance(o, true, false)));
+ paths.push((line(p + outward * o.extension_offset, q + outward * o.extension_length), appearance(o, true, false)));
}
}
angle = u.atan2();
if !(-std::f64::consts::FRAC_PI_2..=std::f64::consts::FRAC_PI_2).contains(&angle) {
angle += std::f64::consts::PI;
}
- let normal = Vec2::new(-angle.sin(), angle.cos());
- text_at = a.midpoint(b) + normal * text_shift;
+ let shift = o.text_offset + o.font_size * 0.5;
+ text_at = a.lerp(b, along(o)) + outward * if away { shift } else { -shift };
paths.push((line(a, b), appearance(o, false, true)));
}
- if o.text_alignment == "horizontal" {
- angle = 0.0;
- }
let style = CharStyle {
font_family: o.font_family.clone(),
font_style: o.font_style.clone(),
@@ -384,32 +354,10 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result Session {
@@ -30,6 +31,13 @@ fn text(s: &Session, id: u64) -> String {
let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap();
n.children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).unwrap()
}
+fn recipe(s: &Session, id: u64) -> vectorcraft_doc::dimension::Dimension {
+ (**s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().dimension.as_ref().unwrap()).clone()
+}
+fn ev(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
+ s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap();
+}
+
#[test]
fn dimension_length_scale_live_edit_undo_and_roundtrip() {
let mut s = session();
@@ -45,6 +53,7 @@ fn dimension_length_scale_live_edit_undo_and_roundtrip() {
assert_eq!(text(&s, id), "20.00 in");
let json = serde_json::to_string(&*s.doc().unwrap().doc).unwrap();
let doc: Document = serde_json::from_str(&json).unwrap();
+ assert!(doc.dimensions, "saved with the document");
let mut reopened = Session::new();
reopened.add_document(doc, None);
run(&mut reopened, "select.set", json!({"ids":[line]}));
@@ -55,32 +64,38 @@ fn dimension_length_scale_live_edit_undo_and_roundtrip() {
run(&mut reopened, "edit.undo", json!({}));
assert_eq!(text(&reopened, id), "10.00 in");
}
+
+/// Documents without dimensions skip the refresh, and lose the flag when the last goes.
#[test]
-fn dimension_selected_rectangle_and_styles_are_one_undo() {
+fn documents_without_dimensions_skip_the_refresh() {
let mut s = session();
- run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":144}));
- let result = run(
- &mut s,
- "dimension.selection",
- json!({"options":{"units":"in","precision":0,"lineType":"dashed","arrowStyle":"open","hideExtensions":true,"textPosition":"center"}}),
- );
- let ids = result["ids"].as_array().unwrap();
- assert_eq!(ids.len(), 4);
- let labels: Vec<_> = ids.iter().map(|id| text(&s, id.as_u64().unwrap())).collect();
- assert_eq!(labels.iter().filter(|s| s.as_str() == "1 in").count(), 2);
- let id = NodeId(ids[0].as_u64().unwrap());
- let node = s.doc().unwrap().doc.node(id).unwrap();
- assert_eq!(node.children().unwrap().len(), 3); // Split line and text, no extension lines.
- let stroke = node.children().unwrap()[0].appearance.stroke().unwrap();
- assert!(stroke.dash.is_some());
- assert_eq!(stroke.start_arrow, Some(vectorcraft_doc::Arrowhead::TriangleOpen));
- run(&mut s, "edit.undo", json!({}));
- assert!(s.doc().unwrap().doc.node(id).is_none());
- run(&mut s, "edit.redo", json!({}));
- assert!(s.doc().unwrap().doc.node(id).is_some());
+ let line = edge(&mut s);
+ assert!(!s.doc().unwrap().doc.dimensions);
+ let id = dim(&mut s, line);
+ assert!(s.doc().unwrap().doc.dimensions);
+ run(&mut s, "select.set", json!({"ids":[id]}));
+ run(&mut s, "edit.clear", json!({}));
+ run(&mut s, "select.set", json!({"ids":[line]}));
+ run(&mut s, "object.move", json!({"dx":1,"dy":0}));
+ assert!(!s.doc().unwrap().doc.dimensions);
}
+
#[test]
-fn dimension_angles_circles_and_curved_lengths() {
+fn apply_to_all_restyles_every_dimension_in_one_undo_step() {
+ let mut s = session();
+ let line = edge(&mut s);
+ let first = dim(&mut s, line);
+ let second = run(&mut s, "dimension.create", json!({"a":[0,0],"b":[72,0],"options":{"units":"pt","precision":0}}))["id"].as_u64().unwrap();
+ assert_eq!(text(&s, second), "72 pt");
+ run(&mut s, "dimension.applyToAll", json!({"id":first}));
+ assert_eq!(text(&s, second), "10.00 in", "the first dimension's style, its scale too");
+ run(&mut s, "edit.undo", json!({}));
+ assert_eq!(text(&s, second), "72 pt");
+ assert!(s.execute("dimension.applyToAll", &json!({"id":line})).is_err(), "not a dimension");
+}
+
+#[test]
+fn dimension_angles_circles_arcs_and_curves() {
let mut s = session();
let a = run(
&mut s,
@@ -91,18 +106,39 @@ fn dimension_angles_circles_and_curved_lengths() {
.unwrap();
assert_eq!(text(&s, a), "90.0°");
let circle = run(&mut s, "shape.ellipse", json!({"x":100,"y":100,"width":144,"height":144}))["id"].as_u64().unwrap();
- for (mode, want) in [("radius", "R 1.0 in"), ("diameter", "Ø 2.0 in")] {
- let id = run(&mut s, "dimension.create", json!({"mode":mode,"source":{"kind":"circle","node":circle},"options":{"units":"in"}}))["id"]
+ for (mode, want) in [("radius", "1.0 in"), ("diameter", "2.0 in")] {
+ let id = run(
+ &mut s,
+ "dimension.create",
+ json!({"mode":mode,"source":{"kind":"circle","node":circle},"options":{"units":"in","precision":1}}),
+ )["id"]
.as_u64()
.unwrap();
assert_eq!(text(&s, id), want);
}
+ // One quarter of the circle is a perfect arc.
+ let id = run(
+ &mut s,
+ "dimension.create",
+ json!({"mode":"radius","source":{"kind":"arc","edge":{"node":circle,"subpath":0,"segment":0}},"options":{"units":"in","precision":1}}),
+ )["id"]
+ .as_u64()
+ .unwrap();
+ assert_eq!(text(&s, id), "1.0 in");
+ // A curve that isn't an arc: no radius, and its length is measured end to end.
let curve = run(&mut s, "path.create", json!({"d":"M 0 0 C 0 100 100 100 100 0"}))["id"].as_u64().unwrap();
- let id = dim(&mut s, curve);
- assert!(text(&s, id).starts_with("Arc "));
- let d = s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().dimension.as_ref().unwrap();
- assert!((d.length - 200.0).abs() < 0.001);
+ let arc = json!({"mode":"radius","source":{"kind":"arc","edge":{"node":curve,"subpath":0,"segment":0}}});
+ assert!(s.execute("dimension.create", &arc).is_err());
+ let id = run(
+ &mut s,
+ "dimension.create",
+ json!({"source":{"kind":"segment","edge":{"node":curve,"subpath":0,"segment":0}},"options":{"units":"pt","precision":0}}),
+ )["id"]
+ .as_u64()
+ .unwrap();
+ assert_eq!(text(&s, id), "100 pt");
}
+
#[test]
fn dimension_rejects_bad_input_without_mutation() {
let mut s = session();
@@ -116,6 +152,9 @@ fn dimension_rejects_bad_input_without_mutation() {
json!({"a":[0,0],"b":[10,0],"options":{"precision":99999}}),
json!({"a":[0,0],"b":[10,0],"options":{"scale":"0:10"}}),
json!({"a":[0,0],"b":[10,0],"options":{"scale":"1:NaN"}}),
+ json!({"a":[0,0],"b":[10,0],"options":{"units":"furlongs"}}),
+ json!({"a":[0,0],"b":[10,0],"options":{"textPosition":"sideways"}}),
+ json!({"a":[0,0],"b":[10,0],"mode":"spiral"}),
json!({"source":{"kind":"segment","edge":{"node":line,"subpath":0,"segment":18446744073709551615u64}}}),
json!({"mode":"angular","source":{"kind":"segment","edge":{"node":line,"subpath":0,"segment":0}}}),
] {
@@ -126,33 +165,86 @@ fn dimension_rejects_bad_input_without_mutation() {
assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), original);
}
+/// Hover a segment and drag outwards: releasing places the dimension; Escape cancels the drag.
#[test]
-fn dimension_gesture_highlights_preview_commits_and_cancels() {
- use vectorcraft_engine::{
- ViewInfo,
- tools::{Mods, PointerEvent, PointerKind, ToolKey},
- };
+fn dragging_out_from_a_segment_places_its_dimension() {
let mut s = session();
edge(&mut s);
- let view = ViewInfo::default();
- s.select_tool("dimension", view).unwrap();
+ s.select_tool("dimension", ViewInfo::default()).unwrap();
let before = s.doc().unwrap().doc.node_count();
- s.pointer(&PointerEvent::new(PointerKind::Move, 130.0, 100.0), view).unwrap();
- s.pointer(&PointerEvent::new(PointerKind::Down, 130.0, 100.0), view).unwrap();
- s.pointer(&PointerEvent::new(PointerKind::Move, 130.0, 60.0), view).unwrap();
+ ev(&mut s, PointerKind::Move, 130.0, 100.0);
+ ev(&mut s, PointerKind::Down, 130.0, 100.0);
+ ev(&mut s, PointerKind::Drag, 130.0, 70.0);
assert!(s.in_interaction());
- s.tool_key(ToolKey::Escape, Mods::default(), view).unwrap();
+ s.tool_key(ToolKey::Escape, Mods::default(), ViewInfo::default()).unwrap();
assert_eq!(s.doc().unwrap().doc.node_count(), before);
assert!(!s.in_interaction());
- s.pointer(&PointerEvent::new(PointerKind::Down, 130.0, 100.0), view).unwrap();
- s.pointer(&PointerEvent::new(PointerKind::Move, 130.0, 60.0), view).unwrap();
- s.pointer(&PointerEvent::new(PointerKind::Down, 130.0, 60.0), view).unwrap();
+ ev(&mut s, PointerKind::Up, 130.0, 70.0);
+ ev(&mut s, PointerKind::Down, 130.0, 100.0);
+ ev(&mut s, PointerKind::Drag, 130.0, 80.0);
+ ev(&mut s, PointerKind::Drag, 130.0, 60.0);
+ ev(&mut s, PointerKind::Up, 130.0, 60.0);
assert!(!s.in_interaction());
assert!(s.doc().unwrap().doc.node_count() > before);
+ let id = s.doc().unwrap().selection.objects[0].0;
+ assert_eq!(text(&s, id), "72.00 pt");
+ assert!((recipe(&s, id).offset.abs() - 40.0).abs() < 1e-6, "placed where it was released");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().doc.node_count(), before);
}
+/// Plain clicks pick two points, and a third click places the dimension.
+#[test]
+fn clicking_two_points_then_placing_measures_between_them() {
+ let mut s = session();
+ s.select_tool("dimension", ViewInfo::default()).unwrap();
+ for (x, y) in [(300.0, 300.0), (372.0, 300.0)] {
+ ev(&mut s, PointerKind::Down, x, y);
+ ev(&mut s, PointerKind::Up, x, y);
+ }
+ ev(&mut s, PointerKind::Move, 336.0, 330.0);
+ assert!(s.in_interaction());
+ ev(&mut s, PointerKind::Down, 336.0, 330.0);
+ ev(&mut s, PointerKind::Up, 336.0, 330.0);
+ assert!(!s.in_interaction());
+ let id = s.doc().unwrap().selection.objects[0].0;
+ assert_eq!(text(&s, id), "72.00 pt");
+}
+
+/// Dragging out from a corner of two straight segments measures the angle between them.
+#[test]
+fn dragging_out_from_a_corner_measures_its_angle() {
+ let mut s = session();
+ run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":50}));
+ s.select_tool("dimension", ViewInfo::default()).unwrap();
+ s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"mode":"angular"}})).unwrap();
+ ev(&mut s, PointerKind::Move, 100.0, 100.0);
+ ev(&mut s, PointerKind::Down, 100.0, 100.0);
+ ev(&mut s, PointerKind::Drag, 120.0, 120.0);
+ ev(&mut s, PointerKind::Drag, 130.0, 130.0);
+ ev(&mut s, PointerKind::Up, 130.0, 130.0);
+ let id = s.doc().unwrap().selection.objects[0].0;
+ assert_eq!(text(&s, id), "90.00°");
+}
+
+/// Dragging out from a circle gives its radius; dragging inwards, its diameter.
+#[test]
+fn dragging_from_a_circle_gives_its_radius_or_diameter() {
+ let mut s = session();
+ run(&mut s, "shape.ellipse", json!({"x":100,"y":100,"width":144,"height":144}));
+ s.select_tool("dimension", ViewInfo::default()).unwrap();
+ s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"mode":"radial","style":{"units":"in","precision":0}}})).unwrap();
+ for ((x0, y0), (x1, y1), want) in [((244.0, 172.0), (280.0, 172.0), "1 in"), ((244.0, 172.0), (200.0, 172.0), "2 in")] {
+ ev(&mut s, PointerKind::Move, x0, y0);
+ ev(&mut s, PointerKind::Down, x0, y0);
+ ev(&mut s, PointerKind::Drag, (x0 + x1) / 2.0, y0);
+ ev(&mut s, PointerKind::Drag, x1, y1);
+ ev(&mut s, PointerKind::Up, x1, y1);
+ let id = s.doc().unwrap().selection.objects[0].0;
+ assert_eq!(text(&s, id), want);
+ }
+}
+
#[test]
fn dimension_anchor_links_and_native_file_survive_source_moves() {
let mut s = session();
@@ -169,14 +261,36 @@ fn dimension_anchor_links_and_native_file_survive_source_moves() {
assert_eq!(text(&s, id), "100 pt");
}
+/// A linked dimension moved on its own keeps its new place when its art changes later; moved with
+/// its art, it keeps its distance from it.
+#[test]
+fn a_moved_linked_dimension_stays_where_it_was_dropped() {
+ let mut s = session();
+ let line = edge(&mut s);
+ let id = dim(&mut s, line);
+ let offset = recipe(&s, id).offset;
+ run(&mut s, "select.set", json!({"ids":[id]}));
+ run(&mut s, "object.move", json!({"dx":0,"dy":-20}));
+ let moved = recipe(&s, id).offset;
+ assert!((moved - offset).abs() - 20.0 < 1e-6 && (moved - offset).abs() > 19.0, "{offset} → {moved}");
+ run(&mut s, "select.set", json!({"ids":[line]}));
+ run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
+ assert!((recipe(&s, id).offset - moved).abs() < 1e-6, "no jump back");
+ run(&mut s, "select.set", json!({"ids":[line, id]}));
+ run(&mut s, "object.move", json!({"dx":0,"dy":50}));
+ assert!((recipe(&s, id).offset - moved).abs() < 1e-6, "moved together");
+}
+
#[test]
fn dimension_options_are_persistent_and_invalid_updates_report_errors() {
let mut s = session();
- s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"style":{"scale":"10:1","units":"mm","precision":3},"mode":"diameter"}})).unwrap();
+ s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"style":{"scale":"10:1","units":"mm","precision":3},"mode":"radial"}})).unwrap();
assert_eq!(s.tool_options_of("dimension")["style"]["scale"], "10:1");
assert!(s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"style":{"scale":"1:0"}}})).is_err());
let o: vectorcraft_doc::dimension::DimensionOptions = serde_json::from_value(s.tool_options_of("dimension")["style"].clone()).unwrap();
assert_eq!(o.label(72.0, false).unwrap(), "2.540 mm");
+ let picas: vectorcraft_doc::dimension::DimensionOptions = serde_json::from_value(json!({"units":"pc","precision":1})).unwrap();
+ assert_eq!(picas.label(72.0, false).unwrap(), "6.0 pc");
let prefs = serde_json::to_value(&s.prefs).unwrap();
let mut fresh = Session::new();
fresh.prefs = serde_json::from_value(prefs).unwrap();
@@ -196,7 +310,7 @@ fn dimension_linked_angle_and_circle_update_and_reject_non_circles() {
let mut s = session();
let first = edge(&mut s);
let second = run(&mut s, "shape.line", json!({"x1":100,"y1":100,"x2":100,"y2":172}))["id"].as_u64().unwrap();
- let id=run(&mut s,"dimension.create",json!({"mode":"angular","source":{"kind":"angle","first":{"node":first,"subpath":0,"segment":0},"second":{"node":second,"subpath":0,"segment":0}}}))["id"].as_u64().unwrap();
+ let id=run(&mut s,"dimension.create",json!({"mode":"angular","source":{"kind":"angle","first":{"node":first,"subpath":0,"segment":0},"second":{"node":second,"subpath":0,"segment":0}},"options":{"precision":1}}))["id"].as_u64().unwrap();
assert_eq!(text(&s, id), "90.0°");
run(&mut s, "select.set", json!({"ids":[second]}));
run(&mut s, "object.rotate", json!({"angle":45,"origin":[100,100]}));
@@ -211,7 +325,7 @@ fn dimension_linked_angle_and_circle_update_and_reject_non_circles() {
.unwrap();
run(&mut s, "select.set", json!({"ids":[circle]}));
run(&mut s, "object.scale", json!({"sx":200,"sy":200}));
- assert_eq!(text(&s, id), "Ø 4 in");
+ assert_eq!(text(&s, id), "4 in");
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
assert!(s.doc().unwrap().doc.node(NodeId(id)).is_none());
assert!(s.execute("dimension.create", &json!({"mode":"radius","source":{"kind":"circle","node":circle}})).is_err());
@@ -225,10 +339,10 @@ fn dimension_style_update_keeps_links_and_can_be_undone() {
run(
&mut s,
"dimension.update",
- json!({"ids":[id],"offset":-42,"options":{"units":"mm","precision":2,"hideUnits":true,"textPosition":"below","lineColor":[10,20,30]}}),
+ json!({"ids":[id],"offset":-42,"options":{"units":"mm","precision":2,"hideUnits":true,"textPosition":"between","textAlignment":"right","lineColor":[10,20,30]}}),
);
assert_eq!(text(&s, id), "25.40");
- let d = s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().dimension.as_ref().unwrap();
+ let d = recipe(&s, id);
assert!(d.source.is_some());
assert_eq!(d.offset, -42.0);
run(&mut s, "edit.undo", json!({}));
diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs
index 9e91f12d7..7c1fe218f 100644
--- a/crates/tools/src/catalog.rs
+++ b/crates/tools/src/catalog.rs
@@ -102,7 +102,7 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[
&[t("gradient", "Gradient Tool", Some("G"), "tool-gradient")],
&[t("eyedropper", "Eyedropper Tool", Some("I"), "tool-eyedropper"), t("measure", "Measure Tool", None, "tool-measure")],
&[t("blend", "Blend Tool", Some("W"), "tool-blend")],
- &[t("dimension", "Dimension Tool", None, "tool-measure")],
+ &[t("dimension", "Dimension Tool", None, "tool-dimension")],
&[
t("symbolSprayer", "Symbol Sprayer Tool", Some("Shift+S"), "tool-symbol"),
t("symbolShifter", "Symbol Shifter Tool", None, "tool-symbol"),
diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs
index 8d8961f6c..eb482a036 100644
--- a/crates/tools/src/dimension.rs
+++ b/crates/tools/src/dimension.rs
@@ -1,22 +1,64 @@
-//! Click a highlighted segment, then click to place its linked dimension. Empty-space clicks
-//! measure two points (three for an angle). Escape cancels without leaving any artwork.
+//! The Dimension tool. Hover a segment (linear), a corner (angular) or a circle or perfect arc
+//! (radial) and drag outwards: releasing places the linked dimension. Plain clicks pick what to
+//! measure instead: two points (linear) or two straight lines (angular), then a click places it;
+//! a click on a circle or arc gives its radius. Escape cancels without leaving any artwork.
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
use serde_json::{Value, json};
use vectorcraft_doc::{
Node,
- dimension::{Dimension, DimensionOptions, DimensionSource, SegmentRef},
+ dimension::{self, Dimension, DimensionMode, DimensionOptions, DimensionSource, PointRef, SegmentRef},
};
use vectorcraft_geom::{BezPath, Point, Vec2};
+/// The three kinds of dimension the tool draws.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum Kind {
+ #[default]
+ Linear,
+ Angular,
+ Radial,
+}
+
+impl Kind {
+ pub fn id(self) -> &'static str {
+ match self {
+ Kind::Linear => "linear",
+ Kind::Angular => "angular",
+ Kind::Radial => "radial",
+ }
+ }
+ /// `radius` and `diameter` are the radial kind.
+ pub fn parse(s: &str) -> Option {
+ match s {
+ "linear" => Some(Kind::Linear),
+ "angular" => Some(Kind::Angular),
+ "radial" | "radius" | "diameter" => Some(Kind::Radial),
+ _ => None,
+ }
+ }
+}
+
+/// What the pointer is over.
+#[derive(Clone, Debug, PartialEq)]
+enum Target {
+ Segment(SegmentRef),
+ Corner { first: SegmentRef, second: SegmentRef, at: Point },
+ Radial { source: DimensionSource, center: Point, radius: f64 },
+}
+
#[derive(Default)]
pub struct DimensionTool {
- mode: String,
+ kind: Kind,
style: DimensionOptions,
- points: Vec,
- point_refs: Vec