From 6e200389067641a3d2d69bc2a61648c03233ec7d Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 22:55:34 +0000 Subject: [PATCH] Sketch Blend Curve (tangent control-point spline between two curve ends) and the Centerline line type (sketch.centerline: left out of profiles, drawn as an axis) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/sketch_create.rs | 60 ++++++++++++++++++++ crates/engine/src/cmd/sketch_modify.rs | 11 +++- crates/engine/src/cmd/sketch_modify_tests.rs | 30 ++++++++++ crates/engine/src/view.rs | 5 +- crates/sketch/src/link.rs | 2 +- crates/sketch/src/model.rs | 12 ++-- crates/sketch/src/profiles.rs | 2 +- docs/parity.md | 6 +- 8 files changed, 116 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index ae670c4d..12db3034 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -70,6 +70,14 @@ pub static COMMANDS: &[CommandSpec] = .icon("conic") .enabled(in_sketch) .params("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)"), + CommandSpec::new("BlendG1CurveSketchCmd", "Blend Curve", blend) + .at("SKETCH", "MODIFY") + .icon("blend") + .enabled(in_sketch) + .params("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them"), + CommandSpec::new("sketch.centerline", "Centerline", centerline) + .enabled(in_sketch) + .params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"), CommandSpec::new("MTextCmd", "Text", text) .at("SKETCH", "CREATE") .icon("text") @@ -139,6 +147,58 @@ fn edit_text(s: &mut Session, p: &Value) -> Result { Ok(json!({"link": l, "curves": curves})) } +/// The curve ending at point `pi` and the direction leaving it there. +fn end_of(sk: &Sketch, pi: usize) -> Option<(usize, Vec2)> { + sk.curves.iter().enumerate().filter(|(_, c)| !c.construction).find_map(|(i, _)| leaving_dir(sk, i, pi).map(|d| (i, d))) +} + +fn blend(s: &mut Session, p: &Value) -> Result { + let cmd = "BlendG1CurveSketchCmd"; + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let pa = req_parg(sk, p, "a", cmd)?.idx().ok_or_else(|| bad(cmd, "`a` must be the end point of a curve"))?; + let pb = req_parg(sk, p, "b", cmd)?.idx().ok_or_else(|| bad(cmd, "`b` must be the end point of a curve"))?; + let (ca, ta) = end_of(sk, pa).ok_or_else(|| bad(cmd, "no curve ends at `a`"))?; + let (cb, tb) = end_of(sk, pb).ok_or_else(|| bad(cmd, "no curve ends at `b`"))?; + let (qa, qb) = (sk.point(pa).unwrap_or_default(), sk.point(pb).unwrap_or_default()); + let l = qa.dist(qb); + if l < 1e-9 || ca == cb && pa == pb { + return Err(bad(cmd, "the ends must differ")); + } + let first_new = sk.points.len(); + let c1 = sk.add_point(qa + ta * (l / 3.0), None)?; + let c2 = sk.add_point(qb + tb * (l / 3.0), None)?; + let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3 }, None)?; + let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?]; + solve_new_only(sk, first_new); + Ok((ids_of(sk, &[sp]), cons)) + })?; + Ok(result(out, info)) +} + +fn centerline(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.centerline"; + let ids = string_list(p, "curves"); + if ids.is_empty() { + return Err(bad(cmd, "`curves` must list lines")); + } + let want = crate::params::bool_(p, "value"); + let (n, info) = edit(s, p, cmd, false, |sk, _| { + for id in &ids { + let c = sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?; + let cu = sk.curves.get_mut(c).ok_or_else(|| bad(cmd, "curve"))?; + if !matches!(cu.kind, CurveKind::Line { .. }) { + return Err(bad(cmd, format!("`{id}` is not a line"))); + } + cu.centerline = want.unwrap_or(!cu.centerline); + if cu.centerline { + cu.construction = false; + } + } + Ok(ids.len()) + })?; + Ok(json!({"changed": n, "sketch": info})) +} + fn ellipse(s: &mut Session, p: &Value) -> Result { let cmd = "CircleElipse"; let (c, m) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "major")?); diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index c4cdbdb9..94bdc4d9 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 }); + sk.curves.push(Curve { id, kind, construction, reversed: false, link: None, centerline: false }); pieces.push(sk.curves.len() - 1); Ok(()) }; @@ -827,7 +827,14 @@ fn copy_through( } }; let id = sk.fresh(prefix); - sk.curves.push(Curve { id, kind, construction: cu.construction, reversed: if flip { !cu.reversed } else { cu.reversed }, link: None }); + sk.curves.push(Curve { + id, + kind, + construction: cu.construction, + reversed: if flip { !cu.reversed } else { cu.reversed }, + link: None, + centerline: false, + }); made.push(sk.curves.len() - 1); } Ok((made, map)) diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index 6fc9777d..64525d59 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -284,3 +284,33 @@ fn text_makes_profiles_that_extrude_and_edit() { assert!(v > 40.0 && v < 200.0, "{v}"); assert!(s.execute("MTextCmd", &json!({"text": "", "at": [0, 0]})).is_err()); } + +#[test] +fn blend_curve_joins_tangentially() { + let mut s = new_sketch(); + let a = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]]}))["curves"])[0].clone(); + let b = ids(&run(&mut s, "DrawPolyline", json!({"points": [[20, 10], [20, 20]]}))["curves"])[0].clone(); + let r = run(&mut s, "BlendG1CurveSketchCmd", json!({"a": format!("{a}.end"), "b": format!("{b}.start")})); + assert_eq!(r["sketch"]["solved"], true, "{r}"); + let sp = ids(&r["curves"])[0].clone(); + 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(); + // Leaves along +x, arrives going +y (control polygon end legs). + let d0 = (pts[1] - pts[0]).normalized().unwrap(); + let d1 = (pts[3] - pts[2]).normalized().unwrap(); + assert!(d0.y.abs() < 1e-6 && d0.x > 0.0, "{d0:?}"); + assert!(d1.x.abs() < 1e-6 && d1.y > 0.0, "{d1:?}"); +} + +#[test] +fn centerline_is_left_out_of_profiles() { + let mut s = new_sketch(); + let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 10]})); + let l = ids(&r["curves"]); + let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[5, -5], [5, 15]]}))["curves"])[0].clone(); + assert_eq!(profiles(&s).len(), 2); + run(&mut s, "sketch.centerline", json!({"curves": [m]})); + assert_eq!(profiles(&s).len(), 1); + assert!(s.execute("sketch.centerline", &json!({"curves": ["nope"]})).is_err()); + let _ = l; +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index f26ab462..4efa0db5 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -20,6 +20,7 @@ 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); + pub const SKETCH_CENTERLINE: Rgb = Rgb(200, 110, 60); /// Linked (projected) reference geometry. pub const SKETCH_PROJECTED: Rgb = Rgb(150, 60, 190); /// Linked geometry whose reference was lost. @@ -71,6 +72,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.centerline { + colors::SKETCH_CENTERLINE } else if c.construction { colors::SKETCH_CONSTRUCTION } else if !active { @@ -81,7 +84,7 @@ pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool colors::SKETCH }; for seg in sk.segs(i) { - out.push((seg.polyline(0.02).iter().map(|p| plane.to_world(*p)).collect(), col, c.construction)); + out.push((seg.polyline(0.02).iter().map(|p| plane.to_world(*p)).collect(), col, c.construction || c.centerline)); } } out diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index 42901718..73306d8e 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -286,7 +286,7 @@ impl Sketch { LCurve::Conic(a, b, x, rho) => (CurveKind::Conic { a: point(self, a)?, b: point(self, b)?, apex: point(self, x)?, rho }, "k"), }; let cid = self.fresh(prefix); - self.curves.push(Curve { id: cid, kind, construction: false, reversed: false, link: Some(id.to_string()) }); + self.curves.push(Curve { id: cid, kind, construction: false, reversed: false, link: Some(id.to_string()), centerline: false }); } for li in &lay.lone { point(self, *li)?; diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 2a453675..7ebfc04b 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -136,6 +136,10 @@ pub struct Curve { /// points are fixed and a linked circle keeps its radius. #[serde(default, skip_serializing_if = "Option::is_none")] pub link: Option, + /// Centerline line type: like construction (not part of profiles), drawn dash-dot; marks + /// an axis (revolve, diameter dimensions). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub centerline: bool, } /// Geometric constraints and dimensions. Indices refer to `Sketch::points` (`p`, `q`) or @@ -484,7 +488,7 @@ 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 }); + self.curves.push(Curve { id, kind: CurveKind::Line { a, b }, construction: false, reversed: false, link: None, centerline: false }); Ok(self.curves.len() - 1) } @@ -513,7 +517,7 @@ 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 }); + self.curves.push(Curve { id, kind: CurveKind::Circle { c, r }, construction: false, reversed: false, link: None, centerline: false }); Ok(self.curves.len() - 1) } @@ -539,7 +543,7 @@ 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 }); + self.curves.push(Curve { id, kind: CurveKind::Arc { c, a, b }, construction: false, reversed: false, link: None, centerline: false }); Ok(self.curves.len() - 1) } @@ -715,7 +719,7 @@ impl Sketch { } }; let id = self.curve_id(id, prefix)?; - self.curves.push(Curve { id, kind, construction: false, reversed: false, link: None }); + self.curves.push(Curve { id, kind, construction: false, reversed: false, link: None, centerline: false }); Ok(self.curves.len() - 1) } diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index 8cadb43d..78b0c6ba 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -107,7 +107,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec { // Analytic shapes of the profile curves. let mut shapes: Vec<(usize, Shape)> = Vec::new(); for (ci, c) in sk.curves.iter().enumerate() { - if c.construction { + if c.construction || c.centerline { continue; } match c.kind { diff --git a/docs/parity.md b/docs/parity.md index 60522dc7..05830ca4 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: 90 / 291 commands live (31%).** All Design tabs: 183 / 1175 (16%). Registered commands: 117. +**SOLID + SKETCH: 91 / 291 commands live (31%).** All Design tabs: 184 / 1175 (16%). Registered commands: 119. | Tab | Panel | Live | Total | % | |---|---|---:|---:|---:| @@ -16,7 +16,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool | SOLID | SELECT | 0 | 15 | 0% | | SOLID | POSITION | 0 | 2 | 0% | | SKETCH | CREATE | 35 | 41 | 85% | -| SKETCH | MODIFY | 11 | 12 | 92% | +| SKETCH | MODIFY | 12 | 12 | 100% | | SKETCH | CONSTRAINTS | 11 | 15 | 73% | | SKETCH | CONFIGURE | 0 | 3 | 0% | | SKETCH | INSPECT | 1 | 25 | 4% | @@ -170,6 +170,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SKETCH › MODIFY › Equal Distance Chamfer (`ChamferSketchEqualDistance`) - SKETCH › MODIFY › Distance and Angle Chamfer (`ChamferSketchDistanceAngle`) - SKETCH › MODIFY › Two Distance Chamfer (`ChamferSketchDistanceDistance`) +- SKETCH › MODIFY › Blend Curve (`BlendG1CurveSketchCmd`) - SKETCH › MODIFY › Offset (`Offset`) - SKETCH › MODIFY › Trim (`TrimSketchCmd`) - SKETCH › MODIFY › Extend (`ExtendSketchCmd`) @@ -336,7 +337,6 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SKETCH › CREATE › Project To Surface (`ProjectToSurface`) - SKETCH › CREATE › Intersection Curve (`IntersectionCurve`) - SKETCH › CREATE › Isoparametric Curve (`SketchIsoparametricCurve`) -- SKETCH › MODIFY › Blend Curve (`BlendG1CurveSketchCmd`) - SKETCH › CONSTRAINTS › AutoConstrain (`SketchAutoConstraintAndDimCmd`) - SKETCH › CONSTRAINTS › AutoConstrain from datum (`SketchAutoConstraintAndDimFromDatumCmd`) - SKETCH › CONSTRAINTS › Curvature (`ConstraintSmooth`)