Files
vectorcraft/crates/engine/src/cmd/dimension.rs
T
dotCooCoo ed17e8fc17 Dimension tool: linear dimensions measure horizontally, vertically or aligned
A linear dimension between two clicked points, or dragged out from a
segment, always measured the straight distance between its points and
drew its line parallel to them, so two points at different heights gave
only a slanted dimension. The linear dimensions help describes aligned,
horizontal and vertical linear dimensions: after the second click,
clicking again gives an aligned one and moving the pointer gives a
horizontal or vertical one.
https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/plot-linear-dimensions-of-objects.html

While a linear dimension is being placed, the pointer's position sets
whether it is aligned, horizontal or vertical. The rule is VectorCraft's
own. With the pointer beside the two points (the nearest point of the
line through them lies between them) or on that line, the dimension is
aligned. Past either point, the line through the points and the
perpendicular through that point bound a quarter on each side of the
line. The quarter that holds the direction straight up or down gives
the horizontal distance, and the quarter that holds the direction
straight left or right gives the vertical distance. A click within
reach of the second point, or of where the pointer was for the second
click, places it aligned. Points on one horizontal or vertical line
give an aligned dimension. The dimension stays linked to the anchors it
measures.

- Dimension recipes carry an orientation (aligned by default; files
  without it open as before). The line runs along the axis, and each
  extension line runs from its point to it.
- dimension.create and dimension.update take orientation: aligned,
  horizontal or vertical, for linear dimensions only. Any other value
  is an error. A new orientation keeps the line on the side of the
  points it was on, and an update that changes nothing records no
  undo step.
- A horizontal or vertical dimension keeps its orientation when it or
  the art it measures turns, and shows 0 when its points come into line
  across its axis. A line that lay outside the points stays outside
  them after a turn.
- The hint bar names the two-click placement.

Docs: docs/dimensions.md, the Evidence list in docs/target-app-parity.md.
2026-11-10 16:54:35 -08:00

539 lines
23 KiB
Rust

