mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 21:57:12 -06:00
Dimension tool: the documented gestures, arcs, picas and text positions; Properties section; review fixes
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.
This commit is contained in:
+123
-175
@@ -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<CommandSpec> {
|
||||
"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<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<f64> {
|
||||
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<Dimension> {
|
||||
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<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;
|
||||
@@ -97,26 +76,25 @@ fn recipe(s: &Session, p: &Value) -> Result<Dimension> {
|
||||
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<NodeId>, 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<Value> {
|
||||
Ok(json!({"id":id.0,"label":label}))
|
||||
}
|
||||
|
||||
fn selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<SegmentRef>) -> 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<f64> = 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<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> {
|
||||
@@ -224,8 +132,8 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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![];
|
||||
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<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)?;
|
||||
@@ -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<Vec<Arc<Node>>> {
|
||||
d.validate().map_err(error)?;
|
||||
let o = &d.options;
|
||||
@@ -325,13 +289,9 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<
|
||||
let mut paths: Vec<(BezPath, Appearance)> = 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<Vec<Arc<
|
||||
paths.push((line(d.vertex + dir * o.extension_offset, d.vertex + dir * (r + o.extension_length)), appearance(o, true, false)));
|
||||
}
|
||||
}
|
||||
text_at = d.vertex + Vec2::from_angle(start + sweep * 0.5) * (r + o.text_offset + o.font_size * 0.5);
|
||||
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.as_str() {
|
||||
"radius" => (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<Vec<Arc<
|
||||
let mut text = TextObject::point(Point::ZERO, &label, style);
|
||||
text.para.justify = vectorcraft_doc::Justify::Center;
|
||||
super::typecmd::refresh_bounds(&mut text);
|
||||
text.xf = Affine::translate(text_at.to_vec2()) * Affine::rotate(angle);
|
||||
// Split the centre line around the label rather than paint an opaque white patch over art.
|
||||
if o.text_position == "center"
|
||||
&& d.mode != "angular"
|
||||
&& let Some((path, app)) = paths.pop()
|
||||
{
|
||||
let elems = path.elements();
|
||||
if let (Some(vectorcraft_geom::PathEl::MoveTo(a)), Some(vectorcraft_geom::PathEl::LineTo(b))) = (elems.first(), elems.get(1)) {
|
||||
let delta = *b - *a;
|
||||
let len = delta.hypot();
|
||||
let u = delta / len;
|
||||
let bounds = text.cached_bounds.unwrap_or_else(|| text.estimate_bounds());
|
||||
let gap = (bounds.width() * 0.5 + 4.0).min(len * 0.45);
|
||||
let mid = a.midpoint(*b);
|
||||
let mut left = app.clone();
|
||||
if let Some(st) = left.stroke_mut() {
|
||||
st.end_arrow = None;
|
||||
}
|
||||
let mut right = app;
|
||||
if let Some(st) = right.stroke_mut() {
|
||||
st.start_arrow = None;
|
||||
}
|
||||
paths.push((line(*a, mid - u * gap), left));
|
||||
paths.push((line(mid + u * gap, *b), right));
|
||||
}
|
||||
}
|
||||
// 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());
|
||||
|
||||
@@ -459,7 +459,7 @@ impl Session {
|
||||
options.validate()?;
|
||||
}
|
||||
if let Some(mode) = values.get("mode")
|
||||
&& !mode.as_str().is_some_and(|m| ["linear", "angular", "radius", "diameter"].contains(&m))
|
||||
&& !mode.as_str().is_some_and(|m| ["linear", "angular", "radial", "radius", "diameter"].contains(&m))
|
||||
{
|
||||
return Err("Unknown dimension mode".into());
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::{
|
||||
Session,
|
||||
Session, ViewInfo,
|
||||
doc::{Document, NodeId, NodeKind},
|
||||
tools::{Mods, PointerEvent, PointerKind, ToolKey},
|
||||
};
|
||||
|
||||
fn session() -> 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!({}));
|
||||
|
||||
Reference in New Issue
Block a user