From 7db66bb824469761f35bd26203157f5f8fd99fe2 Mon Sep 17 00:00:00 2001 From: "Vitali M." Date: Mon, 12 Oct 2026 15:18:11 -0300 Subject: [PATCH] Pick a horizontal or vertical dimension from where its text is placed (#345) Between two points, or on a line, that don't line up with an axis, the Sketch Dimension tool only offered the distance between them: an auto dimension ignored where its text went. Now the placement picks what it measures: across from the two (between the perpendiculars through them) the distance between them, as before; past that, above or below them the horizontal distance, beside them the vertical one. With no text, an explicit type, or two ends already on an axis, nothing changes. The tool previews the dimension from the cursor, so the choice follows the pointer while placing it. Fixes #257 Co-authored-by: Vitali M. <6042958+vitalibr@users.noreply.github.com> Co-authored-by: bherbruck Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/sketch.rs | 28 ++++++++- crates/engine/src/cmd/sketch_ux_tests.rs | 57 +++++++++++++++++++ .../ui-egui/src/dimension_create_ui_tests.rs | 25 ++++++++ docs/book/src/guide/sketching.md | 4 ++ 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 6d68b693..c901995a 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -106,7 +106,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())), CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())), CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params( - "entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)", + "entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)", ), CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical) .at("SKETCH", "CONSTRAINTS") @@ -1610,7 +1610,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result { } ((None, Some(c)), None) if is_line(c) => { let (a, b) = line_pts(sk, c).unwrap_or_default(); - match ty.as_str() { + match auto_orientation(&ty, a, b, text_at) { "angle" => { let angle = (b - a).angle(); (ConstraintKind::LineAngle { l: c, value: angle }, angle) @@ -1647,7 +1647,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result { } ((Some(a), None), Some((Some(b), None))) => { let (pa, pb) = (sk.point(a).unwrap_or_default(), sk.point(b).unwrap_or_default()); - match ty.as_str() { + match auto_orientation(&ty, pa, pb, text_at) { "horizontal" => (ConstraintKind::DistanceX { p: a, q: b, value: (pb.x - pa.x).abs() }, (pb.x - pa.x).abs()), "vertical" => (ConstraintKind::DistanceY { p: a, q: b, value: (pb.y - pa.y).abs() }, (pb.y - pa.y).abs()), _ => (ConstraintKind::Distance { p: a, q: b, value: pa.dist(pb) }, pa.dist(pb)), @@ -1767,6 +1767,28 @@ fn solved_measure(s: &Session, p: &Value, cmd: &str) -> Option<(f64, &'static st Some(measured(k.is_angle(), solvecraft_sketch::measure_dimension(sk, k)?)) } +/// The type of a dimension between `a` and `b` (two points or a line's ends). `auto` takes it +/// from where the text is placed: across from the two (between the perpendiculars through +/// them) the distance between them; past that, above or below them the horizontal distance, +/// beside them the vertical one (#257). With no text, or when the two already line up with +/// an axis, `auto` stays the distance between them; other types are returned unchanged. +fn auto_orientation(ty: &str, a: Vec2, b: Vec2, text_at: Option) -> &str { + let Some(t) = text_at.filter(|_| ty == "auto") else { return ty }; + let d = b - a; + let len = d.len(); + let along = (t - a).dot(d) / len; + let between = |v: f64, p: f64, q: f64| p.min(q) <= v && v <= p.max(q); + if d.x.abs() <= 1e-9 * len || d.y.abs() <= 1e-9 * len || !along.is_finite() || (0.0..=len).contains(&along) { + ty + } else if between(t.x, a.x, b.x) { + "horizontal" + } else if between(t.y, a.y, b.y) { + "vertical" + } else { + ty + } +} + /// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the /// dimension so it follows the geometry. fn place_text(sk: &mut Sketch, id: &str, at: Option) { diff --git a/crates/engine/src/cmd/sketch_ux_tests.rs b/crates/engine/src/cmd/sketch_ux_tests.rs index 3ef20295..2e0f43c1 100644 --- a/crates/engine/src/cmd/sketch_ux_tests.rs +++ b/crates/engine/src/cmd/sketch_ux_tests.rs @@ -1248,6 +1248,63 @@ fn pipe_and_sweep_follow_3d_sketch_curves() { assert!(body["volume_mm3"].as_f64().unwrap_or(0.0) > 4.0 * std::f64::consts::PI * 50.0 * 0.95, "{p} {body}"); } +/// Between two points, or on a line, that don't line up with an axis, an `auto` dimension takes +/// its orientation from where its text goes: across from the two the distance between them, +/// past that above or below them the horizontal distance, beside them the vertical one (#257). +#[test] +fn auto_dimension_orientation_follows_where_its_text_is_placed() { + use solvecraft_sketch::ConstraintKind; + // The ends of a 30 × 40 line: 50 long, and the slope keeps "across" and "past" distinct. + let dimension = |end: [f64; 2], entities: Value, extra: Value| { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0, 0], end], "connect": false})); + let mut p = json!({"entities": entities}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + let r = run(&mut s, "sketch.dimension", p); + let sk = sketch(&s); + let kind = sk.constraints.iter().find(|c| c.kind.is_dimension()).map(|c| c.kind.clone()).expect("a dimension"); + (kind, r["measured"].as_f64().unwrap_or(f64::NAN), sk) + }; + let points = json!(["l1.start", "l1.end"]); + for entities in [points.clone(), json!(["l1"])] { + for (at, expected) in [ + ([15.0, 60.0], "horizontal"), + ([15.0, -20.0], "horizontal"), + ([-30.0, 5.0], "vertical"), + ([55.0, 38.0], "vertical"), + ([-20.0, 25.0], "aligned"), + ([45.0, 10.0], "aligned"), + ([-20.0, -20.0], "aligned"), + ] { + let (kind, measured, _) = dimension([30.0, 40.0], entities.clone(), json!({"text_at": at})); + let (got, value) = match kind { + ConstraintKind::DistanceX { .. } => ("horizontal", 30.0), + ConstraintKind::DistanceY { .. } => ("vertical", 40.0), + ConstraintKind::Distance { .. } | ConstraintKind::Length { .. } => ("aligned", 50.0), + other => panic!("{entities} at {at:?}: {other:?}"), + }; + assert_eq!(got, expected, "{entities} with its text at {at:?}"); + assert!((measured - value).abs() < 1e-9, "{entities} at {at:?} measured {measured}"); + } + // No text, an explicit type, or two ends already on an axis keep today's dimension. + let (kind, ..) = dimension([30.0, 40.0], entities.clone(), json!({})); + assert!(matches!(kind, ConstraintKind::Distance { .. } | ConstraintKind::Length { .. }), "{kind:?}"); + let (kind, ..) = dimension([30.0, 40.0], entities.clone(), json!({"type": "distance", "text_at": [15, 60]})); + assert!(matches!(kind, ConstraintKind::Distance { .. } | ConstraintKind::Length { .. }), "{kind:?}"); + let (kind, ..) = dimension([30.0, 40.0], entities.clone(), json!({"type": "vertical", "text_at": [15, 60]})); + assert!(matches!(kind, ConstraintKind::DistanceY { .. }), "{kind:?}"); + let (kind, ..) = dimension([40.0, 0.0], json!(["l1.start", "l1.end"]), json!({"text_at": [60, 5]})); + assert!(matches!(kind, ConstraintKind::Distance { .. }), "{kind:?}"); + } + // The chosen dimension drives that coordinate: 24 across X, the 40 in Y kept. + let (_, _, sk) = dimension([30.0, 40.0], points, json!({"text_at": [15, 60], "value": 24})); + let end = sk.point(sk.resolve_point("l1.end").expect("end")).expect("end point"); + let start = sk.point(sk.resolve_point("l1.start").expect("start")).expect("start point"); + assert!(((end.x - start.x).abs() - 24.0).abs() < 1e-6 && ((end.y - start.y).abs() - 40.0).abs() < 1e-6, "{start:?} {end:?}"); +} + /// A two-link arm (B, elbow E, hand H) hanging off a rigid triangle A B C that only the cluster /// decomposition places: pinned at F by two of its corners and held at G by the third. Every /// line is dimensioned. Without decomposition budget the elbow's seed has nothing to build on. diff --git a/crates/ui-egui/src/dimension_create_ui_tests.rs b/crates/ui-egui/src/dimension_create_ui_tests.rs index 0bd259be..d4277bee 100644 --- a/crates/ui-egui/src/dimension_create_ui_tests.rs +++ b/crates/ui-egui/src/dimension_create_ui_tests.rs @@ -323,3 +323,28 @@ fn inch_dimension_entry_survives_unit_changes_undo_and_reopen() { assert_eq!(reopened.world_state().sketch(sketch).unwrap().sketch, before); crate::dim_view::cancel(); } + +/// Placing the dimension of two unaligned points above them gives the horizontal distance, +/// beside them the vertical one, and across from them the distance between them (#257). +#[test] +fn placing_a_two_point_dimension_picks_horizontal_vertical_or_aligned() { + // What each placement fixes at 10 mm: the X span, the Y span, or the length. + let spans = |a: solvecraft_engine::geom::Vec2, b: solvecraft_engine::geom::Vec2| [(b.x - a.x).abs(), (b.y - a.y).abs(), a.dist(b)]; + for (at, measured) in [([6., 25.], 0), ([-10., 1.], 1), ([-5., 12.], 2)] { + let mut h = line([12., 16.]); + place(&mut h, &[[0., 0.], [12., 16.]], at); + type_value(&mut h, "10 mm"); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(!crate::dim_view::editing()); + let state = h.app.session.world_state(); + let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + let a = sk.point(sk.resolve_point("l1.start").unwrap()).unwrap(); + let b = sk.point(sk.resolve_point("l1.end").unwrap()).unwrap(); + let spans = spans(a, b); + assert!((spans[measured] - 10.0).abs() < 1e-6, "placed at {at:?}: {a:?} {b:?}"); + // A horizontal or vertical dimension leaves the other span as drawn. + if measured < 2 { + assert!((spans[1 - measured] - [12.0, 16.0][1 - measured]).abs() < 1e-6, "placed at {at:?}: {a:?} {b:?}"); + } + } +} diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index fba5b9bc..1584b737 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -108,6 +108,10 @@ midpoint, symmetry, fix, curvature (G2) and polygon. **Sketch Dimension** (D) ad aligned, horizontal, vertical, radius, diameter and angle dimensions. Each is a parameter (`d1`, `d2`, …), so it can hold an expression. +Between two points, or on a line, that don't line up with an axis, where you place the +dimension picks what it measures: across from them, the distance between them; above or below +them, the horizontal distance; beside them, the vertical distance. + For **Coincident**, pick two points, a point and a curve (in either order), or two distinct straight lines. Two lines join their closest pair of endpoints; the first picked line stays in place when the existing constraints allow it. Pick endpoints