//! Linked dimensions: generated groups share the existing text/path render and export pipeline.
use super::{CommandSpec, EngineError, Result, Session, cmd, has_doc};
use serde_json::{Value, json};
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::dimension::{
ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, Orientation, TextAlignment, TextPosition,
};
use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, ColorMode, Dash, Document, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
pub fn specs() -> Vec<CommandSpec> {
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}}, orientation?: aligned|horizontal|vertical (linear only: the distance between the points, or only its horizontal or vertical part; default aligned), offset?: pt (the line's distance from the first point: along the left normal of a → b when aligned, downward when horizontal, leftward when vertical; negative goes the other way), options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits",
has_doc,
create
),
cmd!(
"dimension.update",
"Update Dimensions",
[],
None,
"{ids?: [dimension ids] (default selection), options?: dimension options, orientation?: aligned|horizontal|vertical (linear dimensions only; the line stays on the side of the points it was on), offset?: pt (as in dimension.create)} restyle, turn 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<DimensionOptions> {
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<Point> {
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<f64> {
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<Dimension> {
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<DimensionSource> = 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 orientation = orientation(p, mode)?.unwrap_or_default();
let mut d = Dimension {
mode,
a,
b,
vertex,
offset: distance(p, 30.0)?,
length: a.distance(b),
options: options(s, p)?,
source,
orientation,
moved: None,
};
if mode == DimensionMode::Linear {
d.length = d.measured();
}
d.resolve(&s.doc()?.doc).map_err(error)?;
Ok(d)
}
/// The `orientation` param, if given: only a linear dimension is horizontal or vertical.
fn orientation(p: &Value, mode: DimensionMode) -> Result<Option<Orientation>> {
let Some(v) = p.get("orientation") else { return Ok(None) };
let orientation: Orientation = serde_json::from_value(v.clone()).map_err(|_| error("Unknown dimension orientation"))?;
if orientation != Orientation::Aligned && mode != DimensionMode::Linear {
return Err(error("Only linear dimensions are horizontal or vertical"));
}
Ok(Some(orientation))
}
fn insert(doc: &mut Document, parent: Option<NodeId>, dim: Dimension) -> Result<NodeId> {
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<Value> {
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<Dimension> {
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<Value> {
let ids: Vec<NodeId> = 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 };
// Every dimension is checked before anything changes, and an update that changes none of
// them records no undo step.
let mut next = Vec::with_capacity(ids.len());
{
let doc = &s.doc()?.doc;
for id in &ids {
if !doc.is_editable(*id) {
return Err(error("Dimension is locked or hidden"));
}
let old = dimension_of(doc, *id)?;
let mut dim = old.clone();
if let Some(o) = &o {
dim.options = o.clone();
}
if let Some(orientation) = orientation(p, dim.mode)? {
dim.set_orientation(orientation);
}
dim.offset = distance(p, dim.offset)?;
dim.resolve(doc).map_err(error)?;
if dim != old {
next.push((*id, dim));
}
}
}
if !next.is_empty() {
s.edit("Update Dimensions", |doc, _| {
for (id, dim) in next {
replace(doc, id, dim)?;
}
Ok(())
})?;
}
Ok(json!({"ids":ids}))
}
fn apply_to_all(s: &mut Session, p: &Value) -> Result<Value> {
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![];
let editable = st.doc.editable();
st.doc.walk(|n| {
if n.extras().dimension.is_some() && editable(n.id) {
ids.push(n.id);
}
});
drop(editable);
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<NodeId> = 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(())
}
/// A dimension whose live link [`end_links`] ended, as it was before.
pub(crate) struct EndedLink {
id: NodeId,
recipe: Dimension,
art: Vec<Arc<Node>>,
}
/// The dimensions under `roots` or holding one of them. A dimension can be listed more than once.
pub(crate) fn reached(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let mut ids = Vec::new();
for n in roots.iter().filter_map(|r| doc.node(*r)) {
n.walk(&mut |c| {
if c.extras().dimension.is_some() {
ids.push(c.id);
}
});
if doc.dimensions {
let holders = doc.ancestry(n.id).unwrap_or_default();
ids.extend(holders.into_iter().filter(|id| doc.node(*id).is_some_and(|h| h.extras().dimension.is_some())));
}
}
ids
}
impl EndedLink {
/// Is the art this dimension measures, changed from `before` to `after`, still art it can link to?
pub(crate) fn source_kept(&self, before: &Document, after: &Document) -> bool {
!self.recipe.source.as_ref().is_some_and(|s| s.topology_changed(before, after)) && self.recipe.clone().resolve(after).is_ok()
}
}
/// The [`reached`] dimensions become plain groups, whose art does not redraw or follow what it
/// measured. A command that replaces art calls this before it changes anything, since replacing
/// the art a dimension measures would remove the dimension, and then [`restore_unchanged`].
pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec<EndedLink> {
let mut ended = Vec::new();
for id in reached(doc, roots) {
let Some(n) = doc.node_mut(id) else { continue };
let art = n.children().cloned().unwrap_or_default();
if let Some(recipe) = n.extras_mut().dimension.take() {
ended.push(EndedLink { id, recipe: *recipe, art });
}
}
ended
}
/// The dimensions in `ended` whose art is as it was are live again.
pub(crate) fn restore_unchanged(doc: &mut Document, ended: Vec<EndedLink>) {
for e in ended {
if doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art)
&& let Some(n) = doc.node_mut(e.id)
{
n.extras_mut().dimension = Some(Box::new(e.recipe));
doc.dimensions = true;
}
}
}
/// 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<bool> {
if !doc.dimensions {
return Ok(false);
}
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();
let mut changed = false;
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)?;
changed = true;
continue;
}
next.keep_gap(&old);
// 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;
}
}
// A new document colour mode (Document Color Mode) redraws every dimension in it.
if next != old
|| before.color_mode != doc.color_mode
|| before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next)
{
replace(doc, id, next)?;
changed = true;
}
}
Ok(changed)
}
/// Redraw the dimensions of an opened document whose art isn't in its colour mode: files saved
/// before dimensions followed the colour mode have RGB dimensions in CMYK documents. A dimension
/// that can't be redrawn keeps the art it has.
pub(crate) fn conform(doc: &mut Document) {
if !doc.dimensions {
return;
}
let mode = doc.color_mode;
let off_mode = |c: &Color| match mode {
ColorMode::Rgb => matches!(c, Color::Cmyk { .. }),
ColorMode::Cmyk => matches!(c, Color::Rgb { .. }),
};
let mut stale = Vec::new();
doc.walk(|n| {
if let Some(d) = &n.extras().dimension {
let mut off = false;
n.walk(&mut |c| {
off |= [c.appearance.fill_paint(), c.appearance.stroke_paint()].iter().filter_map(|p| p.color()).any(|c| off_mode(&c));
// The label's characters carry their own fill.
if let NodeKind::Text(t) = &c.kind {
off |= t.runs.iter().filter_map(|r| r.style.fill.color()).any(|c| off_mode(&c));
}
});
if off {
stale.push((n.id, (**d).clone()));
}
}
});
for (id, d) in stale {
// An error leaves this dimension as it was; the others are still redrawn.
let _ = replace(doc, id, d);
}
}
/// Option colour `c` as a paint in the document's colour model: in a CMYK document black is K
/// only (a rich four-ink black would print a black dimension in every plate) and other colours
/// convert through the active colour settings.
fn paint(c: [u8; 3], mode: ColorMode) -> Paint {
let rgb = Color::rgb8(c[0], c[1], c[2]);
Paint::solid(match mode {
ColorMode::Rgb => rgb,
ColorMode::Cmyk if c == [0, 0, 0] => Color::cmyk(0.0, 0.0, 0.0, 1.0),
ColorMode::Cmyk => {
let cms = vectorcraft_color::cms::active();
cms.convert(&rgb, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent)
}
})
}
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, mode: ColorMode, 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, mode), 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<Vec<Arc<Node>>> {
d.validate().map_err(error)?;
let o = &d.options;
let mode = doc.color_mode;
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, mode, 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, mode, 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;
// A linear dimension measures along its direction (a → b, or the horizontal or vertical axis).
let u = if d.mode == DimensionMode::Linear { d.direction() } else { 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.line_ends(),
};
// The side of the line facing away from the object: the side the line is on, seen from
// the middle of the measured points.
let outward = match d.mode {
DimensionMode::Linear => {
if (a.midpoint(b) - d.a.midpoint(d.b)).dot(n) < 0.0 {
-n
} else {
n
}
}
_ => n,
};
if d.mode == DimensionMode::Linear && !o.hide_extensions {
// Each extension line runs from its point toward the dimension line, which can lie on
// different sides of the two points of a horizontal or vertical dimension.
for (p, q) in [(d.a, a), (d.b, b)] {
let side = (q - p).dot(n);
let toward = match side {
s if s < 0.0 => -n,
s if s > 0.0 => n,
_ => outward,
};
paths.push((line(p + toward * o.extension_offset, q + toward * o.extension_length), appearance(o, mode, 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, mode, 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, mode),
..Default::default()
};
let mut text = TextObject::point(Point::ZERO, &label, style);
text.para.justify = vectorcraft_doc::Justify::Center;
text.system_layout = doc.setup.system_layout;
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, mode), Paint::None, 0.0).into();
children.push(Arc::new(node));
Ok(children)
}