3D sketch curves (wires): Include 3D Geometry keeps out-of-plane edges and curves 3D; Project To Surface (curves onto a face along the sketch normal), Intersection Curve (bodies/faces), Isoparametric Curve (u/v or a direction), Spun Profile (a body's outline about an axis) and Fit Curves to Mesh Section; all linked and re-resolved; wires drawn, inspected and deletable

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 23:59:59 +00:00
1 parent beab69cfb2
commit cb01314d35
14 files changed
+835 -13

No files matched your search

+1
View File
@@ -142,6 +142,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
"constraints": constraints,
"profiles": profiles,
"links": sk.links,
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link})).collect::<Vec<_>>(),
}))
}
+2
View File
@@ -1245,6 +1245,8 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
points.push(q);
} else if sk.constraints.iter().any(|c| &c.id == id) {
cons.push(id.clone());
} else if sk.wire_index(id).is_some() {
sk.remove_wires(std::slice::from_ref(id));
} else if sk.link(id).is_some() {
curves.extend(sk.link_curves(id));
points.extend(sk.link_points(id));
+126 -1
View File
@@ -23,6 +23,31 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("include")
.enabled(in_sketch)
.params("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it"),
CommandSpec::new("ProjectToSurface", "Project To Surface", project_to_surface)
.at("SKETCH", "CREATE")
.icon("project_surface")
.enabled(in_sketch)
.params("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves"),
CommandSpec::new("IntersectionCurve", "Intersection Curve", intersection_curve)
.at("SKETCH", "CREATE")
.icon("intersection_curve")
.enabled(in_sketch)
.params("a, b: {body} | {face: [x,y,z], body?}: where they meet, as linked 3D curves"),
CommandSpec::new("SpunProfileCmd", "Spun Profile", spun_profile)
.at("SKETCH", "CREATE")
.icon("spun")
.enabled(in_sketch)
.params("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane"),
CommandSpec::new("SketchIsoparametricCurve", "Isoparametric Curve", iso_curve)
.at("SKETCH", "CREATE")
.icon("iso_curve")
.enabled(in_sketch)
.params("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve"),
CommandSpec::new("FitCurvesToSectionCommand", "Fit Curves to Mesh Section", fit_section)
.at("SKETCH", "CREATE")
.icon("fit_section")
.enabled(in_sketch)
.params("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry"),
CommandSpec::new("sketch.break_link", "Break Link", break_link)
.enabled(in_sketch)
.params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"),
@@ -116,7 +141,7 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki
let (vals, _) = doc.param_values();
let st = s.model.state_before(id);
let r = resolve(doc, &vals, &st, &plane, kind, &src)?;
Ok(sk.add_link(kind, r.source, &r.geom)?)
Ok(sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?)
}
fn link_json(sk: &Sketch, link: &str) -> Value {
@@ -162,6 +187,106 @@ fn include_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "Include3DGeometry", LinkKind::Include)
}
/// Add one link from `src` to the active sketch and report it.
fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result<Value> {
let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
refresh(s, &doc, id, &mut sk);
let what = src.describe();
let l = add_link(s, &doc, id, &mut sk, kind, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
let mut out = link_json(&sk, &l);
let wires: Vec<String> = sk.link_wires(&l).iter().filter_map(|w| sk.wires.get(*w).map(|w| w.id.clone())).collect();
out["wires"] = json!(wires);
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
Ok(out)
}
fn project_to_surface(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ProjectToSurface";
let at = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on the target face"))?;
let body = str_(p, "body").unwrap_or("").to_string();
let list = p.get("curves").and_then(Value::as_array).cloned().ok_or_else(|| bad(cmd, "`curves` must list {sketch, curve}"))?;
if list.is_empty() || list.len() > 500 {
return Err(bad(cmd, "`curves` must list 1…500 curves"));
}
let mut out = Vec::new();
for v in list {
let LinkSource::SketchCurve { sketch, curve } = source(s, &v, cmd)? else {
return Err(bad(cmd, "each entry must be {sketch, curve}"));
};
out.push(one_link(s, cmd, LinkKind::Project, LinkSource::OnSurface { sketch, curve, body: body.clone(), at })?);
}
Ok(json!({"links": out}))
}
fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "IntersectionCurve";
let side = |k: &str| -> Result<LinkSource> {
let v = p.get(k).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?;
match source(s, v, cmd)? {
x @ (LinkSource::Body { .. } | LinkSource::Face { .. }) => Ok(x),
_ => Err(bad(cmd, format!("`{k}` must be a body or a face"))),
}
};
let (a, b) = (side("a")?, side("b")?);
one_link(s, cmd, LinkKind::Include, LinkSource::Intersection { a: Box::new(a), b: Box::new(b) })
}
fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SpunProfileCmd";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must name a body"))?.to_string();
let axis = str_(p, "axis").ok_or_else(|| bad(cmd, "`axis` must be a sketch line or X|Y|Z"))?;
let (origin, dir) = match axis.to_ascii_uppercase().as_str() {
"X" => (Vec3::ZERO, Vec3::X),
"Y" => (Vec3::ZERO, Vec3::Y),
"Z" => (Vec3::ZERO, Vec3::Z),
_ => {
let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch is not evaluated".into()))?;
let ci = ss.sketch.curve_index(axis).ok_or_else(|| bad(cmd, format!("unknown curve `{axis}`")))?;
let Some(solvecraft_sketch::Shape::Line { a, b }) = ss.sketch.shape(ci) else { return Err(bad(cmd, "the axis must be a line")) };
let (wa, wb) = (ss.plane.to_world(a), ss.plane.to_world(b));
(wa, wb - wa)
}
};
one_link(s, cmd, LinkKind::Project, LinkSource::Spun { body, origin, dir })
}
fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchIsoparametricCurve";
let at = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on the face"))?;
let body = str_(p, "body").unwrap_or("").to_string();
let dir = match p.get("direction") {
None => "u".to_string(),
Some(Value::String(d)) if d.eq_ignore_ascii_case("u") || d.eq_ignore_ascii_case("v") => d.to_ascii_lowercase(),
Some(v) => {
let d = vec3(v).ok_or_else(|| bad(cmd, "`direction` must be u, v or [x,y,z]"))?;
format!("{},{},{}", d.x, d.y, d.z)
}
};
one_link(s, cmd, LinkKind::Include, LinkSource::Iso { body, at, dir })
}
fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FitCurvesToSectionCommand";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must name a body"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
let l = add_link(s, &doc, id, &mut sk, LinkKind::Intersect, LinkSource::Body { body }).map_err(|e| bad(cmd, e.to_string()))?;
let curves: Vec<String> = sk.link_curves(&l).iter().filter_map(|c| sk.curves.get(*c).map(|c| c.id.clone())).collect();
sk.break_link(&l)?;
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
Ok(json!({"curves": curves}))
}
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.break_link";
let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
@@ -195,3 +195,106 @@ fn drawing_on_a_model_vertex_projects_it() {
let si = inspect(&mut s);
assert!(si["constraints"].as_array().unwrap().iter().any(|c| c["type"] == "point_on_curve"), "{si}");
}
#[test]
fn fit_curves_to_a_mesh_section() {
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 10}));
let name = s.model.state().bodies[0].name.clone();
let r = run(&mut s, "FitCurvesToSectionCommand", json!({"body": name}));
assert_eq!(r["curves"].as_array().unwrap().len(), 1, "{r}");
let si = inspect(&mut s);
assert!(si["links"].as_array().unwrap().is_empty());
let c = &si["curves"][0];
assert_eq!(c["type"], "circle");
assert!(c.get("link").is_none());
assert!(s.execute("FitCurvesToSectionCommand", &json!({"body": "nope"})).is_err());
}
#[test]
fn three_d_sketch_curves() {
let mut s = Session::default();
// Include keeps an out-of-plane edge 3D.
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "Include3DGeometry", json!({"refs": [{"edge": [10, 10, 5]}]}));
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
assert_eq!(sk.wires.len(), 1, "{r}");
assert!((sk.wires[0].pts.iter().map(|p| p.z).fold(0.0, f64::max) - 10.0).abs() < 1e-9);
run(&mut s, "SketchStop", json!({}));
// Project a line onto a sphere along the sketch normal.
let mut s = Session::default();
run(&mut s, "PrimitiveSphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-10, 0], [10, 0]]}))["curves"])[0].clone();
run(&mut s, "SketchStop", json!({}));
let src = s.doc.features.last().unwrap().id;
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectToSurface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
let wid = r["links"][0]["wires"][0].as_str().unwrap().to_string();
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
let w = &sk.wires[sk.wire_index(&wid).unwrap()];
assert!(w.pts.len() > 20);
for p in &w.pts {
assert!((p.len() - 20.0).abs() < 0.1, "{p:?}");
assert!(p.z > 0.0);
}
// Delete the wire.
run(&mut s, "sketch.delete", json!({"entities": [wid]}));
assert!(s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.wires.is_empty());
run(&mut s, "SketchStop", json!({}));
// Two crossing cylinders meet in 3D curves.
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, -20], "radius": 5, "height": 40}));
run(&mut s, "PrimitiveCylinder", json!({"base": [-20, 0, 0], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names.len(), 2, "{names:?}");
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "IntersectionCurve", json!({"a": {"body": names[0]}, "b": {"body": names[1]}}));
assert!(!r["wires"].as_array().unwrap().is_empty(), "{r}");
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
for w in &sk.wires {
for p in &w.pts {
assert!(((p.x * p.x + p.y * p.y).sqrt() - 5.0).abs() < 0.1, "{p:?}");
assert!(((p.y * p.y + p.z * p.z).sqrt() - 4.0).abs() < 0.1, "{p:?}");
}
}
run(&mut s, "SketchStop", json!({}));
// Spun profile of a cylinder about Z, on the XZ plane: a straight outline 6 from the axis.
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
let name = s.model.state().bodies[0].name.clone();
run(&mut s, "SketchCreate", json!({"plane": "XZ"}));
let r = run(&mut s, "SpunProfileCmd", json!({"body": name, "axis": "Z"}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "line", "{c}");
assert!((c["start_at"][0].as_f64().unwrap().abs() - 6.0).abs() < 1e-3, "{c}");
}
#[test]
fn isoparametric_curves_of_a_cylinder() {
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
// u: around (a circle at z = 10); v: along (a line from z 0 to 20).
let r = run(&mut s, "SketchIsoparametricCurve", json!({"face": [6, 0, 10]}));
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
let w = &sk.wires[sk.wire_index(r["wires"][0].as_str().unwrap()).unwrap()];
assert!(w.pts.iter().all(|p| (p.z - 10.0).abs() < 1e-6 && ((p.x * p.x + p.y * p.y).sqrt() - 6.0).abs() < 1e-3), "{:?}", &w.pts[..3]);
let r = run(&mut s, "SketchIsoparametricCurve", json!({"face": [6, 0, 10], "direction": "v"}));
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
let w = &sk.wires[sk.wire_index(r["wires"][0].as_str().unwrap()).unwrap()];
let zs: Vec<f64> = w.pts.iter().map(|p| p.z).collect();
assert!(zs.iter().cloned().fold(f64::MIN, f64::max) > 19.9 && zs.iter().cloned().fold(f64::MAX, f64::min) < 0.1, "{zs:?}");
assert!(w.pts.iter().all(|p| (p.x - 6.0).abs() < 1e-3 && p.y.abs() < 1e-3));
}