From 5b8816aa7db25b240217ad879e260aa018eb20bf Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 04:58:30 +0000 Subject: [PATCH] Fix/Unfix on every sketch entity: points (Fix constraint) and lines, arcs, circles, ellipses, splines and conics (fixed curves hold their points and radius); ConstraintFix takes entities (fixes them all unless all are fixed); the toolbar button and the context menu act on the whole selection; fixed points never move when dragged or solved; projected geometry counts as fixed; fixed geometry is drawn green; sketch.inspect reports fixed Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/inspect.rs | 15 ++-- crates/engine/src/cmd/sketch.rs | 55 +++++++++++---- crates/engine/src/cmd/sketch_modify.rs | 3 +- crates/engine/src/cmd/sketch_ux_tests.rs | 88 ++++++++++++++++++++++++ crates/engine/src/view.rs | 4 ++ crates/sketch/src/link.rs | 10 ++- crates/sketch/src/model.rs | 69 +++++++++++++++++-- crates/sketch/src/solver.rs | 9 ++- crates/ui-egui/src/context_menu.rs | 7 +- crates/ui-egui/src/lib.rs | 16 +++++ crates/ui-egui/src/viewport.rs | 11 ++- 11 files changed, 252 insertions(+), 35 deletions(-) diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 8f865955..dfa1a5f9 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -100,12 +100,12 @@ fn sketch_json(s: &Session, id: u64) -> Option { let det = ss.report.curve_determined.get(i).copied().unwrap_or(false); let pid = |k: usize| sk.points.get(k).map(|p| p.id.clone()).unwrap_or_default(); let mut v = match c.kind { - CurveKind::Line { a, b } => json!({"id": c.id, "type": "line", "start": pid(a), "end": pid(b), "start_at": sk.point(a), "end_at": sk.point(b), "construction": c.construction, "fully_constrained": det}), - CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fully_constrained": det}), - CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fully_constrained": det}), - CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fully_constrained": det}), - CurveKind::Spline { ref pts, control, degree } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::>(), "control": control, "degree": degree, "construction": c.construction, "fully_constrained": det}), - CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fully_constrained": det}), + CurveKind::Line { a, b } => json!({"id": c.id, "type": "line", "start": pid(a), "end": pid(b), "start_at": sk.point(a), "end_at": sk.point(b), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), + CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), + CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), + CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), + CurveKind::Spline { ref pts, control, degree } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::>(), "control": control, "degree": degree, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), + CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), }; if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) { o.insert("link".into(), json!(l)); @@ -113,11 +113,12 @@ fn sketch_json(s: &Session, id: u64) -> Option { v }) .collect(); + let locked = sk.locked_points(); let points: Vec = sk .points .iter() .enumerate() - .map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": ss.plane.to_world(p.pos), "fixed": p.fixed, "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)})) + .map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": ss.plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)})) .collect(); let constraints: Vec = sk .constraints diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index bd20cce8..073e0e66 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -123,7 +123,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "CONSTRAINTS") .icon("c_fix") .enabled(in_sketch) - .params("entity: point or curve; fixed?: bool (default toggles)"), + .params("entity: point or curve | entities: [points and curves]; fixed?: bool (default: fix them all unless all are fixed, then unfix)"), CommandSpec::new("ConstraintMidPoint", "MidPoint", c_midpoint) .at("SKETCH", "CONSTRAINTS") .icon("c_midpoint") @@ -972,31 +972,55 @@ fn round_center(sk: &Sketch, c: usize) -> Option { fn c_fix(s: &mut Session, p: &Value) -> Result { let cmd = "ConstraintFix"; let want = bool_(p, "fixed"); + let mut refs = string_list(p, "entities"); + if let Some(e) = str_(p, "entity") { + refs.insert(0, e.to_string()); + } + if refs.is_empty() { + return Err(bad(cmd, "`entity` or `entities` must name sketch points or curves")); + } let (n, info) = edit(s, p, cmd, false, |sk, _| { - let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a point or curve id"))?; - let pts: Vec = if let Some(i) = sk.resolve_point(r) { - vec![i] - } else { - let c = sk.curve_index(r).ok_or_else(|| bad(cmd, format!("unknown entity `{r}`")))?; - sk.curves.get(c).map(|c| c.kind.point_ids()).unwrap_or_default() - }; - let is_fixed = |sk: &Sketch, q: usize| sk.constraints.iter().any(|c| c.kind == ConstraintKind::Fix { p: q }); - let all_fixed = pts.iter().all(|q| is_fixed(sk, *q)); + // Points and curves; a point is fixed by a Fix constraint, a curve by its flag (which + // also holds its radius). Projected geometry is always fixed. + let mut pts = Vec::new(); + let mut curves = Vec::new(); + for r in &refs { + if let Some(i) = sk.resolve_point(r) { + pts.push(i); + } else { + curves.push(sk.curve_index(r).ok_or_else(|| bad(cmd, format!("unknown entity `{r}`")))?); + } + } + let all_fixed = pts.iter().all(|q| *q == 0 || sk.point_locked(*q)) && curves.iter().all(|c| sk.curve_locked(*c)); let fix = want.unwrap_or(!all_fixed); let mut n = 0; for q in pts { - if q == 0 { + let own = sk.points.get(q).is_some_and(|x| x.fixed || x.link.is_some()); + if q == 0 || own { continue; } - if fix && !is_fixed(sk, q) { + let has = sk.constraints.iter().any(|c| c.kind == ConstraintKind::Fix { p: q }); + if fix && !has { add_c(sk, ConstraintKind::Fix { p: q })?; n += 1; - } else if !fix { + } else if !fix && has { sk.constraints.retain(|c| c.kind != ConstraintKind::Fix { p: q }); n += 1; } } - Ok(json!({"fixed": fix, "points": n})) + for c in curves { + let Some(cu) = sk.curves.get_mut(c) else { continue }; + if cu.link.is_some() { + continue; + } + cu.fixed = fix; + n += 1; + if !fix { + let ids = cu.kind.point_ids(); + sk.constraints.retain(|k| !matches!(k.kind, ConstraintKind::Fix { p } if ids.contains(&p))); + } + } + Ok(json!({"fixed": fix, "changed": n})) })?; Ok(json!({"result": n, "sketch": info})) } @@ -1319,8 +1343,9 @@ fn move_point(s: &mut Session, p: &Value) -> Result { let to = req_vec2(cmd, p, "to")?; let (_, info) = edit(s, p, cmd, false, |sk, _| { let q = point_ref(sk, p.get("point"), cmd, "point")?; + let locked = sk.point_locked(q); if let Some(pt) = sk.points.get_mut(q) - && !pt.fixed + && !locked { pt.pos = to; } diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 467211f4..8d0b488e 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -169,7 +169,7 @@ fn split_at(sk: &mut Sketch, ci: usize, ts: &[f64]) -> Result<(Vec, Vec Result<()> { let id = sk.fresh(prefix); - sk.curves.push(Curve { id, kind, construction, reversed: false, link: None, centerline: false }); + sk.curves.push(Curve { id, kind, construction, reversed: false, link: None, centerline: false, fixed: false }); pieces.push(sk.curves.len() - 1); Ok(()) }; @@ -980,6 +980,7 @@ fn copy_through( reversed: if flip { !cu.reversed } else { cu.reversed }, link: None, centerline: false, + fixed: false, }); made.push(sk.curves.len() - 1); } diff --git a/crates/engine/src/cmd/sketch_ux_tests.rs b/crates/engine/src/cmd/sketch_ux_tests.rs index b32de950..70b2a400 100644 --- a/crates/engine/src/cmd/sketch_ux_tests.rs +++ b/crates/engine/src/cmd/sketch_ux_tests.rs @@ -110,3 +110,91 @@ fn deleting_a_dimension_frees_its_parameter() { assert!(sketch(&s).constraints.iter().all(|c| !c.kind.is_dimension())); assert!(s.doc.param(¶m).is_none()); } + +// --------------------------------------------------------------------------------------------- +// Fix / Unfix + +fn pt(s: &Session, id: &str) -> solvecraft_geom::Vec2 { + let sk = sketch(s); + sk.resolve_point(id).and_then(|i| sk.point(i)).unwrap_or_else(|| panic!("no point {id}")) +} + +fn dof(s: &mut Session) -> i64 { + run(s, "sketch.inspect", json!({}))["dof"].as_i64().unwrap_or(-1) +} + +#[test] +fn fixed_points_survive_drags_and_solves_and_unfix_frees_them() { + let mut s = new_sketch(); + run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 0], [30, 20]]})); + let free = dof(&mut s); + let r = run(&mut s, "ConstraintFix", json!({"entity": "l1.end"})); + assert_eq!(r["result"]["fixed"], true); + assert_eq!(dof(&mut s), free - 2); + let corner = pt(&s, "l1.end"); + // Dragging the fixed point does nothing; dragging its neighbours never moves it. + run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [50, 50]})); + assert_eq!(pt(&s, "l1.end"), corner); + run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l2", "mode": "vertical"})); + run(&mut s, "sketch.move_point", json!({"point": "l2.end", "to": [40, 25]})); + run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 12})); + assert!(pt(&s, "l1.end").dist(corner) < 1e-12, "{:?}", pt(&s, "l1.end")); + assert!((pt(&s, "l1.start").dist(corner) - 12.0).abs() < 1e-6); + // Unfix: the point moves again. + let r = run(&mut s, "ConstraintFix", json!({"entity": "l1.end"})); + assert_eq!(r["result"]["fixed"], false); + run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [30, -5]})); + assert!(pt(&s, "l1.end").dist(corner) > 1.0); +} + +#[test] +fn fixing_curves_holds_their_points_and_radius() { + let mut s = new_sketch(); + run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 5})); + run(&mut s, "DrawPolyline", json!({"points": [[30, 0], [50, 0]]})); + run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 40], "start": [10, 40], "end": [0, 50]})); + run(&mut s, "CircleElipse", json!({"center": [60, 40], "major": [70, 40], "minor_radius": 4})); + let free = dof(&mut s); + // Multi-select fixes them all (circle 3, line 4, arc 5, ellipse 5 degrees of freedom). + let r = run(&mut s, "ConstraintFix", json!({"entities": ["c1", "l1", "a1", "e1"]})); + assert_eq!(r["result"]["changed"], 4); + assert_eq!(dof(&mut s), free - 17); + let si = run(&mut s, "sketch.inspect", json!({})); + assert!(si["curves"].as_array().is_some_and(|c| c.iter().all(|c| c["fixed"] == true)), "{si}"); + // A fixed circle keeps centre and radius; a dimension on it conflicts. + run(&mut s, "sketch.move_point", json!({"point": "c1.center", "to": [0, 0]})); + assert_eq!(pt(&s, "c1.center"), solvecraft_geom::Vec2::new(10.0, 10.0)); + assert!(s.execute("SketchDimension", &json!({"entities": ["c1"], "value": 20})).is_err()); + let r0 = sketch(&s).radius(0); + assert_eq!(r0, Some(5.0)); + // Toggle without `fixed`: all fixed, so all are unfixed. + let r = run(&mut s, "ConstraintFix", json!({"entities": ["c1", "l1", "a1", "e1"]})); + assert_eq!(r["result"]["fixed"], false); + assert_eq!(dof(&mut s), free); + run(&mut s, "SketchDimension", json!({"entities": ["c1"], "type": "radius", "value": 7})); + assert!((sketch(&s).radius(0).unwrap_or(0.0) - 7.0).abs() < 1e-9); + // Mixed selection: one fixed, one free → both end up fixed. + run(&mut s, "ConstraintFix", json!({"entity": "l1"})); + let r = run(&mut s, "ConstraintFix", json!({"entities": ["l1", "l1.start", "a1"]})); + assert_eq!(r["result"]["fixed"], true); + assert!(sketch(&s).curves.iter().filter(|c| c.id == "l1" || c.id == "a1").all(|c| c.fixed)); +} + +#[test] +fn projected_geometry_counts_as_fixed() { + let mut s = Session::default(); + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]})); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "Extrude", json!({"distance": 5})); + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [10, 0, 5]}]})); + let sk = sketch(&s); + let ci = sk.curves.iter().position(|c| c.link.is_some()).unwrap_or_else(|| panic!("projected: {r}")); + let id = sk.curves[ci].id.clone(); + assert!(sk.curve_locked(ci)); + let r = run(&mut s, "ConstraintFix", json!({"entity": id})); + // Already fixed: the toggle unfixes, which projected geometry ignores. + assert_eq!(r["result"]["changed"], 0); + assert!(sketch(&s).curve_locked(ci)); +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index c1a2a673..a0b19c14 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -20,6 +20,8 @@ pub mod colors { pub const SKETCH_DONE: Rgb = Rgb(20, 20, 20); pub const SKETCH_CONSTRUCTION: Rgb = Rgb(230, 130, 40); pub const SKETCH_FIXED: Rgb = Rgb(20, 20, 20); + /// Fixed (locked) geometry. + pub const SKETCH_LOCKED: Rgb = Rgb(0, 140, 60); pub const SKETCH_CENTERLINE: Rgb = Rgb(200, 110, 60); /// Linked (projected) reference geometry. pub const SKETCH_PROJECTED: Rgb = Rgb(150, 60, 190); @@ -72,6 +74,8 @@ pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool for (i, c) in sk.curves.iter().enumerate() { let col = if c.link.is_some() && !sk.is_text_curve(i) { if sk.is_lost_curve(i) { colors::SKETCH_LOST } else { colors::SKETCH_PROJECTED } + } else if c.fixed { + colors::SKETCH_LOCKED } else if c.centerline { colors::SKETCH_CENTERLINE } else if c.construction { diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index 835b07ee..a7b3fe42 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -407,7 +407,15 @@ impl Sketch { } }; let cid = self.fresh(prefix); - self.curves.push(Curve { id: cid, kind, construction: false, reversed: false, link: Some(id.to_string()), centerline: false }); + self.curves.push(Curve { + id: cid, + kind, + construction: false, + reversed: false, + link: Some(id.to_string()), + centerline: false, + fixed: false, + }); } for li in &lay.lone { point(self, *li)?; diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 45ba5fd8..13d6e884 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -140,6 +140,9 @@ pub struct Curve { /// an axis (revolve, diameter dimensions). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub centerline: bool, + /// Fixed (locked): its points and radius are constants for the solver. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub fixed: bool, } /// Geometric constraints and dimensions. Indices refer to `Sketch::points` (`p`, `q`) or @@ -536,7 +539,15 @@ impl Sketch { return Err(SketchError::Invalid("line needs two distinct points".into())); } let id = self.curve_id(id, "l")?; - self.curves.push(Curve { id, kind: CurveKind::Line { a, b }, construction: false, reversed: false, link: None, centerline: false }); + self.curves.push(Curve { + id, + kind: CurveKind::Line { a, b }, + construction: false, + reversed: false, + link: None, + centerline: false, + fixed: false, + }); Ok(self.curves.len() - 1) } @@ -565,7 +576,15 @@ impl Sketch { Some(i) if i < self.points.len() => i, _ => self.own_point(&id, "center", center)?, }; - self.curves.push(Curve { id, kind: CurveKind::Circle { c, r }, construction: false, reversed: false, link: None, centerline: false }); + self.curves.push(Curve { + id, + kind: CurveKind::Circle { c, r }, + construction: false, + reversed: false, + link: None, + centerline: false, + fixed: false, + }); Ok(self.curves.len() - 1) } @@ -591,7 +610,15 @@ impl Sketch { Some(i) if i < n && i != a => i, _ => self.own_point(&id, "end", p1)?, }; - self.curves.push(Curve { id, kind: CurveKind::Arc { c, a, b }, construction: false, reversed: false, link: None, centerline: false }); + self.curves.push(Curve { + id, + kind: CurveKind::Arc { c, a, b }, + construction: false, + reversed: false, + link: None, + centerline: false, + fixed: false, + }); Ok(self.curves.len() - 1) } @@ -680,6 +707,40 @@ impl Sketch { Ok(()) } + /// 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 { + self.points.get(i).is_some_and(|p| p.fixed || p.link.is_some()) + || self.constraints.iter().any(|c| c.kind == ConstraintKind::Fix { p: i }) + || self.curves.iter().any(|c| c.fixed && c.kind.uses(i)) + } + + /// [`Sketch::point_locked`] for every point at once. + pub fn locked_points(&self) -> Vec { + let mut out: Vec = self.points.iter().map(|p| p.fixed || p.link.is_some()).collect(); + let mut set = |i: usize| { + if let Some(x) = out.get_mut(i) { + *x = true; + } + }; + for c in &self.constraints { + if let ConstraintKind::Fix { p } = c.kind { + set(p); + } + } + for c in self.curves.iter().filter(|c| c.fixed) { + for p in c.kind.point_ids() { + set(p); + } + } + out + } + + /// Is curve `c` held in place: fixed or projected (linked). + pub fn curve_locked(&self, c: usize) -> bool { + self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some()) + } + /// Radius of a circle or arc. pub fn radius(&self, ci: usize) -> Option { match self.curves.get(ci)?.kind { @@ -834,7 +895,7 @@ impl Sketch { } }; let id = self.curve_id(id, prefix)?; - self.curves.push(Curve { id, kind, construction: false, reversed: false, link: None, centerline: false }); + self.curves.push(Curve { id, kind, construction: false, reversed: false, link: None, centerline: false, fixed: false }); Ok(self.curves.len() - 1) } diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index f9a6d2f4..e4d4a126 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -70,6 +70,13 @@ fn layout(sk: &Sketch) -> Layout { *f = true; } } + for c in sk.curves.iter().filter(|c| c.fixed) { + for p in c.kind.point_ids() { + if let Some(f) = fixed.get_mut(p) { + *f = true; + } + } + } let mut n = 0; let pvar = fixed .iter() @@ -86,7 +93,7 @@ fn layout(sk: &Sketch) -> Layout { .curves .iter() .map(|c| match c.kind { - CurveKind::Circle { .. } | CurveKind::Ellipse { .. } if c.link.is_none() => { + CurveKind::Circle { .. } | CurveKind::Ellipse { .. } if c.link.is_none() && !c.fixed => { n += 1; Some(n - 1) } diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 95fdf336..e660f6bd 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -586,11 +586,8 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { "ui.unsection" => drop(app.run("FusionHalfSectionViewCommand", json!({"clear": true}))), "ui.fit" => app.animate_view("fit"), "ui.home" => app.animate_view("home"), - "ui.fix" => { - for e in strs(&p, "entities") { - let _ = app.run("ConstraintFix", json!({ "entity": e })); - } - } + // One command for the whole selection: all fixed unless all already are. + "ui.fix" => drop(app.run("ConstraintFix", json!({ "entities": strs(&p, "entities") }))), "ui.editSketch" => { if let Some(id) = id_of(&p, "sketch") { app.edit_sketch(id); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 16c6e4ee..a2ff8eab 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -295,6 +295,22 @@ impl SolveApp { let edges = self.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::Edge { .. })); return self.start(if edges { "FusionFilletEdgesCommand" } else { "Extrude" }); } + // Fix/Unfix: the selected sketch points and curves change at once, otherwise pick them. + if id == "ConstraintFix" { + let ents: Vec = self + .session + .selection + .iter() + .filter_map(|s| match s { + solvecraft_engine::Sel::SketchCurve { id } | solvecraft_engine::Sel::SketchPoint { id } => Some(id.clone()), + _ => None, + }) + .collect(); + if !ents.is_empty() { + let _ = self.run(id, json!({ "entities": ents })); + return; + } + } // Construction toggle: selected sketch curves switch at once, otherwise pick them. if id == "sketch.construction" { let curves: Vec = self diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 730f069f..72ebaac1 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -178,6 +178,8 @@ fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] { c4(t.sketch) } else if c == colors::SKETCH_DONE || c == colors::SKETCH_FIXED { c4(t.sketch_fixed) + } else if c == colors::SKETCH_LOCKED { + c4(t.sketch_locked) } else { rgba(c) } @@ -1265,13 +1267,20 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { let Some(sid) = app.session.active_sketch else { return }; let Some(ss) = st.sketch(sid) else { return }; let sk = &ss.sketch; + let locked = sk.locked_points(); for (i, p) in sk.points.iter().enumerate() { if !solvecraft_engine::view::sketch_point_visible(sk, i) { continue; } let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) else { continue }; let det = ss.report.point_determined.get(i).copied().unwrap_or(false); - let c = if det { t.sketch_point } else { t.sketch_point_free }; + let c = if p.link.is_none() && locked.get(i).copied().unwrap_or(false) { + t.sketch_locked + } else if det { + t.sketch_point + } else { + t.sketch_point_free + }; if i == 0 { painter.circle(sp, 3.5, Color32::from_rgb(240, 200, 60), Stroke::new(1.0, t.body_edge)); } else {