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