diff --git a/crates/engine/src/cmd/sketch_constraints.rs b/crates/engine/src/cmd/sketch_constraints.rs index 3fad7313..25ab797b 100644 --- a/crates/engine/src/cmd/sketch_constraints.rs +++ b/crates/engine/src/cmd/sketch_constraints.rs @@ -36,6 +36,14 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("finish") .enabled(in_sketch) .params("like AutoConstrain, then Finish Sketch"), + CommandSpec::new("SketchConstrainer", "SketchConstrainer", smart_constrain) + .at("SKETCH", "CREATE") + .icon("constrainer") + .enabled(in_sketch) + .params("entities: [one or two sketch entity ids]: applies the constraint the geometry nearly has (horizontal/vertical, parallel, perpendicular, collinear, coincident, concentric, equal, tangent)"), + CommandSpec::new("sketch.toggle_driven", "Toggle Driven Dimension", toggle_driven) + .enabled(in_sketch) + .params("constraint: dimension id, driven?: bool (default toggles); a driving dimension gets a parameter with its current value"), CommandSpec::new("sketch.glyphs", "Constraint Glyphs", glyphs) .enabled(in_sketch) .noundo() @@ -262,6 +270,135 @@ fn auto_constrain_finish(s: &mut Session, p: &Value) -> Result { Ok(r) } +/// The constraint two entities (or one line) are closest to satisfying. +fn guess(sk: &Sketch, ents: &[String], cmd: &str) -> Result { + use ConstraintKind::*; + let ent = |r: &str| -> Result<(Option, Option)> { + if let Some(c) = sk.curve_index(r) { + return Ok((None, Some(c))); + } + sk.resolve_point(r).map(|p| (Some(p), None)).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`"))) + }; + let line_dir = |c: usize| match sk.shape(c) { + Some(solvecraft_sketch::Shape::Line { a, b }) => (b - a).normalized(), + _ => None, + }; + let round = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Arc { .. })); + match ents { + [a] => { + let (_, Some(c)) = ent(a)? else { return Err(bad(cmd, "pick a line, or two entities")) }; + let d = line_dir(c).ok_or_else(|| bad(cmd, "a single entity must be a line"))?; + Ok(if d.x.abs() >= d.y.abs() { Horizontal { l: c } } else { Vertical { l: c } }) + } + [a, b] => match (ent(a)?, ent(b)?) { + ((Some(p), _), (Some(q), _)) => Ok(Coincident { p, q }), + ((Some(p), _), (_, Some(c))) | ((_, Some(c)), (Some(p), _)) => { + // A point at a curve's centre is concentric-like: coincident with the centre. + if let Some(cp) = sk.curves.get(c).and_then(|cu| match cu.kind { + CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } => Some(c), + _ => None, + }) && sk.point(cp).zip(sk.point(p)).is_some_and(|(x, y)| x.dist(y) < sk.radius(c).unwrap_or(1.0) * 0.25) + { + return Ok(Coincident { p, q: cp }); + } + Ok(PointOnCurve { p, c }) + } + ((_, Some(x)), (_, Some(y))) => { + if let (Some(u), Some(v)) = (line_dir(x), line_dir(y)) { + let cross = u.cross(v).abs(); + if cross < 0.2 { + // Parallel; on one line → collinear. + let off = match (sk.shape(x), sk.shape(y)) { + (Some(solvecraft_sketch::Shape::Line { a, .. }), Some(solvecraft_sketch::Shape::Line { a: b0, .. })) => { + u.cross(b0 - a).abs() + } + _ => f64::INFINITY, + }; + let len = sk.polyline(x).windows(2).map(|w| w[0].dist(w[1])).sum::(); + return Ok(if off < len * 0.05 { Collinear { a: x, b: y } } else { Parallel { a: x, b: y } }); + } + if u.dot(v).abs() < 0.2 { + return Ok(Perpendicular { a: x, b: y }); + } + return Ok(Equal { a: x, b: y }); + } + if round(x) && round(y) { + let (cx, cy) = (sk.center(x).unwrap_or_default(), sk.center(y).unwrap_or_default()); + let (rx, ry) = (sk.radius(x).unwrap_or(1.0), sk.radius(y).unwrap_or(1.0)); + if cx.dist(cy) < 0.25 * rx.min(ry) { + return Ok(Concentric { a: x, b: y }); + } + let d = cx.dist(cy); + if (d - (rx + ry)).abs() < 0.15 * rx.min(ry) || (d - (rx - ry).abs()).abs() < 0.15 * rx.min(ry) { + return Ok(Tangent { a: x, b: y }); + } + return Ok(Equal { a: x, b: y }); + } + Ok(Tangent { a: x, b: y }) + } + _ => Err(bad(cmd, "cannot constrain that combination")), + }, + _ => Err(bad(cmd, "`entities` must list one or two entities")), + } +} + +fn smart_constrain(s: &mut Session, p: &Value) -> Result { + let cmd = "SketchConstrainer"; + let ents = string_list(p, "entities"); + let mut before = None; + let mut name = ""; + let (id, info) = edit(s, p, cmd, true, |sk, _| { + let k = guess(sk, &ents, cmd)?; + name = k.name(); + before = Some(dof_now(sk)); + add_c(sk, k) + })?; + reject_redundant(before, &info, cmd)?; + Ok(json!({"constraint": id, "type": name, "sketch": info})) +} + +fn toggle_driven(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.toggle_driven"; + let cid = str_(p, "constraint").ok_or_else(|| bad(cmd, "`constraint` must be a dimension id"))?.to_string(); + let want = crate::params::bool_(p, "driven"); + let mut before = None; + let (driven, info) = edit(s, p, cmd, true, |sk, doc| { + let c = sk.constraints.iter().find(|c| c.id == cid).cloned().ok_or_else(|| bad(cmd, format!("no constraint `{cid}`")))?; + let v = c.kind.value().ok_or_else(|| bad(cmd, format!("`{cid}` is not a dimension")))?; + let driven = want.unwrap_or(!c.driven); + if driven == c.driven { + return Ok(driven); + } + let slot = sk.constraints.iter_mut().find(|c| c.id == cid).ok_or_else(|| bad(cmd, "constraint"))?; + if driven { + slot.driven = true; + slot.param = None; + } else { + let (unit, e) = if c.kind.is_angle() { + ("deg", format!("{} deg", (v.to_degrees() * 1e6).round() / 1e6)) + } else { + ("mm", format!("{} mm", (v * 1e6).round() / 1e6)) + }; + slot.driven = false; + slot.param = Some(doc.new_model_param(&e, unit)); + before = None; + } + Ok(driven) + })?; + if !driven { + // Making it driving must not over-constrain: compare with the sketch without it. + let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch"))?; + let mut sk = s.doc.sketch(id)?.clone(); + if let Some(c) = sk.constraints.iter_mut().find(|c| c.id == cid) { + c.driven = true; + } + before = Some(dof_now(&sk)); + reject_redundant(before, &info, cmd)?; + } + s.doc_mut().prune_model_params(); + Ok(json!({"constraint": cid, "driven": driven, "sketch": info})) +} + /// Where a constraint's glyph goes (sketch coordinates): beside the middle of its first curve, /// or at its point. fn anchor(sk: &Sketch, k: &ConstraintKind) -> Option { diff --git a/crates/engine/src/cmd/sketch_constraints_tests.rs b/crates/engine/src/cmd/sketch_constraints_tests.rs index d144094e..b311c72a 100644 --- a/crates/engine/src/cmd/sketch_constraints_tests.rs +++ b/crates/engine/src/cmd/sketch_constraints_tests.rs @@ -146,3 +146,27 @@ fn drawing_infers_tangent_and_perpendicular() { let names: Vec<&str> = si["constraints"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect(); assert!(names.contains(&"Perpendicular") && names.contains(&"Tangent"), "{names:?} {r}"); } + +#[test] +fn smart_constrain_and_driven_toggle() { + let mut s = new_sketch(); + let a = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0.5]]}))["curves"])[0].clone(); + let b = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 5], [10, 6]]}))["curves"])[0].clone(); + let r = run(&mut s, "SketchConstrainer", json!({"entities": [a]})); + assert_eq!(r["type"], "Horizontal"); + let r = run(&mut s, "SketchConstrainer", json!({"entities": [a, b]})); + assert_eq!(r["type"], "Parallel"); + let c1 = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [50, 0], "radius": 5}))["curves"])[0].clone(); + let c2 = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [50.3, 0.2], "radius": 3}))["curves"])[0].clone(); + let r = run(&mut s, "SketchConstrainer", json!({"entities": [c1, c2]})); + assert_eq!(r["type"], "Concentric"); + // Driven toggle. + let d = run(&mut s, "SketchDimension", json!({"entities": [c1], "value": 10})); + let g = run(&mut s, "sketch.glyphs", json!({})); + let did = g["glyphs"].as_array().unwrap().iter().find(|x| x["param"] == d["param"]).unwrap()["id"].as_str().unwrap().to_string(); + let r = run(&mut s, "sketch.toggle_driven", json!({"constraint": did})); + assert_eq!(r["driven"], true); + assert!(s.doc.param(d["param"].as_str().unwrap()).is_none(), "the parameter goes"); + let r = run(&mut s, "sketch.toggle_driven", json!({"constraint": did})); + assert_eq!(r["driven"], false); +} diff --git a/docs/parity.md b/docs/parity.md index 452da8a7..e51867b8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,9 +2,9 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth. -**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 153 / 541 commands live (28%).** SOLID + SKETCH: 114 / 291 (39%). Registered commands: 160. +**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 154 / 541 commands live (28%).** SOLID + SKETCH: 115 / 291 (40%). Registered commands: 162. -Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 247 / 1175 (21%). +Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 248 / 1175 (21%). | Tab | Panel | Live | Total | % | |---|---|---:|---:|---:| @@ -17,7 +17,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | SOLID | INSERT | 3 | 11 | 27% | | SOLID | SELECT | 0 | 15 | 0% | | SOLID | POSITION | 2 | 2 | 100% | -| SKETCH | CREATE | 40 | 41 | 98% | +| SKETCH | CREATE | 41 | 41 | 100% | | SKETCH | MODIFY | 12 | 12 | 100% | | SKETCH | CONSTRAINTS | 15 | 15 | 100% | | SKETCH | CONFIGURE | 0 | 3 | 0% | @@ -152,6 +152,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SOLID › POSITION › Revert Position (`AsBuiltPositionsCmd`) - SKETCH › CREATE › Line (`DrawPolyline`) - SKETCH › CREATE › Midpoint Line (`SketchMidpointLine`) +- SKETCH › CREATE › SketchConstrainer (`SketchConstrainer`) - SKETCH › CREATE › 2-Point Rectangle (`ShapeRectangleTwoPoint`) - SKETCH › CREATE › 3-Point Rectangle (`ShapeRectangleThreePoint`) - SKETCH › CREATE › Center Rectangle (`ShapeRectangleCenter`) @@ -351,7 +352,6 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SOLID › SELECT › Select Edge Priority (`SelectEdgePriorityCommand`) - SOLID › SELECT › Select Component Priority (`SelectComponentPriorityCommand`) - SOLID › SELECT › Selection Filters (`SelectionFilterCommand`) -- SKETCH › CREATE › SketchConstrainer (`SketchConstrainer`) - SKETCH › CONFIGURE › Configure (`FusionStartDesignConfigModeCmd`) - SKETCH › CONFIGURE › Display Configuration Table (`FusionShowDesignConfigPanelCmd`) - SKETCH › CONFIGURE › Create Configuration Rules (`FusionConfigurationRulesCmd`)