Files
vectorcraft/crates/engine/src/cmd/dimension.rs
T
dotCooCoo d13f7324f9 Dimension tool: a dimension selected with its art stays live through Expand and Outline Stroke
A dimension selected with the art it measures became a plain group of
paths and text when Object › Expand, Object › Expand Appearance or
Object › Path › Outline Stroke ran, so it no longer redrew when moved
or restyled.

These commands now leave such a dimension's lines and label as they
are, also inside selected groups without fills, strokes or effects of
their own, and change only the other art. The dimension keeps its
export assets and variable bindings. When the new art no longer fits
the dimension's link, for example because the outlined stroke has
other anchors, the dimension stays where it was, no longer linked, as
it does when its art is deleted. Undo links it again.

When the dimension is all the command would change, the command
changes nothing and reports the error it gives for a selection with
nothing to change. Object › Expand… is no longer dimmed for the label
of such a dimension. A dimension selected without the art it measures,
a part of one, and one inside a selected group with an appearance of
its own still become plain groups, and Type › Create Outlines still
outlines the label.

Docs: docs/dimensions.md, docs/mcp.md, docs/target-app-parity.md.
2026-11-11 01:26:12 -08:00

688 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 and
/// [`restore_unchanged`] after, so that a dimension whose own art the command changed stays a
/// plain group.
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 own art is as it was are live again. One whose measured art the
/// command changed from `before` so that the art no longer fits its link stays where it was, no
/// longer linked, as [`refresh`] leaves a dimension whose art was deleted.
pub(crate) fn restore_unchanged(before: &Document, 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) {
continue;
}
let recipe = if e.source_kept(before, doc) { e.recipe } else { unlinked(&e.recipe) };
if let Some(n) = doc.node_mut(e.id) {
n.extras_mut().dimension = Some(Box::new(recipe));
doc.dimensions = true;
}
}
}
/// `old` where it was, no longer linked. A move it had on its own and not yet drawn is kept when
/// the moved dimension is valid.
fn unlinked(old: &Dimension) -> Dimension {
let mut unlinked = old.clone();
unlinked.source = None;
unlinked.moved = None;
if let Some(v) = old.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;
}
}
unlinked
}
/// The whole dimensions under `roots` that are selected together with art they measure (art under
/// `roots`), each one a root or held by groups without fills, strokes or effects of their own.
/// Object › Expand, Object › Expand Appearance and Object › Path › Outline Stroke leave their
/// lines and label as they are. A group with an appearance of its own changes everything it holds
/// when it expands, its dimensions included.
pub(crate) fn with_their_art(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
if !doc.dimensions {
return Vec::new();
}
let mut found = Vec::new();
for root in roots {
let Some(n) = doc.node(*root) else { continue };
n.walk(&mut |c| {
if let Some(source) = c.extras().dimension.as_ref().and_then(|d| d.source.as_ref()) {
found.push((*root, c.id, source.nodes()));
}
});
}
let under_roots = |id: NodeId| doc.ancestry(id).is_some_and(|a| a.iter().any(|a| roots.contains(a)));
found
.into_iter()
.filter(|(root, id, _)| {
let Some(ancestry) = doc.ancestry(*id) else { return false };
let from = ancestry.iter().position(|a| a == root).unwrap_or(ancestry.len());
ancestry.get(from..ancestry.len().saturating_sub(1)).unwrap_or_default().iter().all(|a| doc.node(*a).is_some_and(plain_group))
})
.filter(|(_, _, measured)| measured.iter().any(|m| under_roots(*m)))
.map(|(_, id, _)| id)
.collect()
}
/// A group or layer without fills, strokes or effects of its own.
fn plain_group(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && n.appearance.items.is_empty() && n.appearance.effects.is_empty()
}
/// A dimension [`lift`] took out of the document, with its place, the export assets that list it or
/// its parts (with their places in the asset list) and its parts' variable bindings.
pub(crate) struct Lifted {
parent: Option<NodeId>,
index: usize,
node: Arc<Node>,
assets: Vec<(usize, vectorcraft_doc::assets::ExportAsset)>,
bindings: Vec<(NodeId, std::collections::BTreeSet<String>)>,
}
impl Lifted {
pub(crate) fn id(&self) -> NodeId {
self.node.id
}
}
/// Takes the [`with_their_art`] dimensions under `roots` out of `doc`, so a command that replaces
/// art does not reach them; [`put_back`] puts them back.
pub(crate) fn lift(doc: &mut Document, roots: &[NodeId]) -> Vec<Lifted> {
let mut lifted = Vec::new();
for id in with_their_art(doc, roots) {
let Some((parent, index, _)) = doc.position(id) else { continue };
let mut parts = std::collections::BTreeSet::new();
if let Some(n) = doc.node(id) {
n.walk(&mut |c| {
parts.insert(c.id);
});
}
let assets = doc.assets.iter().enumerate().filter(|(_, a)| a.nodes.iter().any(|n| parts.contains(n))).map(|(i, a)| (i, a.clone())).collect();
let bindings = doc.variables.bindings.iter().filter(|(n, _)| parts.contains(n)).map(|(n, b)| (*n, b.clone())).collect();
if let Ok(node) = doc.remove(id) {
lifted.push(Lifted { parent, index, node, assets, bindings });
}
}
lifted
}
/// Each dimension [`lift`] took out, as it was, back in its place in `doc`, with its parts back in
/// the export assets that listed them and their variable bindings back: edits made while it was out
/// let go of them. A dimension whose group is gone goes on top of the first layer.
pub(crate) fn put_back(doc: &mut Document, lifted: Vec<Lifted>) {
for l in lifted.into_iter().rev() {
let parent = if doc.children(l.parent).is_some() { l.parent } else { doc.layers.first().map(|layer| layer.id) };
let Ok(ch) = doc.children_mut(parent) else { continue };
let i = if parent == l.parent { l.index.min(ch.len()) } else { ch.len() };
ch.insert(i, l.node);
doc.dimensions = true;
for (at, saved) in l.assets {
let there: Vec<NodeId> = saved.nodes.iter().copied().filter(|n| doc.node(*n).is_some()).collect();
if let Some(a) = doc.assets.iter_mut().find(|a| a.id == saved.id) {
for (k, n) in there.iter().enumerate() {
if !a.nodes.contains(n) {
let k = k.min(a.nodes.len());
a.nodes.insert(k, *n);
}
}
} else if !there.is_empty() {
let at = at.min(doc.assets.len());
doc.assets.insert(at, vectorcraft_doc::assets::ExportAsset { nodes: there, ..saved });
}
}
for (n, b) in l.bindings {
doc.variables.bindings.entry(n).or_insert(b);
}
}
}
/// `doc` without the [`with_their_art`] dimensions under `roots`, to see what a command that leaves
/// them alone would change: `doc` itself when there are none.
pub(crate) fn without_kept<'a>(doc: &'a Document, roots: &[NodeId]) -> std::borrow::Cow<'a, Document> {
let ids = with_their_art(doc, roots);
if ids.is_empty() {
return std::borrow::Cow::Borrowed(doc);
}
let mut view = doc.clone();
for id in ids {
// A dimension already gone (inside another one removed) needs nothing more.
let _ = view.remove(id);
}
std::borrow::Cow::Owned(view)
}
/// 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.
if replace(doc, id, unlinked(&old)).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)
}