//! Linked dimensions: generated groups share the existing text/path render and export pipeline. use super::*; use serde_json::{Value, json}; use std::sync::Arc; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::dimension::{ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, TextAlignment, TextPosition}; use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, Dash, Document, Node, NodeId, NodeKind, TextObject}; use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2}; pub fn specs() -> Vec { vec![ cmd!( "dimension.create", "Create Dimension", [], None, "{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}}, offset?: pt, options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits", has_doc, create ), cmd!( "dimension.update", "Update Dimensions", [], None, "{ids?: [dimension ids] (default selection), options?: dimension options, offset?: pt} restyle or move existing dimensions → {ids}", has_doc, update ), cmd!( "dimension.applyToAll", "Apply to All", [], None, "{id?: dimension id (default the first selected dimension)} gives every dimension in the document that dimension's style (its scale too), as one undo step → {ids}", has_doc, apply_to_all ), ] } fn error(s: impl ToString) -> EngineError { EngineError::Other(s.to_string()) } fn options(s: &Session, p: &Value) -> Result { let default = s.tool_options_of("dimension"); let value = p.get("options").cloned().unwrap_or_else(|| default.get("style").cloned().unwrap_or(json!({}))); DimensionOptions::from_params(value).map_err(error) } fn point(p: &Value, key: &str) -> Result { let a = p.get(key).and_then(Value::as_array).filter(|a| a.len() == 2).ok_or_else(|| error(format!("{key} must be [x,y]")))?; let x = a.first().and_then(Value::as_f64).ok_or_else(|| error("Invalid point"))?; let y = a.get(1).and_then(Value::as_f64).ok_or_else(|| error("Invalid point"))?; Ok(Point::new(x, y)) } fn distance(p: &Value, fallback: f64) -> Result { match p.get("offset") { Some(v) => v.as_f64().filter(|n| n.is_finite() && n.abs() <= 1e6).ok_or_else(|| error("Invalid dimension offset")), None => Ok(fallback), } } fn recipe(s: &Session, p: &Value) -> Result { let mode: DimensionMode = p.get("mode").map(|v| serde_json::from_value(v.clone()).map_err(|_| error("Unknown dimension mode"))).transpose()?.unwrap_or_default(); let source: Option = p.get("source").map(|v| serde_json::from_value(v.clone()).map_err(error)).transpose()?; if let Some(source) = &source { let doc = &s.doc()?.doc; for id in source.nodes() { if doc.ancestry(id).is_some_and(|path| path.iter().any(|id| doc.node(*id).is_some_and(|n| n.extras().dimension.is_some()))) { return Err(error("Choose original artwork, not a dimension's generated paths")); } } let compatible = matches!( (mode, source), (DimensionMode::Linear, DimensionSource::Segment { .. }) | (DimensionMode::Angular, DimensionSource::Angle { .. }) | (DimensionMode::Radius | DimensionMode::Diameter, DimensionSource::Circle { .. } | DimensionSource::Arc { .. }) | (DimensionMode::Linear | DimensionMode::Angular, DimensionSource::Points { .. }) ); if !compatible { return Err(error("Dimension mode does not match its source")); } } let a = if source.is_none() || p.get("a").is_some() { point(p, "a")? } else { Point::ZERO }; let b = if source.is_none() || p.get("b").is_some() { point(p, "b")? } else { Point::new(1.0, 0.0) }; let vertex = if mode == DimensionMode::Angular && (source.is_none() || matches!(source, Some(DimensionSource::Points { .. }))) { point(p, "vertex")? } else { Point::ZERO }; let mut d = Dimension { mode, a, b, vertex, offset: distance(p, 30.0)?, length: a.distance(b), options: options(s, p)?, source, moved: None }; d.resolve(&s.doc()?.doc).map_err(error)?; Ok(d) } fn insert(doc: &mut Document, parent: Option, dim: Dimension) -> Result { let id = doc.alloc_id(); let mut group = Node::new(id, NodeKind::Group { children: vec![], clip: false }); group.name = Some(format!("Dimension · {}", dim.label().map_err(error)?)); group.kind = NodeKind::Group { children: artwork(doc, &dim, &[])?, clip: false }; doc.dimensions = true; group.extras_mut().dimension = Some(Box::new(dim)); doc.insert(parent, usize::MAX, group)?; Ok(id) } fn create(s: &mut Session, p: &Value) -> Result { let dim = recipe(s, p)?; let label = dim.label().map_err(error)?; let parent = s.doc()?.insertion_parent(); s.note_journal("options", serde_json::to_value(&dim.options).map_err(error)?); let id = s.edit("Create Dimension", |doc, sel| { let id = insert(doc, parent, dim)?; sel.set([id]); Ok(id) })?; Ok(json!({"id":id.0,"label":label})) } fn dimension_of(doc: &Document, id: NodeId) -> Result { doc.node(id).and_then(|n| n.extras().dimension.as_deref()).cloned().ok_or_else(|| error("Select only dimensions")) } fn update(s: &mut Session, p: &Value) -> Result { let ids: Vec = match p.get("ids") { Some(v) => serde_json::from_value(v.clone()).map_err(error)?, None => s.doc()?.selection.objects.clone(), }; if ids.is_empty() || ids.len() > 10_000 { return Err(error("Select dimensions to update")); } let o = if p.get("options").is_some() { Some(options(s, p)?) } else { None }; s.edit("Update Dimensions", |doc, _| { for id in &ids { if !doc.is_editable(*id) { return Err(error("Dimension is locked or hidden")); } let mut dim = dimension_of(doc, *id)?; if let Some(o) = &o { dim.options = o.clone(); } dim.offset = distance(p, dim.offset)?; dim.resolve(doc).map_err(error)?; replace(doc, *id, dim)?; } Ok(()) })?; Ok(json!({"ids":ids})) } fn apply_to_all(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let from = match p.get("id") { Some(v) => NodeId(v.as_u64().ok_or_else(|| error("id must be a dimension id"))?), None => *st .selection .objects .iter() .find(|id| st.doc.node(**id).is_some_and(|n| n.extras().dimension.is_some())) .ok_or_else(|| error("Select a dimension first"))?, }; let o = dimension_of(&st.doc, from)?.options; let mut ids = vec![]; st.doc.walk(|n| { if n.extras().dimension.is_some() && st.doc.is_editable(n.id) { ids.push(n.id); } }); s.edit("Apply to All", |doc, _| { for id in &ids { let mut dim = dimension_of(doc, *id)?; dim.options = o.clone(); dim.resolve(doc).map_err(error)?; replace(doc, *id, dim)?; } Ok(()) })?; Ok(json!({"ids":ids})) } fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> { let old: Vec = doc.node(id).and_then(Node::children).map(|ch| ch.iter().map(|n| n.id).collect()).unwrap_or_default(); let children = artwork(doc, &dim, &old)?; if let Some(n) = doc.node_mut(id) { n.kind = NodeKind::Group { children, clip: false }; n.name = Some(format!("Dimension · {}", dim.label().map_err(error)?)); n.extras_mut().dimension = Some(Box::new(dim)); } Ok(()) } /// Called inside every edit, so dimensions and their undo history stay in step with the art they /// measure. Returns at once in documents without dimensions. pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> { if !doc.dimensions { return Ok(()); } let mut dimensions = Vec::new(); doc.walk(|n| { if let Some(d) = &n.extras().dimension { dimensions.push((n.id, (**d).clone())); } }); doc.dimensions = !dimensions.is_empty(); for (id, old) in dimensions { let mut next = old.clone(); let moved = next.moved.take(); if next.source.as_ref().is_some_and(|s| s.topology_changed(before, doc)) || next.resolve(doc).is_err() { // A deleted/degenerate source has no valid measurement. Remove its annotation in // this same undo step instead of keeping a silently wrong number. doc.remove(id)?; continue; } // Moved on its own (the art it measures stayed put): it keeps its new place. if let Some(v) = moved && (next.a, next.b, next.vertex) == (old.a, old.b, old.vertex) { next.place(next.handle() + v); if next.resolve(doc).is_err() { next = old.clone(); next.moved = None; } } if next != old || before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next) { replace(doc, id, next)?; } } Ok(()) } fn paint(c: [u8; 3]) -> Paint { Paint::solid(Color::rgb8(c[0], c[1], c[2])) } fn line(a: Point, b: Point) -> BezPath { let mut p = BezPath::new(); p.move_to(a); p.line_to(b); p } fn appearance(o: &DimensionOptions, ext: bool, arrows: bool) -> Appearance { let (color, width, kind) = if ext { (o.extension_color, o.extension_weight, o.extension_type) } else { (o.line_color, o.line_weight, o.line_type) }; let mut a = Appearance::basic(Paint::None, paint(color), width); if let Some(st) = a.stroke_mut() { st.dash = match kind { LineType::Dashed => Some(Dash { pattern: vec![width * 5.0, width * 3.0], ..Default::default() }), LineType::Dotted => { st.cap = vectorcraft_doc::LineCap::Round; Some(Dash { pattern: vec![0.0, width * 3.0], ..Default::default() }) } LineType::Solid => None, }; if arrows { let head = match o.arrow_style { ArrowStyle::Filled => Some(Arrowhead::Triangle), ArrowStyle::Open => Some(Arrowhead::TriangleOpen), ArrowStyle::Tick => Some(Arrowhead::Slash), ArrowStyle::None => None, }; st.start_arrow = head; st.end_arrow = head; st.arrow_scale = (o.arrow_scale, o.arrow_scale); st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip; } } a } /// How far along the line (0 start, 0.5 middle, 1 end) the label sits, kept clear of the arrows. fn along(o: &DimensionOptions) -> f64 { match o.text_alignment { TextAlignment::Left => 0.2, TextAlignment::Center => 0.5, TextAlignment::Right => 0.8, } } fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result>> { d.validate().map_err(error)?; let o = &d.options; let label = d.label().map_err(error)?; let mut paths: Vec<(BezPath, Appearance)> = Vec::new(); let mut angle = 0.0; let text_at; // Away from the object: past the dimension line; between: on the object's side of it. let away = o.text_position == TextPosition::Away; if d.mode == DimensionMode::Angular { let start = (d.a - d.vertex).atan2(); let sweep = d.angle(); let r = d.offset.abs().max(12.0); let on = |t: f64| d.vertex + Vec2::from_angle(start + sweep * t) * r; let mut arc = BezPath::new(); arc.move_to(on(0.0)); for i in 1..=64 { arc.line_to(on(i as f64 / 64.0)); } paths.push((arc, appearance(o, false, true))); if !o.hide_extensions { for p in [d.a, d.b] { let dir = (p - d.vertex) / (p - d.vertex).hypot(); paths.push((line(d.vertex + dir * o.extension_offset, d.vertex + dir * (r + o.extension_length)), appearance(o, true, false))); } } let dir = Vec2::from_angle(start + sweep * along(o)); let gap = o.text_offset + o.font_size * 0.5; text_at = d.vertex + dir * if away { r + gap } else { (r - gap).max(o.font_size * 0.5) }; } else { let delta = d.b - d.a; let u = delta / delta.hypot(); let n = Vec2::new(-u.y, u.x); let (a, b) = match d.mode { DimensionMode::Radius => (d.a, d.b), DimensionMode::Diameter => (d.a - delta, d.b), _ => (d.a + n * d.offset, d.b + n * d.offset), }; // The side of the line facing away from the object. let outward = match d.mode { DimensionMode::Linear => { if d.offset < 0.0 { -n } else { n } } _ => n, }; if d.mode == DimensionMode::Linear && !o.hide_extensions { for (p, q) in [(d.a, a), (d.b, b)] { paths.push((line(p + outward * o.extension_offset, q + outward * o.extension_length), appearance(o, true, false))); } } angle = u.atan2(); if !(-std::f64::consts::FRAC_PI_2..=std::f64::consts::FRAC_PI_2).contains(&angle) { angle += std::f64::consts::PI; } let shift = o.text_offset + o.font_size * 0.5; text_at = a.lerp(b, along(o)) + outward * if away { shift } else { -shift }; paths.push((line(a, b), appearance(o, false, true))); } let style = CharStyle { font_family: o.font_family.clone(), font_style: o.font_style.clone(), size: o.font_size, fill: paint(o.text_color), ..Default::default() }; let mut text = TextObject::point(Point::ZERO, &label, style); text.para.justify = vectorcraft_doc::Justify::Center; super::typecmd::refresh_bounds(&mut text); // Centre the label's box on its anchor point, along the line. let bounds = text.cached_bounds.unwrap_or_else(|| text.estimate_bounds()); let middle = Affine::translate(-bounds.center().to_vec2()); text.xf = Affine::translate(text_at.to_vec2()) * Affine::rotate(angle) * middle; let mut children = Vec::new(); for (path, a) in paths { let id = old.get(children.len()).copied().unwrap_or_else(|| doc.alloc_id()); children.push(Arc::new(Node::path(id, PathData::from_bezpath(&path), a))); } let id = old.get(children.len()).copied().unwrap_or_else(|| doc.alloc_id()); let mut node = Node::new(id, NodeKind::Text(Box::new(text))); node.appearance = Appearance::basic(paint(o.text_color), Paint::None, 0.0).into(); children.push(Arc::new(node)); Ok(children) }