From ef53bc46f3ed3b2fbc9f9623b968efad8987d8d0 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 15 Oct 2026 13:13:14 -0400 Subject: [PATCH] Sketch: the first dimension scales a sketch drawn by eye (#1365) (#1416) * Sketch: the first dimension scales a sketch drawn by eye (#1365) Lines drawn by eye have no size of their own until a dimension gives them one, but the first dimension kept every other drawn length and distorted the sketch to fit (a free construction line through the origin turned instead of the shape moving). The dimension tool now scales the whole sketch about its origin to the first length dimension, as long as nothing in the sketch fixes a size yet: no other driving length dimension, nothing fixed, projected or patterned. Every geometric constraint and angle holds on the scaled sketch. Later dimensions size what they measure as before; sketch.dimension does this only with scale_sketch: true, so scripts are unchanged. Fixes #1365 Co-Authored-By: Claude Opus 5.5 * Scenario s01: dimension the box's height too, now that the first dimension scales the sketch Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/sketch.rs | 20 ++++++- crates/engine/src/cmd/sketch_ux_tests.rs | 56 +++++++++++++++++++ crates/sketch/src/model.rs | 48 ++++++++++++++++ crates/ui-egui/src/dim_view.rs | 3 +- .../ui-egui/src/dimension_create_ui_tests.rs | 9 ++- .../scenarios/s01_box_fillet_hole_shell.json | 40 ++++++++++++- 6 files changed, 168 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 181d69d3..bc19ee39 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -111,7 +111,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 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. A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. 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. A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends true): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled", ), CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical) .at("SKETCH", "CONSTRAINTS") @@ -1880,6 +1880,8 @@ fn dimension(s: &mut Session, p: &Value) -> Result { let value = expr(p, "value"); let text_at = p.get("text_at").and_then(vec2); let driven = bool_(p, "driven").unwrap_or(false); + let scale_sketch = bool_(p, "scale_sketch").unwrap_or(false); + let mut scaled = None; let mut before = None; let (mut residual, mut new_id) = (None, String::new()); let start = s.doc.clone(); @@ -2043,6 +2045,16 @@ fn dimension(s: &mut Session, p: &Value) -> Result { // only restates what the sketch already has. let want = vals.get(&pname).and_then(|v| v.to_kind(if is_angle { Kind::Angle } else { Kind::Length }).ok()); residual = want.map(|w| (w - cur).abs()).filter(|r| r.is_finite()); + // The first length dimension of a sketch with nothing sized yet scales it (#1365). + if scale_sketch + && !is_angle + && let Some(factor) = want.map(|w| w / cur).filter(|f| f.is_finite() && (f - 1.0).abs() > 1e-9) + && let Some(big) = sk.scaled_for_first_dimension(factor) + { + *sk = big; + scaled = Some(factor); + residual = Some(0.0); + } let name = k.name(); let id = sk.add_constraint(k, Some(pname.clone()))?; new_id = id.clone(); @@ -2078,7 +2090,11 @@ fn dimension(s: &mut Session, p: &Value) -> Result { )); } let v = s.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default(); - Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info})) + let mut out = json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info}); + if let Some(factor) = scaled { + out["scaled"] = json!(round6(factor)); + } + Ok(out) } /// A dimension's measured value as reported, in the unit its expression is written in. diff --git a/crates/engine/src/cmd/sketch_ux_tests.rs b/crates/engine/src/cmd/sketch_ux_tests.rs index 8656cfa3..0e1c1830 100644 --- a/crates/engine/src/cmd/sketch_ux_tests.rs +++ b/crates/engine/src/cmd/sketch_ux_tests.rs @@ -1468,3 +1468,59 @@ fn a_live_drag_shares_one_decomposition_budget() { assert!(d.count > 6, "{d:?}"); assert!(d.work <= budget + one.work, "{d:?} over a budget of {budget}"); } + +/// #1365: lines drawn by eye and a construction line through the origin, as in the report. +fn drawn_by_eye() -> Session { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[-56.7, 0], [56.7, 0]], "construction": true, "ids": ["hc"]})); + run(&mut s, "sketch.constraint.coincident", json!({"a": "origin", "b": "hc"})); + run(&mut s, "sketch.line", json!({"points": [[0, -54], [0, 55.2]], "construction": true, "ids": ["vc"]})); + run(&mut s, "sketch.constraint.coincident", json!({"a": "origin", "b": "vc"})); + run( + &mut s, + "sketch.line", + json!({"points": [[0, 26], [29.6, 26], [29.6, 41.2], [44, 41.2], [44, 0]], "infer": true, "ids": ["a", "b", "c", "d"]}), + ); + run(&mut s, "sketch.constraint.coincident", json!({"a": "a.start", "b": "vc"})); + run(&mut s, "sketch.constraint.coincident", json!({"a": "d.end", "b": "hc"})); + s +} + +#[test] +fn the_first_dimension_scales_a_sketch_drawn_by_eye() { + let mut s = drawn_by_eye(); + let before = sketch(&s); + let out = run(&mut s, "sketch.dimension", json!({"entities": ["vc", "b"], "value": "50 mm", "text_at": [15, 47], "scale_sketch": true})); + let f = 50.0 / 29.6; + assert!((out["scaled"].as_f64().unwrap() - f).abs() < 1e-6, "{out}"); + assert!((out["measured"].as_f64().unwrap() - 50.0).abs() < 1e-6, "{out}"); + // Every point scaled about the origin: the shape and the construction lines keep their + // directions (the vertical construction line stays vertical), only the size changes. + let after = sketch(&s); + for (p, q) in before.points.iter().zip(&after.points) { + assert!(q.pos.dist(p.pos * f) < 1e-6, "{} {:?} -> {:?}", p.id, p.pos, q.pos); + } + // The second dimension sizes the sketch as usual: nothing scales again. + let out = run(&mut s, "sketch.dimension", json!({"entities": ["c"], "value": "30 mm", "scale_sketch": true})); + assert!(out.get("scaled").is_none(), "{out}"); + assert!((pt(&s, "c.end").x - pt(&s, "c.start").x - 30.0).abs() < 1e-6); + assert!((pt(&s, "b.start").x - pt(&s, "vc.start").x - 50.0).abs() < 1e-6); + // The construction line can still be made vertical (it already is: redundant, not a conflict). + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "vc"})); +} + +#[test] +fn the_first_dimension_does_not_scale_a_sketch_that_has_a_size() { + // A sketch with projected or fixed geometry, or another length dimension, already has a + // size: its first new dimension sizes what it measures as before. + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [60, 40]})); + run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"})); + let out = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": "50 mm", "scale_sketch": true})); + assert!(out.get("scaled").is_none(), "{out}"); + // An angle never scales. + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0], [50, 20]]})); + let out = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l2"], "value": "60 deg", "scale_sketch": true})); + assert!(out.get("scaled").is_none(), "{out}"); +} diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 5ea713c3..bf278854 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -949,6 +949,54 @@ impl Sketch { Ok(()) } + /// The sketch scaled by `factor` about its origin, for its first dimension (#1365): drawn + /// geometry has no size of its own until a dimension gives it one, so the first length + /// dimension scales the whole sketch to its value instead of distorting it. Only a sketch + /// with nothing that fixes a size: no other driving length dimension, nothing fixed, + /// projected or patterned, no offsets and no 3D curves. Every geometric constraint + /// (coincident, horizontal, parallel, tangent, equal, symmetric, …) and every angle holds + /// on the scaled sketch as it held before. `None` when the sketch does not qualify. + pub fn scaled_for_first_dimension(&self, factor: f64) -> Option { + if !(factor.is_finite() && (1e-6..=1e6).contains(&factor)) { + return None; + } + let sizes = self.constraints.iter().any(|c| match c.kind { + ConstraintKind::Fix { .. } | ConstraintKind::PatternPoint { .. } | ConstraintKind::PatternRadius { .. } => true, + ref k => k.is_dimension() && !k.is_angle() && !c.driven, + }); + if sizes + || !self.links.is_empty() + || !self.patterns.is_empty() + || !self.offsets.is_empty() + || !self.freeform_offsets.is_empty() + || !self.wires.is_empty() + || self.curves.iter().any(|c| c.fixed || c.link.is_some()) + || self.points.iter().enumerate().any(|(i, p)| (p.fixed || p.link.is_some()) && (i != 0 || p.pos != Vec2::ZERO)) + { + return None; + } + let mut out = self.clone(); + for p in out.points.iter_mut().filter(|p| !p.fixed) { + p.pos = p.pos * factor; + if !in_range(p.pos) { + return None; + } + } + for c in &mut out.curves { + if let CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. } = &mut c.kind { + *r *= factor; + if !radius_in_range(*r) { + return None; + } + } + } + // Dimension texts keep their places relative to what they measure. + for t in out.constraints.iter_mut().filter_map(|c| c.text.as_mut()) { + *t = *t * factor; + } + Some(out) + } + /// Is point `i` held in place: fixed, projected (linked), fixed by a Fix constraint or a /// point of a fixed curve. pub fn point_locked(&self, i: usize) -> bool { diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs index 1eddb760..4c88a7eb 100644 --- a/crates/ui-egui/src/dim_view.rs +++ b/crates/ui-egui/src/dim_view.rs @@ -90,7 +90,8 @@ impl DimensionDraft { && app.session.pending_moves == self.pending } fn params(&self, expr: &str) -> serde_json::Value { - let mut params = json!({"entities": self.entities, "value": expr}); + // The first dimension of a sketch scales it rather than distorting it (#1365). + let mut params = json!({"entities": self.entities, "value": expr, "scale_sketch": true}); if let Some(at) = self.at { params["text_at"] = json!([at.x, at.y]); } diff --git a/crates/ui-egui/src/dimension_create_ui_tests.rs b/crates/ui-egui/src/dimension_create_ui_tests.rs index 809e7c61..7cfd1910 100644 --- a/crates/ui-egui/src/dimension_create_ui_tests.rs +++ b/crates/ui-egui/src/dimension_create_ui_tests.rs @@ -146,7 +146,8 @@ fn rectangle_side_and_fixed_origin_line_dimensions_preserve_other_constraints() h.step(&json!({"key":"Enter"})).unwrap(); let state = h.app.session.world_state(); let solved = state.sketch(h.app.session.active_sketch.unwrap()).unwrap(); - assert!((solved.profiles[0].area - 240.).abs() < 1e-5, "side dimensions keep rectangle width: {}", solved.profiles[0].area); + // The sketch's first dimension scales it (#1365): 20 × 10 becomes 24 × 12. + assert!((solved.profiles[0].area - 288.).abs() < 1e-5, "the first dimension scales the rectangle: {}", solved.profiles[0].area); assert_eq!(h.app.session.undo.len(), undo + 1); h.app.run("edit.undo", json!({})).unwrap(); assert_eq!(h.app.session.doc.to_json(), baseline); @@ -342,9 +343,11 @@ fn placing_a_two_point_dimension_picks_horizontal_vertical_or_aligned() { 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. + // The sketch's first dimension scales it (#1365): the other span keeps its proportion. if measured < 2 { - assert!((spans[1 - measured] - [12.0, 16.0][1 - measured]).abs() < 1e-6, "placed at {at:?}: {a:?} {b:?}"); + let drawn = [12.0, 16.0]; + let other = drawn[1 - measured] * 10.0 / drawn[measured]; + assert!((spans[1 - measured] - other).abs() < 1e-6, "placed at {at:?}: {a:?} {b:?}"); } } } diff --git a/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json b/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json index 45c796e3..5a6c0ad8 100644 --- a/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json +++ b/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json @@ -50,7 +50,14 @@ ] } }, - {"click": {"sketch": [20, -8]}}, + { + "click": { + "sketch": [ + 20, + -8 + ] + } + }, { "text": "50" }, @@ -60,11 +67,40 @@ { "key": "Escape" }, + { + "start": "sketch.dimension" + }, + { + "click": { + "sketch": [ + 50, + 15 + ] + } + }, + { + "click": { + "sketch": [ + 58, + 15 + ] + } + }, + { + "text": "30" + }, + { + "key": "Enter" + }, + { + "key": "Escape" + }, { "expect": { "dialog": null, "param": { - "d1": 50 + "d1": 50, + "d2": 30 } } },