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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
beab69cfb2
commit
cb01314d35
14 files changed
+835
-13
No files matched your search
@@ -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<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user