From 54ee20bef4ad0d582574c0e6287a1f7fe39775cd Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 23:06:31 +0000 Subject: [PATCH] Sketch constraints and dimensions: Curvature (G2, ConstraintSmooth) and Polygon constraints; redundant constraints and dimensions are refused as over-constraining (Fusion behaviour), with driven (reference) dimensions that measure instead; arc length and linear diameter (about a centerline) dimensions; sketch.glyphs (constraint glyph anchors, conflicts) and sketch.snap (coincident/midpoint/on-curve/horizontal/vertical inference); solver damping floor keeps weakly seen variables from flying off Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/sketch.rs | 76 ++++++- crates/engine/src/cmd/sketch_constraints.rs | 209 ++++++++++++++++++ .../src/cmd/sketch_constraints_tests.rs | 113 ++++++++++ crates/engine/src/tests.rs | 3 + crates/sketch/src/curves.rs | 113 +++++++--- crates/sketch/src/lib.rs | 2 +- crates/sketch/src/model.rs | 64 +++++- crates/sketch/src/solver.rs | 128 ++++++++++- docs/parity.md | 8 +- 10 files changed, 668 insertions(+), 50 deletions(-) create mode 100644 crates/engine/src/cmd/sketch_constraints.rs create mode 100644 crates/engine/src/cmd/sketch_constraints_tests.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a3ae23af..ddc80117 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -10,6 +10,7 @@ pub use features::auto_operation; mod file; mod inspect; mod sketch; +mod sketch_constraints; mod sketch_create; mod sketch_modify; mod sketch_project; @@ -110,6 +111,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { let mut v: Vec<&'static CommandSpec> = Vec::new(); v.extend(sketch::COMMANDS.iter()); v.extend(sketch_create::COMMANDS.iter()); + v.extend(sketch_constraints::COMMANDS.iter()); v.extend(sketch_modify::COMMANDS.iter()); v.extend(sketch_project::COMMANDS.iter()); v.extend(features::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 662109e9..657ec5bc 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -786,13 +786,33 @@ fn draw_point(s: &mut Session, p: &Value) -> Result { // Constraints fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&Sketch) -> Result) -> Result { + let mut before = None; let (id, info) = edit(s, p, cmd, true, |sk, _| { let k = f(sk)?; + before = Some(dof_now(sk)); add_c(sk, k) })?; + reject_redundant(before, &info, cmd)?; Ok(json!({"constraint": id, "sketch": info})) } +/// Degrees of freedom of a sketch as it is. +pub(super) fn dof_now(sk: &Sketch) -> usize { + solve(&mut sk.clone()).dof +} + +/// A constraint that removes no degree of freedom over-constrains the sketch (Fusion refuses +/// it); the caller's edit is rolled back by returning the error. +pub(super) fn reject_redundant(before: Option, info: &Value, cmd: &str) -> Result<()> { + let after = info.get("dof").and_then(Value::as_u64).map(|d| d as usize); + if let (Some(b), Some(a)) = (before, after) + && a >= b + { + return Err(bad(cmd, "that would over-constrain the sketch (it is already determined by other constraints)")); + } + Ok(()) +} + fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result { let cmd = "ConstraintHorizontalVertical"; let mode = str_(p, "mode").map(str::to_ascii_lowercase); @@ -962,7 +982,10 @@ 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 mut before = None; let ((param, kind_name, current), info) = edit(s, p, cmd, true, |sk, doc| { + before = Some(dof_now(sk)); let ent = |r: &str| -> Result<(Option, Option)> { if let Some(c) = sk.curve_index(r) { return Ok((None, Some(c))); @@ -992,8 +1015,12 @@ fn dimension(s: &mut Session, p: &Value) -> Result { _ => (ConstraintKind::Length { l: c, value: a.dist(b) }, a.dist(b)), } } + ((None, Some(c)), None) if ty == "arc_length" || ty == "arclength" => { + let len = arc_len(sk, c).ok_or_else(|| bad(cmd, "arc length needs an arc"))?; + (ConstraintKind::ArcLength { c, value: len }, len) + } ((None, Some(c)), None) => { - let r = sk.radius(c).unwrap_or(1.0); + let r = sk.radius(c).ok_or_else(|| bad(cmd, "cannot dimension that curve"))?; let diameter = ty == "diameter" || (ty != "radius" && is_circle(c)); if diameter { (ConstraintKind::Diameter { c, value: 2.0 * r }, 2.0 * r) } else { (ConstraintKind::Radius { c, value: r }, r) } } @@ -1009,17 +1036,27 @@ fn dimension(s: &mut Session, p: &Value) -> Result { let (a, b) = line_pts(sk, l).unwrap_or_default(); let pq = sk.point(q).unwrap_or_default(); let d = (b - a).normalized().map(|d| d.cross(pq - a).abs()).unwrap_or(0.0); - (ConstraintKind::PointLineDistance { p: q, l, value: d }, d) + if diameter_about(sk, l, &ty) { + (ConstraintKind::LinearDiameter { p: q, l, value: 2.0 * d }, 2.0 * d) + } else { + (ConstraintKind::PointLineDistance { p: q, l, value: d }, d) + } } ((None, Some(a)), Some((None, Some(b)))) if is_line(a) && is_line(b) => { let (a0, a1) = line_pts(sk, a).unwrap_or_default(); let (b0, b1) = line_pts(sk, b).unwrap_or_default(); let (da, db) = (a1 - a0, b1 - b0); if da.cross(db).abs() < 1e-9 * da.len() * db.len() && ty != "angle" { - // Parallel lines: distance between them. + // Parallel lines: distance between them (a diameter about a centerline). + let (a, b, b0) = if sk.curves.get(a).is_some_and(|c| c.centerline) { (a, b, b0) } else { (b, a, a0) }; let (bq, _) = line_ends(sk, b); - let d = da.normalized().map(|d| d.cross(b0 - a0).abs()).unwrap_or(0.0); - (ConstraintKind::PointLineDistance { p: bq, l: a, value: d }, d) + let (a0, a1) = line_pts(sk, a).unwrap_or_default(); + let d = (a1 - a0).normalized().map(|d| d.cross(b0 - a0).abs()).unwrap_or(0.0); + if diameter_about(sk, a, &ty) { + (ConstraintKind::LinearDiameter { p: bq, l: a, value: 2.0 * d }, 2.0 * d) + } else { + (ConstraintKind::PointLineDistance { p: bq, l: a, value: d }, d) + } } else { match text_at { Some(t) => angle_by_sector(a, b, (a0, a1), (b0, b1), t).ok_or_else(|| bad(cmd, "cannot place that angle"))?, @@ -1038,6 +1075,16 @@ fn dimension(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(cmd, "cannot dimension that combination of entities")), }; let is_angle = k.is_angle(); + if driven { + // A reference dimension: measures, no parameter. + let name = k.name(); + let id = sk.add_constraint(k, None)?; + if let Some(c) = sk.constraints.iter_mut().find(|c| c.id == id) { + c.driven = true; + } + before = None; + return Ok((String::new(), name, cur)); + } let (unit, default_expr) = if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) }; let e = value.clone().unwrap_or(default_expr); @@ -1052,10 +1099,29 @@ fn dimension(s: &mut Session, p: &Value) -> Result { sk.add_constraint(k, Some(pname.clone()))?; Ok((pname, name, cur)) })?; + if driven { + return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current, "sketch": info})); + } + if reject_redundant(before, &info, cmd).is_err() { + return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true")); + } 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, "sketch": info})) } +/// Dimension a point (or a parallel line) to line `l` as a diameter: when `l` is a centerline +/// (unless a plain distance was asked for) or when a diameter was asked for. +fn diameter_about(sk: &Sketch, l: usize, ty: &str) -> bool { + ty == "diameter" || (ty == "auto" && sk.curves.get(l).is_some_and(|c| c.centerline)) +} + +fn arc_len(sk: &Sketch, c: usize) -> Option { + match sk.segs(c).first()? { + solvecraft_geom::Seg2::Arc { radius, sweep, .. } if matches!(sk.curves.get(c)?.kind, CurveKind::Arc { .. }) => Some(radius * sweep), + _ => None, + } +} + /// The angle dimension between two lines whose sector contains `t` (the text position): returns /// the constraint and the current value of that sector angle. fn angle_by_sector(a: usize, b: usize, (a0, a1): (Vec2, Vec2), (b0, b1): (Vec2, Vec2), t: Vec2) -> Option<(ConstraintKind, f64)> { diff --git a/crates/engine/src/cmd/sketch_constraints.rs b/crates/engine/src/cmd/sketch_constraints.rs new file mode 100644 index 00000000..c160bbdb --- /dev/null +++ b/crates/engine/src/cmd/sketch_constraints.rs @@ -0,0 +1,209 @@ +//! More sketch constraints (curvature, polygon), constraint glyph anchors and snapping +//! inference for interactive tools. + +use serde_json::{Value, json}; +use solvecraft_geom::Vec2; +use solvecraft_sketch::{ConstraintKind, CurveKind, Sketch}; + +use super::sketch::{add_c, dof_now, edit, reject_redundant}; +use super::{CommandSpec, in_sketch}; +use crate::params::{bad, num, req_vec2, string_list, vec2}; +use crate::{EngineError, Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("ConstraintSmooth", "Curvature", c_smooth) + .at("SKETCH", "CONSTRAINTS") + .icon("c_smooth") + .enabled(in_sketch) + .params("a, b: two curves sharing an end point (curvature continuous, G2)"), + CommandSpec::new("SketchPolygonConstraintCmd", "Polygon", c_polygon) + .at("SKETCH", "CONSTRAINTS") + .icon("c_polygon") + .enabled(in_sketch) + .params("lines: [line ids] of a closed chain: equal sides with every corner on one circle (a regular polygon)"), + CommandSpec::new("sketch.glyphs", "Constraint Glyphs", glyphs) + .enabled(in_sketch) + .noundo() + .params("sketch?: id|name: every constraint with where to draw its glyph (sketch coordinates)"), + CommandSpec::new("sketch.snap", "Snap Inference", snap) + .enabled(in_sketch) + .noundo() + .params("at: [x,y], from?: [x,y] (previous click), radius?: mm (snap distance, default 1): what a click there would snap to and infer"), +]; + +fn c_smooth(s: &mut Session, p: &Value) -> Result { + let cmd = "ConstraintSmooth"; + let mut before = None; + let (id, info) = edit(s, p, cmd, true, |sk, _| { + let a = curve(sk, p.get("a"), cmd)?; + let b = curve(sk, p.get("b"), cmd)?; + before = Some(dof_now(sk)); + add_c(sk, ConstraintKind::Smooth { a, b }) + })?; + reject_redundant(before, &info, cmd)?; + Ok(json!({"constraint": id, "sketch": info})) +} + +fn curve(sk: &Sketch, v: Option<&Value>, cmd: &str) -> Result { + let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, "expected a curve id"))?; + sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`"))) +} + +fn c_polygon(s: &mut Session, p: &Value) -> Result { + let cmd = "SketchPolygonConstraintCmd"; + let ids = string_list(p, "lines"); + if ids.len() < 3 || ids.len() > 200 { + return Err(bad(cmd, "`lines` must list 3 to 200 lines of a closed chain")); + } + let (out, info) = edit(s, p, cmd, true, |sk, _| { + let ls: Vec = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::>()?; + let mut corners: Vec = Vec::new(); + for l in &ls { + match sk.curves.get(*l).map(|c| &c.kind) { + Some(CurveKind::Line { a, b }) => { + for q in [*a, *b] { + if !corners.contains(&q) { + corners.push(q); + } + } + } + _ => return Err(bad(cmd, "the polygon must be made of lines")), + } + } + if corners.len() != ls.len() { + return Err(bad(cmd, "the lines must form one closed chain")); + } + let pts: Vec = corners.iter().filter_map(|q| sk.point(*q)).collect(); + let c = pts.iter().fold(Vec2::ZERO, |a, q| a + *q) / pts.len() as f64; + let r = pts.iter().map(|q| q.dist(c)).sum::() / pts.len() as f64; + let circ = sk.add_circle(c, r.max(1e-6), None, None)?; + if let Some(cu) = sk.curves.get_mut(circ) { + cu.construction = true; + } + let mut cons = Vec::new(); + for w in ls.windows(2) { + cons.push(add_c(sk, ConstraintKind::Equal { a: w[0], b: w[1] })?); + } + for q in corners { + cons.push(add_c(sk, ConstraintKind::PointOnCurve { p: q, c: circ })?); + } + Ok(cons) + })?; + Ok(json!({"constraints": out, "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 { + use ConstraintKind::*; + let mid = |c: usize| -> Option { + let poly = sk.polyline(c); + poly.get(poly.len() / 2).copied() + }; + let pt = |p: usize| sk.point(p); + match *k { + Coincident { p, .. } | Fix { p } | Midpoint { p, .. } => pt(p), + PointOnCurve { p, .. } => pt(p), + HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => Some((pt(p)? + pt(q)?) * 0.5), + Horizontal { l } | Vertical { l } => mid(l), + Parallel { a, .. } | Perpendicular { a, .. } | Collinear { a, .. } | Equal { a, .. } | Concentric { a, .. } | Angle { a, .. } => mid(a), + Tangent { a, b } | Smooth { a, b } => { + // At the shared end when there is one. + let ends = |c: usize| sk.curves.get(c).and_then(|c| c.kind.ends()); + match (ends(a), ends(b)) { + (Some((a0, a1)), Some((b0, b1))) => [a0, a1].into_iter().find(|x| *x == b0 || *x == b1).and_then(pt).or_else(|| mid(a)), + _ => mid(a), + } + } + Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => Some((pt(p)? + pt(q)?) * 0.5), + PointLineDistance { p, .. } | LinearDiameter { p, .. } => pt(p), + Length { l, .. } => mid(l), + Radius { c, .. } | Diameter { c, .. } | ArcLength { c, .. } => mid(c), + } +} + +fn glyphs(s: &mut Session, p: &Value) -> Result { + let id = match p.get("sketch") { + Some(Value::Number(n)) => n.as_u64(), + Some(Value::String(x)) => s.doc.find_feature(x).map(|f| f.id), + _ => s.active_sketch, + } + .ok_or_else(|| bad("sketch.glyphs", "no such sketch"))?; + let st = s.model.state(); + let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?; + let failing = &ss.report.failing; + let list: Vec = ss + .sketch + .constraints + .iter() + .map(|c| { + json!({ + "id": c.id, + "name": c.kind.name(), + "dimension": c.kind.is_dimension(), + "driven": c.driven, + "value": c.kind.value(), + "param": c.param, + "at": anchor(&ss.sketch, &c.kind), + "failing": failing.contains(&c.id), + }) + }) + .collect(); + Ok(json!({"sketch": id, "glyphs": list, "dof": ss.report.dof, "fully_constrained": ss.report.fully_constrained(), "conflicts": failing})) +} + +/// Snap inference for a click at `at`: the nearest sketch point (coincident), the midpoint of a +/// line, a point on a curve, and horizontal/vertical alignment with `from` or with other +/// points. Results are ordered by priority; the first one is what a click would use. +fn snap(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.snap"; + let at = req_vec2(cmd, p, "at")?; + let from = p.get("from").and_then(vec2); + let r = num(p, "radius").filter(|r| *r > 0.0).unwrap_or(1.0); + let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?; + let st = s.model.state(); + let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch is not evaluated".into()))?; + let sk = &ss.sketch; + let mut out: Vec<(f64, Value)> = Vec::new(); + // Points (end points, centres, the origin). + for q in &sk.points { + let d = q.pos.dist(at); + if d <= r { + out.push((d * 0.5, json!({"type": "coincident", "point": q.id, "at": q.pos}))); + } + } + for (i, c) in sk.curves.iter().enumerate() { + if let CurveKind::Line { a, b } = c.kind + && let (Some(pa), Some(pb)) = (sk.point(a), sk.point(b)) + { + let m = (pa + pb) * 0.5; + let d = m.dist(at); + if d <= r { + out.push((d * 0.6, json!({"type": "midpoint", "curve": c.id, "at": m}))); + } + } + if let Some(sh) = sk.shape(i) { + let q = sh.project(at); + if sh.dist(at) <= r && q.dist(at) <= r { + out.push((sh.dist(at) + r * 0.5, json!({"type": "on_curve", "curve": c.id, "at": q}))); + } + } + } + // Alignment with the previous click. + if let Some(f) = from { + let d = at - f; + if d.y.abs() <= r && d.x.abs() > r { + out.push((d.y.abs() + r, json!({"type": "horizontal", "at": [at.x, f.y]}))); + } + if d.x.abs() <= r && d.y.abs() > r { + out.push((d.x.abs() + r, json!({"type": "vertical", "at": [f.x, at.y]}))); + } + } + out.sort_by(|a, b| a.0.total_cmp(&b.0)); + let list: Vec = out.into_iter().take(8).map(|x| x.1).collect(); + Ok(json!({"snaps": list})) +} + +#[cfg(test)] +#[path = "sketch_constraints_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/sketch_constraints_tests.rs b/crates/engine/src/cmd/sketch_constraints_tests.rs new file mode 100644 index 00000000..18f2fc64 --- /dev/null +++ b/crates/engine/src/cmd/sketch_constraints_tests.rs @@ -0,0 +1,113 @@ +use serde_json::{Value, json}; +use solvecraft_geom::Vec2; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn ids(v: &Value) -> Vec { + v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() +} + +fn new_sketch() -> Session { + let mut s = Session::default(); + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + s +} + +fn sketch(s: &Session) -> solvecraft_sketch::Sketch { + s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.clone() +} + +#[test] +fn redundant_constraints_and_dimensions_are_refused_driven_ones_measure() { + let mut s = new_sketch(); + let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"])[0].clone(); + // Already horizontal (inferred). + let e = s.execute("ConstraintHorizontalVertical", &json!({"line": l})).unwrap_err().to_string(); + assert!(e.contains("over-constrain"), "{e}"); + run(&mut s, "ConstraintCoincident", json!({"a": format!("{l}.start"), "b": "origin"})); + run(&mut s, "SketchDimension", json!({"entities": [l], "value": 10})); + let si = run(&mut s, "sketch.inspect", json!({})); + assert_eq!(si["dof"], 0, "{si}"); + // A second length on it over-constrains: refused, unless driven. + assert!(s.execute("SketchDimension", &json!({"entities": [format!("{l}.start"), format!("{l}.end")]})).is_err()); + let d = run(&mut s, "SketchDimension", json!({"entities": [format!("{l}.start"), format!("{l}.end")], "driven": true})); + assert_eq!(d["driven"], true); + // The driven dimension follows the driving one. + let pname = si["constraints"].as_array().unwrap().iter().find_map(|c| c["param"].as_str().map(str::to_string)).unwrap(); + run(&mut s, "ChangeParameterCommand", json!({"name": pname, "expression": "25 mm"})); + let g = run(&mut s, "sketch.glyphs", json!({})); + let driven = g["glyphs"].as_array().unwrap().iter().find(|x| x["driven"] == true).unwrap().clone(); + assert!((driven["value"].as_f64().unwrap() - 25.0).abs() < 1e-7, "{driven}"); + assert_eq!(g["fully_constrained"], true); +} + +#[test] +fn curvature_polygon_arc_length_linear_diameter() { + let mut s = new_sketch(); + // A horizontal line ending where a control-point spline starts: G2 keeps the spline's + // start straight (zero curvature) and tangent. + let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-10, 0], [0, 0]], "infer": true}))["curves"])[0].clone(); + let sp = ids(&run(&mut s, "DrawCVMSpline3D", json!({"points": [format!("{l}.end"), [5, 2], [10, 6], [15, 15]]}))["curves"])[0].clone(); + run(&mut s, "ConstraintSmooth", json!({"a": l, "b": sp})); + let sk = sketch(&s); + let pts: Vec = sk.curves[sk.curve_index(&sp).unwrap()].kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect(); + // Tangent: first leg along +x; zero curvature: the second leg too (collinear first three). + assert!((pts[1] - pts[0]).y.abs() < 1e-5, "{pts:?}"); + assert!((pts[2] - pts[1]).cross(pts[1] - pts[0]).abs() < 1e-3, "{pts:?}"); + + // Polygon constraint on a rough quadrilateral: becomes a square-ish regular polygon. + let q = run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [111, 1], [110, 9], [99, 10]], "closed": true})); + let ql = ids(&q["curves"]); + run(&mut s, "SketchPolygonConstraintCmd", json!({"lines": ql})); + let sk = sketch(&s); + let lens: Vec = ql.iter().map(|i| sk.polyline(sk.curve_index(i).unwrap())).map(|p| p[0].dist(p[p.len() - 1])).collect(); + assert!(lens.iter().all(|x| (x - lens[0]).abs() < 1e-6), "{lens:?}"); + + // Arc length. + let a = ids(&run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 50], "start": [10, 50], "end": [0, 60]}))["curves"])[0].clone(); + run(&mut s, "SketchDimension", json!({"entities": [a], "type": "arc_length", "value": 20})); + let sk = sketch(&s); + let ai = sk.curve_index(&a).unwrap(); + let len = match sk.segs(ai)[0] { + solvecraft_geom::Seg2::Arc { radius, sweep, .. } => radius * sweep, + _ => 0.0, + }; + assert!((len - 20.0).abs() < 1e-6, "{len}"); + + // Linear diameter about a centerline. + let cl = ids(&run(&mut s, "DrawPolyline", json!({"points": [[200, 0], [200, 50]]}))["curves"])[0].clone(); + run(&mut s, "sketch.centerline", json!({"curves": [cl]})); + run(&mut s, "DrawPoint", json!({"point": [207, 10], "id": "rim"})); + let d = run(&mut s, "SketchDimension", json!({"entities": ["rim", cl], "value": 30})); + assert_eq!(d["type"], "LinearDiameterDimension", "{d}"); + let sk = sketch(&s); + let rim = sk.point(sk.point_index("rim").unwrap()).unwrap(); + let line = sk.polyline(sk.curve_index(&cl).unwrap()); + let (a, b) = (line[0], line[line.len() - 1]); + let dist = (b - a).normalized().unwrap().cross(rim - a).abs(); + assert!((dist - 15.0).abs() < 1e-6, "{rim:?} {dist}"); +} + +#[test] +fn glyphs_and_snaps() { + let mut s = new_sketch(); + let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]})); + let l = ids(&r["curves"]); + let g = run(&mut s, "sketch.glyphs", json!({})); + assert_eq!(g["glyphs"].as_array().unwrap().len(), 4); + assert!(g["glyphs"][0]["at"].is_array()); + let sn = run(&mut s, "sketch.snap", json!({"at": [20.3, 0.2], "radius": 1})); + assert_eq!(sn["snaps"][0]["type"], "coincident", "{sn}"); + let sn = run(&mut s, "sketch.snap", json!({"at": [10.2, 0.3], "radius": 1})); + assert_eq!(sn["snaps"][0]["type"], "midpoint", "{sn}"); + assert_eq!(sn["snaps"][0]["curve"], json!(l[0])); + let sn = run(&mut s, "sketch.snap", json!({"at": [40, 10.4], "from": [30, 10], "radius": 1})); + assert!(sn["snaps"].as_array().unwrap().iter().any(|x| x["type"] == "horizontal"), "{sn}"); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 073ac40c..c8fa4564 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -282,6 +282,9 @@ fn hostile_params_never_panic() { "apex", "rho", "text", + "lines", + "driven", + "from", ]; let mut internal = Vec::new(); for spec in command_specs() { diff --git a/crates/sketch/src/curves.rs b/crates/sketch/src/curves.rs index cd4da17b..130739e2 100644 --- a/crates/sketch/src/curves.rs +++ b/crates/sketch/src/curves.rs @@ -94,38 +94,73 @@ fn hermite(p0: Vec2, p1: Vec2, t0: Vec2, t1: Vec2, s: f64) -> Vec2 { p0 * (2.0 * s3 - 3.0 * s2 + 1.0) + t0 * (s3 - 2.0 * s2 + s) + p1 * (-2.0 * s3 + 3.0 * s2) + t1 * (s3 - s2) } -/// Clamped uniform B-spline of `degree` over control points `p`, sampled. -fn bspline_polyline(p: &[Vec2], degree: usize) -> Vec { +/// Clamped uniform B-spline of `degree` over control points `p` at `t` in [0, 1] (de Boor). +fn bspline_at(p: &[Vec2], degree: usize, t: f64) -> Vec2 { let n = p.len(); let k = degree.min(n.saturating_sub(1)).max(1); - // Knots: k+1 zeros, uniform interior, k+1 ones. let spans = n - k; let mut knots = vec![0.0; k + 1]; for i in 1..spans { knots.push(i as f64 / spans as f64); } knots.extend(std::iter::repeat_n(1.0, k + 1)); - let total = SPAN_SAMPLES * spans; - (0..=total) - .map(|i| { - let t = i as f64 / total as f64; - // De Boor. - let mut s = k; - while s + 1 < n && knots.get(s + 1).is_some_and(|x| *x <= t) { - s += 1; - } - let mut d: Vec = (0..=k).map(|j| p.get(j + s - k).copied().unwrap_or_default()).collect(); - for r in 1..=k { - for j in (r..=k).rev() { - let i0 = j + s - k; - let (lo, hi) = (knots.get(i0).copied().unwrap_or(0.0), knots.get(i0 + k + 1 - r).copied().unwrap_or(1.0)); - let a = if hi - lo > 1e-15 { (t - lo) / (hi - lo) } else { 0.0 }; - d[j] = d[j - 1] * (1.0 - a) + d[j] * a; - } - } - d[k] - }) - .collect() + let mut s = k; + while s + 1 < n && knots.get(s + 1).is_some_and(|x| *x <= t) { + s += 1; + } + let mut d: Vec = (0..=k).map(|j| p.get(j + s - k).copied().unwrap_or_default()).collect(); + for r in 1..=k { + for j in (r..=k).rev() { + let i0 = j + s - k; + let (lo, hi) = (knots.get(i0).copied().unwrap_or(0.0), knots.get(i0 + k + 1 - r).copied().unwrap_or(1.0)); + let a = if hi - lo > 1e-15 { (t - lo) / (hi - lo) } else { 0.0 }; + d[j] = d[j - 1] * (1.0 - a) + d[j] * a; + } + } + d[k] +} + +/// Clamped uniform B-spline of `degree` over control points `p`, sampled. +fn bspline_polyline(p: &[Vec2], degree: usize) -> Vec { + let n = p.len(); + let k = degree.min(n.saturating_sub(1)).max(1); + let total = SPAN_SAMPLES * (n - k); + (0..=total).map(|i| bspline_at(p, degree, i as f64 / total as f64)).collect() +} + +/// Point of a spline at global parameter `t` in [0, 1]. +pub fn spline_point(p: &[Vec2], control: bool, degree: u8, t: f64) -> Vec2 { + let t = t.clamp(0.0, 1.0); + if p.len() < 2 { + return p.first().copied().unwrap_or_default(); + } + if control { + return bspline_at(p, degree.clamp(1, 7) as usize, t); + } + let spans = fit_spans(p); + let n = spans.len().max(1); + let x = t * n as f64; + let i = (x.floor() as usize).min(n - 1); + match spans.get(i) { + Some((p0, p1, t0, t1)) => hermite(*p0, *p1, *t0, *t1, x - i as f64), + None => p[0], + } +} + +/// Curvature vector (towards the centre of curvature, magnitude 1/radius) of a parametric +/// curve at an end (`at_end`), by one-sided second-order differences. +pub fn end_curvature(f: &dyn Fn(f64) -> Vec2, at_end: bool) -> Vec2 { + let h = 1e-3; + let (t0, sgn) = if at_end { (1.0, -1.0) } else { (0.0, 1.0) }; + let (f0, f1, f2, f3) = (f(t0), f(t0 + sgn * h), f(t0 + sgn * 2.0 * h), f(t0 + sgn * 3.0 * h)); + let d1 = (f0 * -3.0 + f1 * 4.0 - f2) / (2.0 * h) * sgn; + let d2 = (f0 * 2.0 - f1 * 5.0 + f2 * 4.0 - f3) / (h * h); + let l2 = d1.len2(); + if l2 < 1e-24 { + return Vec2::ZERO; + } + let t = d1 / l2.sqrt(); + (d2 - t * t.dot(d2)) / l2 } /// Polyline of a spline through (`control == false`) or over (`control`) the points. @@ -161,20 +196,20 @@ pub fn spline_end_tangent(p: &[Vec2], control: bool, at_end: bool) -> Option f64 { - let mut best = f64::INFINITY; +/// Signed distance from `q` to a polyline (positive on the left of the nearest segment). +pub fn polyline_signed_dist(poly: &[Vec2], q: Vec2) -> f64 { + let mut best = (f64::INFINITY, 1.0); for w in poly.windows(2) { let (a, b) = (w[0], w[1]); let d = b - a; let l2 = d.len2(); let t = if l2 > 0.0 { ((q - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 }; - best = best.min(q.dist(a + d * t)); + let dist = q.dist(a + d * t); + if dist < best.0 { + best = (dist, if d.cross(q - a) >= 0.0 { 1.0 } else { -1.0 }); + } } - if poly.len() == 1 { - best = poly[0].dist(q); - } - best + best.0 * best.1 } #[cfg(test)] @@ -195,6 +230,20 @@ mod tests { assert!((t.x - t.y).abs() < 1e-9); } + #[test] + fn end_curvature_of_a_circle_quarter() { + let w = std::f64::consts::FRAC_1_SQRT_2; + let rho = w / (1.0 + w); + let (a, x, b) = (Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)); + let k = end_curvature(&|t| conic_point(a, x, b, rho, t), false); + assert!(k.dist(Vec2::new(-0.1, 0.0)) < 1e-5, "{k:?}"); + let k1 = end_curvature(&|t| conic_point(a, x, b, rho, t), true); + assert!(k1.dist(Vec2::new(0.0, -0.1)) < 1e-5, "{k1:?}"); + // Fit splines have natural (straight) ends. + let p = [Vec2::new(0.0, 0.0), Vec2::new(10.0, 5.0), Vec2::new(20.0, 0.0)]; + assert!(end_curvature(&|t| spline_point(&p, false, 3, t), false).len() < 1e-3); + } + #[test] fn control_spline_is_clamped_and_conic_hits_its_ends() { let p = [Vec2::new(0.0, 0.0), Vec2::new(0.0, 10.0), Vec2::new(10.0, 10.0), Vec2::new(10.0, 0.0)]; diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 0b72601b..32c9f142 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -17,7 +17,7 @@ mod shape; mod solver; mod text; -pub use curves::{MAX_SPLINE_POINTS, conic_point, ellipse_point, spline_end_tangent, spline_polyline}; +pub use curves::{MAX_SPLINE_POINTS, conic_point, ellipse_point, end_curvature, spline_end_tangent, spline_point, spline_polyline}; pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES}; pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError}; pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions}; diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 7ebfc04b..2c4cdcb2 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -248,6 +248,23 @@ pub enum ConstraintKind { #[serde(default, skip_serializing_if = "std::ops::Not::not")] flip: bool, }, + /// Curvature continuity (G2) where curves `a` and `b` share an end point: tangent and + /// equal curvature. + Smooth { + a: usize, + b: usize, + }, + /// Length along an arc. + ArcLength { + c: usize, + value: f64, + }, + /// Twice the distance from point `p` to line `l` (a diameter about a centerline). + LinearDiameter { + p: usize, + l: usize, + value: f64, + }, } impl ConstraintKind { @@ -264,6 +281,8 @@ impl ConstraintKind { | Length { value, .. } | Radius { value, .. } | Diameter { value, .. } + | ArcLength { value, .. } + | LinearDiameter { value, .. } | Angle { value, .. } => Some(value), _ => None, } @@ -278,6 +297,8 @@ impl ConstraintKind { | Length { value, .. } | Radius { value, .. } | Diameter { value, .. } + | ArcLength { value, .. } + | LinearDiameter { value, .. } | Angle { value, .. } => *value = v, _ => {} } @@ -305,6 +326,9 @@ impl ConstraintKind { Radius { .. } => "RadialDimension", Diameter { .. } => "DiameterDimension", Angle { .. } => "AngularDimension", + Smooth { .. } => "Smooth", + ArcLength { .. } => "ArcLengthDimension", + LinearDiameter { .. } => "LinearDiameterDimension", } } fn indices(&self) -> (Vec, Vec) { @@ -322,6 +346,9 @@ impl ConstraintKind { Midpoint { p, l } | PointLineDistance { p, l, .. } => (vec![p], vec![l]), Symmetric { p, q, l } => (vec![p, q], vec![l]), Fix { p } => (vec![p], vec![]), + Smooth { a, b } => (vec![], vec![a, b]), + ArcLength { c, .. } => (vec![], vec![c]), + LinearDiameter { p, l, .. } => (vec![p], vec![l]), } } fn remap(&mut self, pmap: &dyn Fn(usize) -> Option, cmap: &dyn Fn(usize) -> Option) -> bool { @@ -369,6 +396,15 @@ impl ConstraintKind { fc(l); } Fix { p } => fp(p), + Smooth { a, b } => { + fc(a); + fc(b); + } + ArcLength { c, .. } => fc(c), + LinearDiameter { p, l, .. } => { + fp(p); + fc(l); + } } ok && ok2 } @@ -382,6 +418,9 @@ pub struct Constraint { /// For dimensions: the model parameter that drives the value (e.g. `d1`). #[serde(default, skip_serializing_if = "Option::is_none")] pub param: Option, + /// A driven (reference) dimension: it measures, it does not constrain. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub driven: bool, } /// A 2D sketch. Point 0 is always the fixed sketch origin (id `origin`). @@ -553,7 +592,7 @@ impl Sketch { } self.validate(&kind)?; let id = self.fresh("k"); - self.constraints.push(Constraint { id: id.clone(), kind, param }); + self.constraints.push(Constraint { id: id.clone(), kind, param, driven: false }); Ok(id) } @@ -577,8 +616,27 @@ impl Sketch { let need_line = |i: usize| if is_line(i) { Ok(()) } else { Err(SketchError::WrongKind(name(i), "needs a line".into())) }; let need_round = |i: usize| if is_round(i) { Ok(()) } else { Err(SketchError::WrongKind(name(i), "needs a circle or arc".into())) }; match *k { - Horizontal { l } | Vertical { l } | Length { l, .. } | Midpoint { l, .. } | Symmetric { l, .. } | PointLineDistance { l, .. } => { - need_line(l)? + Horizontal { l } + | Vertical { l } + | Length { l, .. } + | Midpoint { l, .. } + | Symmetric { l, .. } + | PointLineDistance { l, .. } + | LinearDiameter { l, .. } => need_line(l)?, + ArcLength { c, .. } => { + if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) { + return Err(SketchError::WrongKind(name(c), "arc length needs an arc".into())); + } + } + Smooth { a, b } => { + let ends = |i: usize| self.curves.get(i).and_then(|c| c.kind.ends()); + let shared = match (ends(a), ends(b)) { + (Some((a0, a1)), Some((b0, b1))) => a0 == b0 || a0 == b1 || a1 == b0 || a1 == b1, + _ => false, + }; + if !shared || a == b { + return Err(SketchError::Invalid("curvature continuity needs two curves sharing an end point".into())); + } } Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Angle { a, b, .. } => { need_line(a)?; diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index 3c6b9384..be3dff9f 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -157,6 +157,43 @@ impl State<'_> { _ => Vec::new(), } } + fn ends(&self, c: usize) -> Option<(usize, usize)> { + self.sk.curves.get(c)?.kind.ends() + } + /// Length along an arc (counter-clockwise from its start). + fn arc_length(&self, c: usize) -> f64 { + match self.sk.curves.get(c).map(|c| &c.kind) { + Some(CurveKind::Arc { c: ci, a, b }) => { + let (cc, pa, pb) = (self.p(*ci), self.p(*a), self.p(*b)); + let mut sw = (pb - cc).angle() - (pa - cc).angle(); + while sw <= 1e-12 { + sw += std::f64::consts::TAU; + } + cc.dist(pa) * sw + } + _ => 0.0, + } + } + /// Curvature vector of a curve at its point `p` (towards the centre of curvature). + fn curvature_at(&self, c: usize, p: usize) -> Vec2 { + let Some(kind) = self.sk.curves.get(c).map(|c| &c.kind) else { return Vec2::ZERO }; + match kind { + CurveKind::Line { .. } | CurveKind::Ellipse { .. } => Vec2::ZERO, + CurveKind::Circle { .. } | CurveKind::Arc { .. } => { + let (cc, r) = self.round(c); + (cc - self.p(p)) / (r * r).max(1e-24) + } + CurveKind::Spline { pts, control, degree } => { + let q: Vec = pts.iter().map(|i| self.p(*i)).collect(); + let at_end = pts.last() == Some(&p) && pts.first() != Some(&p); + crate::curves::end_curvature(&|t| crate::curves::spline_point(&q, *control, *degree, t), at_end) + } + CurveKind::Conic { a, b, apex, rho } => { + let (pa, pb, px) = (self.p(*a), self.p(*b), self.p(*apex)); + crate::curves::end_curvature(&|t| crate::curves::conic_point(pa, px, pb, *rho, t), p == *b) + } + } + } /// Unit tangent of any curve at its point `p` (an end point for open free-form curves). fn tangent_at(&self, c: usize, p: usize) -> Option { match self.sk.curves.get(c).map(|c| &c.kind)? { @@ -214,7 +251,7 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec) { PointOnCurve { p, c } => { let pp = s.p(p); if s.is_freeform(c) { - out.push(crate::curves::polyline_dist(&s.poly(c), pp)); + out.push(crate::curves::polyline_signed_dist(&s.poly(c), pp)); } else if s.is_line(c) { let (a, b) = s.line(c); out.push(line_dist(pp, a, b)); @@ -253,15 +290,17 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec) { // Without a shared end point: the curves touch (closest approach zero). let (f, o) = if s.is_freeform(a) { (a, b) } else { (b, a) }; let poly = s.poly(f); + // Signed gap at the closest approach (smooth through zero, unlike its size). + let closest = |v: &mut dyn Iterator| v.fold(f64::INFINITY, |m: f64, x| if x.abs() < m.abs() { x } else { m }); let d = if s.is_line(o) { let (p0, p1) = s.line(o); - poly.iter().map(|q| line_dist(*q, p0, p1).abs()).fold(f64::INFINITY, f64::min) + closest(&mut poly.iter().map(|q| line_dist(*q, p0, p1))) } else if s.is_freeform(o) { let other = s.poly(o); - poly.iter().map(|q| crate::curves::polyline_dist(&other, *q)).fold(f64::INFINITY, f64::min) + closest(&mut poly.iter().map(|q| crate::curves::polyline_signed_dist(&other, *q))) } else { let (cc, r) = s.round(o); - poly.iter().map(|q| (q.dist(cc) - r).abs()).fold(f64::INFINITY, f64::min) + closest(&mut poly.iter().map(|q| q.dist(cc) - r)) }; out.push(if d.is_finite() { d } else { 0.0 }); } else if la || lb { @@ -327,9 +366,63 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec) { let target = if flip { value + std::f64::consts::PI } else { value }; out.push(wrap_angle(ang - target) * scale.max(1e-6)); } + Smooth { a, b } => { + let shared = match (s.ends(a), s.ends(b)) { + (Some((a0, a1)), Some((b0, b1))) => [a0, a1].into_iter().find(|x| *x == b0 || *x == b1), + _ => None, + }; + let Some(p) = shared else { + out.extend([0.0, 0.0, 0.0]); + return; + }; + match (s.tangent_at(a, p), s.tangent_at(b, p)) { + (Some(u), Some(v)) => out.push(u.cross(v) * 10.0), + _ => out.push(0.0), + } + let k = s.curvature_at(a, p) - s.curvature_at(b, p); + out.extend([k.x * 100.0, k.y * 100.0]); + } + ArcLength { c, value } => out.push(s.arc_length(c) - value), + LinearDiameter { p, l, value } => { + let (a, b) = s.line(l); + out.push(2.0 * line_dist(s.p(p), a, b).abs() - value); + } } } +/// Measured value of a dimension (for driven dimensions). +fn measure(s: &State, k: &ConstraintKind) -> Option { + use ConstraintKind::*; + Some(match *k { + Distance { p, q, .. } => s.p(p).dist(s.p(q)), + DistanceX { p, q, .. } => (s.p(q).x - s.p(p).x).abs(), + DistanceY { p, q, .. } => (s.p(q).y - s.p(p).y).abs(), + PointLineDistance { p, l, .. } => { + let (a, b) = s.line(l); + line_dist(s.p(p), a, b).abs() + } + Length { l, .. } => { + let (a, b) = s.line(l); + a.dist(b) + } + Radius { c, .. } => s.round(c).1, + Diameter { c, .. } => 2.0 * s.round(c).1, + Angle { a, b, flip, .. } => { + let (a0, a1) = s.line(a); + let (b0, b1) = s.line(b); + let (da, db) = (a1 - a0, b1 - b0); + let ang = da.cross(db).atan2(da.dot(db)) - if flip { std::f64::consts::PI } else { 0.0 }; + ang.rem_euclid(std::f64::consts::TAU) + } + ArcLength { c, .. } => s.arc_length(c), + LinearDiameter { p, l, .. } => { + let (a, b) = s.line(l); + 2.0 * line_dist(s.p(p), a, b).abs() + } + _ => return None, + }) +} + /// One residual block: implicit arc radius equality, a user constraint, or a tangency at a known /// touch point (better conditioned than the distance form when an end point is on the curve). enum Block<'a> { @@ -445,6 +538,15 @@ fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec { curve(&mut v, l); } Fix { .. } => {} + Smooth { a, b } => { + curve(&mut v, a); + curve(&mut v, b); + } + ArcLength { c, .. } => curve(&mut v, c), + LinearDiameter { p, l, .. } => { + pt(&mut v, p); + curve(&mut v, l); + } }, Block::TangentAt(_, k, p) => { pt(&mut v, *p); @@ -553,11 +655,13 @@ fn lm(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec)], vars: &[usize], } } let mut improved = false; + // Damping floor: variables the residuals barely see must not take huge steps. + let floor = (0..n).map(|i| a.get(i, i)).fold(0.0_f64, f64::max) * 1e-4; for _ in 0..30 { let mut m = a.clone(); for i in 0..n { let d = a.get(i, i); - m.add(i, i, lambda * (d + 1e-9) + 1e-12); + m.add(i, i, lambda * (d.max(floor) + 1e-9) + 1e-12); } let rhs: Vec = g.iter().map(|v| -v).collect(); let Some(step) = lin_solve(m, rhs) else { @@ -625,6 +729,9 @@ pub fn solve(sk: &mut Sketch) -> SolveReport { } } for (i, c) in sk.constraints.iter().enumerate() { + if c.driven { + continue; + } let bl = match touch_point(sk, &c.kind) { Some(p) => Block::TangentAt(i, &c.kind, p), None => Block::User(i, &c.kind), @@ -725,6 +832,17 @@ pub fn solve(sk: &mut Sketch) -> SolveReport { Some(v) => vdet(Some(v)) && vdet(Some(v + 1)), None => true, }; + // Driven dimensions measure the solved geometry. + let fixed_lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0 }; + let measured: Vec> = + sk.constraints.iter().map(|c| if c.driven { measure(&State { sk, lay: &fixed_lay, x: &[] }, &c.kind) } else { None }).collect(); + for (c, m) in sk.constraints.iter_mut().zip(measured) { + if let Some(v) = m + && v.is_finite() + { + c.kind.set_value(v); + } + } let point_determined: Vec = (0..sk.points.len()).map(pdet).collect(); let curve_determined = sk.curves.iter().enumerate().map(|(i, c)| c.kind.point_ids().into_iter().all(pdet) && vdet(lay.rvar.get(i).copied().flatten())).collect(); diff --git a/docs/parity.md b/docs/parity.md index 05830ca4..d357b150 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,7 +2,7 @@ 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. -**SOLID + SKETCH: 91 / 291 commands live (31%).** All Design tabs: 184 / 1175 (16%). Registered commands: 119. +**SOLID + SKETCH: 93 / 291 commands live (32%).** All Design tabs: 186 / 1175 (16%). Registered commands: 123. | Tab | Panel | Live | Total | % | |---|---|---:|---:|---:| @@ -17,7 +17,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool | SOLID | POSITION | 0 | 2 | 0% | | SKETCH | CREATE | 35 | 41 | 85% | | SKETCH | MODIFY | 12 | 12 | 100% | -| SKETCH | CONSTRAINTS | 11 | 15 | 73% | +| SKETCH | CONSTRAINTS | 13 | 15 | 87% | | SKETCH | CONFIGURE | 0 | 3 | 0% | | SKETCH | INSPECT | 1 | 25 | 4% | | SKETCH | INSERT | 2 | 13 | 15% | @@ -189,6 +189,8 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SKETCH › CONSTRAINTS › Concentric (`ConstraintConcentric`) - SKETCH › CONSTRAINTS › Collinear (`ConstraintCollinear`) - SKETCH › CONSTRAINTS › Symmetry (`ConstraintSymmetry`) +- SKETCH › CONSTRAINTS › Curvature (`ConstraintSmooth`) +- SKETCH › CONSTRAINTS › Polygon (`SketchPolygonConstraintCmd`) - SKETCH › INSPECT › Measure (`MeasureCommand`) - SKETCH › INSERT › Insert Mesh (`ParaMeshInsertAlignCommand`) - SKETCH › INSERT › Insert Component (`FusionImportCommandFromToolbar`) @@ -339,8 +341,6 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SKETCH › CREATE › Isoparametric Curve (`SketchIsoparametricCurve`) - SKETCH › CONSTRAINTS › AutoConstrain (`SketchAutoConstraintAndDimCmd`) - SKETCH › CONSTRAINTS › AutoConstrain from datum (`SketchAutoConstraintAndDimFromDatumCmd`) -- SKETCH › CONSTRAINTS › Curvature (`ConstraintSmooth`) -- SKETCH › CONSTRAINTS › Polygon (`SketchPolygonConstraintCmd`) - SKETCH › CONFIGURE › Configure (`FusionStartDesignConfigModeCmd`) - SKETCH › CONFIGURE › Display Configuration Table (`FusionShowDesignConfigPanelCmd`) - SKETCH › CONFIGURE › Create Configuration Rules (`FusionConfigurationRulesCmd`)