Files
vectorcraft/crates/engine/src/cmd/dimension.rs
T
dotCooCoo ef6d14dc22 Dimension tool: both ends of a dimension follow the art it measures
A linear dimension clicked from an anchor to a point that is not an
anchor (a point on a segment, or a point a Smart Guide gave) followed
the art at the anchor end only, so scaling the art turned it into a
long slanted dimension.

- A point on a segment is stored by its curve parameter on that
  segment, and stays on it when the segment's anchors or handles are
  edited.
- Any other point is stored against anchors (and, for a path whose
  anchors lie on one line, handles) of the path under it, or of the
  path at the other end, and moves, scales, turns and reflects with
  that path. Beside a path whose anchors and handles all lie on one
  line, such as a straight line, it keeps its distance along and
  across the path: a reflection moves it as a half turn does, and a
  scale that is not uniform moves it as a uniform scale does.
- When linked art is reflected, a linear dimension's line goes to the
  reflected side, except for art whose anchors and handles lie on one
  line.
- Deleting the art, changing its anchor count or whether it is closed,
  turning an angle's arm into a curve, or making a measured circle or
  arc no longer circular leaves the dimension where it was, no longer
  linked, instead of removing it. Undo links it again.

Docs: docs/dimensions.md, docs/target-app-parity.md.
2026-11-10 21:25:59 -08:00

560 lines
24 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}, freeA?, freeB?: a point that is not an anchor, {kind: onSegment, edge: {node,subpath,segment}, t: 0..1 (curve parameter)}|{kind: anchors, node, anchors: [{subpath,anchor,part?: point|in|out (the anchor, default, or its in or out handle),weight}] (2 or 3 points of one path whose weights add up to 1), across?: (2 anchors only: times the first-to-second anchor vector (x,y) turned to (-y,x), which points down when the vector points right)} (an end is an anchor or a free point, not both)}, 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)?;
let name = format!("Dimension · {}", dim.label().map_err(error)?);
if let Some(n) = doc.node_mut(id) {
n.kind = NodeKind::Group { children, clip: false };
n.name = Some(name);
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 otherwise leave the dimension unlinked, 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() {
// The art it measured was deleted, or rebuilt with other anchors, or no longer fits
// (an ellipse that was a circle): it stays where it was, no longer linked, in this
// same undo step, rather than attaching to a different edge.
let mut unlinked = old.clone();
unlinked.source = None;
unlinked.moved = None;
if let Some(v) = moved {
let mut shifted = unlinked.clone();
(shifted.a, shifted.b, shifted.vertex) = (shifted.a + v, shifted.b + v, shifted.vertex + v);
if shifted.validate().is_ok() {
unlinked = shifted;
}
}
if replace(doc, id, unlinked).is_err()
&& let Some(n) = doc.node_mut(id)
{
// Its recipe cannot be drawn again: its art stays as it is, as a plain group.
n.extras_mut().dimension = None;
}
changed = true;
continue;
}
next.keep_gap(&old);
if let Some(xf) = next.source.as_ref().and_then(|s| s.reflection(before, doc)) {
next.mirror_line(&old, xf * old.handle());
}
// 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(old.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)
}