Create construction planes along drawn 3D sketch paths (#240)

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-12 11:44:19 -04:00
1 parent 6bf86f1f1e
commit 4d4b3a715f
8 files changed
+210 -3

No files matched your search

+6
View File
@@ -321,6 +321,12 @@ the active plane. Bridge endpoints capture their source positions; associative e
construction-plane selection of 3D wires and loft guide rails remain separate work.
Feature dialog header chevrons collapse and restore the form without discarding selections
or edited values, including the Appearance panel.
## Construction on 3D paths
Plane Along Path has a viewport path selector, fractional position, live preview and timeline
editing. Construction planes and points resolve drawn 3D wires without applying their sketch
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
and reference-object placement remain separate work, as do loft rails and centerlines.
## Document-opening performance
+8
View File
@@ -202,6 +202,14 @@ fn sketch_world(ctx: &Ctx, sketch: u64, q: Vec2) -> Result<Vec3> {
fn sketch_curve_points(ctx: &Ctx, sketch: u64, curve: &str) -> Result<Vec<Vec3>> {
let ss = ctx.st.sketch(sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
if let Some(wire) = ss.sketch.wires.iter().find(|wire| wire.id == curve) {
if wire.pts.len() < 2 {
return Err(bad(format!("curve `{curve}` has no length")));
}
// Evaluation has already mapped wires into this component's frame. Their coordinates
// are independent of the sketch plane; applying it again would move the path twice.
return Ok(wire.pts.clone());
}
let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("curve `{curve}`")))?;
let pl = ss.sketch.polyline(ci);
if pl.len() < 2 {
+41
View File
@@ -216,6 +216,47 @@ fn plane_along_a_path() {
run(&mut s, "sketch.create", json!({"plane": "P", "name": "OnPath"}));
}
#[test]
fn construction_on_a_3d_wire_ignores_its_sketch_plane_and_follows_edits() {
let mut s = Session::default();
let sketch = run(&mut s, "sketch.create", json!({"plane":{"origin":[100,200,300],"x_dir":[0,1,0],"y_dir":[0,0,1]}}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
let wire = run(&mut s, "sketch.line3d", json!({"points":[[10,20,10],[30,35,40]]}))["curves"][0].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
let path = json!({"sketch":sketch,"curve":wire});
run(&mut s, "construct.plane.along_path", json!({"path":path,"t":0.5,"name":"BridgePlane"}));
run(&mut s, "construct.point.along_path", json!({"path":path,"t":0.25,"name":"Quarter"}));
let p = plane(&s, "BridgePlane");
assert!(near(p.origin, Vec3::new(20.0, 27.5, 25.0)), "{p:?}");
assert!(parallel(p.normal(), Vec3::new(20.0, 15.0, 30.0)));
assert!(near(point(&s, "Quarter"), Vec3::new(15.0, 23.75, 17.5)));
run(&mut s, "sketch.edit", json!({"sketch":sketch}));
run(&mut s, "sketch.move3d", json!({"wire":wire,"index":1,"to":[50,35,40]}));
run(&mut s, "sketch.finish", json!({}));
let updated = plane(&s, "BridgePlane");
assert!(near(updated.origin, Vec3::new(30.0, 27.5, 25.0)), "{updated:?}");
assert!(parallel(updated.normal(), Vec3::new(40.0, 15.0, 30.0)));
let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
let mut cold = Session { doc: restored.into(), ..Default::default() };
cold.refresh();
assert!(near(plane(&cold, "BridgePlane").origin, updated.origin));
}
#[test]
fn plane_on_a_3d_wire_moves_with_its_component() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name":"Part"}));
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
let wire = run(&mut s, "sketch.line3d", json!({"points":[[0,0,10],[0,0,40]]}))["curves"][0].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "construct.plane.along_path", json!({"path":{"sketch":sketch,"curve":wire},"t":0.5,"name":"Section"}));
run(&mut s, "occurrence.move", json!({"occurrence":"Part:1","translate":[50,20,0],"capture":true}));
let p = plane(&s, "Section");
assert!(near(p.origin, Vec3::new(50.0, 20.0, 25.0)), "{p:?}");
assert!(parallel(p.normal(), Vec3::Z));
}
/// Built from model geometry, they follow when the model changes: the box gets taller.
#[test]
fn construction_follows_the_model() {
+64
View File
@@ -101,6 +101,11 @@ pub enum Kind {
AnglePlane {
angle: String,
},
/// A plane perpendicular to one selected path at a fractional distance.
PathPlane {
t: String,
sketch: Option<u64>,
},
/// Split a body by a plane.
Split,
/// Scale bodies uniformly.
@@ -387,6 +392,9 @@ impl Dialog {
"construct.plane.offset" => {
Dialog::new(Kind::OffsetPlane { offset: "10 mm".into() }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)])
}
"construct.plane.along_path" => {
Dialog::new(Kind::PathPlane { t: "0.5".into(), sketch: None }, vec![SelInput::new("Path", CURVES | EDGES, false)])
}
"construct.plane.at_angle" => Dialog::new(
Kind::AnglePlane { angle: "45 deg".into() },
vec![SelInput::new("Plane", PLANES, false), SelInput::new("Axis", AXES, false)],
@@ -588,6 +596,7 @@ impl Dialog {
| Kind::PatternCirc { .. }
| Kind::Loft { .. }
| Kind::Sweep { .. }
| Kind::PathPlane { .. }
| Kind::Move { .. }
| Kind::Hole { .. }
| Kind::Primitive { .. }
@@ -611,6 +620,7 @@ impl Dialog {
Kind::PatternCirc { angle, .. } => ("Angle", ValueKind::Angle, angle),
Kind::Section { offset, .. } => ("Distance", ValueKind::Length, offset),
Kind::OffsetPlane { offset } => ("Distance", ValueKind::Length, offset),
Kind::PathPlane { t, .. } => ("Position (0 to 1)", ValueKind::Unitless, t),
Kind::Rib { thickness, .. } => ("Thickness", ValueKind::Length, thickness),
Kind::Emboss { depth, .. } => ("Depth", ValueKind::Length, depth),
Kind::PathPattern { spacing, .. } => ("Spacing", ValueKind::Length, spacing),
@@ -666,6 +676,10 @@ impl Dialog {
/// A pick in the viewport (already accepted by the active input).
pub fn pick(&mut self, s: &Session, sel: Sel) {
if let Kind::PathPlane { sketch, .. } = &mut self.kind {
*sketch = None;
self.extra.remove("path");
}
if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) {
self.profile_selection_changed = true;
}
@@ -709,6 +723,10 @@ impl Dialog {
/// Box selection result: replace (or add to) the active input.
pub fn take_box(&mut self, s: &Session, sels: Vec<Sel>, add: bool) {
if let Kind::PathPlane { sketch, .. } = &mut self.kind {
*sketch = None;
self.extra.remove("path");
}
if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) {
self.profile_selection_changed = true;
}
@@ -821,6 +839,7 @@ fn title(k: &Kind) -> &'static str {
Kind::Measure { .. } => "MEASURE",
Kind::Material { .. } => "PHYSICAL MATERIAL",
Kind::OffsetPlane { .. } => "OFFSET PLANE",
Kind::PathPlane { .. } => "PLANE ALONG PATH",
Kind::Rib { web: false, .. } => "RIB",
Kind::Rib { web: true, .. } => "WEB",
Kind::Emboss { .. } => "EMBOSS",
@@ -1659,6 +1678,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
enter |= field(ui, angle);
ui.end_row();
}
Kind::PathPlane { t, .. } => {
row_label(ui, "Position (0 to 1)");
enter |= field(ui, t);
ui.end_row();
}
Kind::Split | Kind::ReplaceFace | Kind::Remove => {}
Kind::Rib { thickness, depth, flip, .. } => {
row_label(ui, "Thickness");
@@ -2618,6 +2642,29 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
};
("construct.plane.offset", json!({"base": base, "offset": offset}))
}
Kind::PathPlane { t, sketch } => {
need(0, "a path")?;
let path = match sels(d, 0).first() {
Some(Sel::SketchCurve { id }) => {
let st = app.session.world_state();
let matches: Vec<u64> = st
.sketches
.iter()
.filter(|ss| ss.sketch.curve_index(id).is_some() || ss.sketch.wire_index(id).is_some())
.map(|ss| ss.feature)
.collect();
let source = match sketch {
Some(source) if matches.contains(source) => *source,
None if matches.len() == 1 => *matches.first().ok_or("pick a path")?,
_ => return Err("pick the path in the viewport to identify its source sketch".into()),
};
json!({"sketch": source, "curve": id})
}
Some(Sel::Edge { point, .. }) => d.extra.get("path").cloned().unwrap_or_else(|| json!({"edge": pt(*point)})),
_ => return Err("pick a sketch curve or model edge".into()),
};
("construct.plane.along_path", json!({"path": path, "t": t}))
}
Kind::AnglePlane { angle } => {
need(0, "a plane")?;
let base = match sels(d, 0).first() {
@@ -3538,6 +3585,23 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::ConstructionPlane { plane: PlaneRef::AlongPath { path, t } } => {
let mut d = start("construct.plane.along_path")?;
let (item, sketch) = match path.as_ref() {
solvecraft_engine::doc::construct::GeoRef::SketchCurve { sketch, curve } => (Sel::SketchCurve { id: curve.clone() }, Some(*sketch)),
solvecraft_engine::doc::construct::GeoRef::Edge { at, name } => {
// Preserve the saved named reference until a replacement is picked.
d.extra.insert("path".into(), json!({"type":"edge","at":pt3(*at),"name":name}));
(Sel::Edge { body: String::new(), index: 0, point: *at }, None)
}
_ => return None,
};
d.kind = Kind::PathPlane { t: t.clone(), sketch };
if let Some(input) = d.inputs.first_mut() {
input.items = vec![item];
}
d
}
FeatureKind::ConstructionPlane { plane: PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } } => {
let mut d = start("construct.plane.at_angle")?;
d.kind = Kind::AnglePlane { angle: angle.clone() };
+52
View File
@@ -8,6 +8,58 @@ use solvecraft_engine::{Sel, Session};
use crate::dialogs::{Dialog, Kind, apply_commands};
use crate::{Services, SolveApp};
#[test]
fn path_plane_click_retains_source_and_previews_then_edits_in_place() {
let mut h = crate::scenario::Harness::new();
let source = h.app.run("sketch.create", json!({"plane":"XY","name":"Bridge"})).unwrap()["sketch"].as_u64().unwrap();
h.app.run("sketch.options", json!({"sketch_3d":true})).unwrap();
let wire = h.app.run("sketch.line3d", json!({"points":[[10,20,10],[30,35,40]]})).unwrap()["curves"][0].as_str().unwrap().to_string();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("sketch.create", json!({"plane":"XY","name":"Other"})).unwrap();
h.app.run("sketch.options", json!({"sketch_3d":true})).unwrap();
let duplicate = h.app.run("sketch.line3d", json!({"points":[[-30,0,0],[-20,0,0]]})).unwrap()["curves"][0].as_str().unwrap().to_string();
assert_eq!(wire, duplicate, "exercise duplicate curve ids in different sketches");
h.app.run("sketch.finish", json!({})).unwrap();
h.app.ui.show_origin = false;
h.settle();
h.call("ui.view", json!({"view":"home"}));
h.call("ui.view", json!({"view":"fit"}));
h.app.start("construct.plane.along_path");
h.frames(2);
h.step(&json!({"click":{"world":[20,27.5,25]}})).unwrap();
let d = h.app.dialog.as_ref().unwrap();
let commands = apply_commands(&h.app, d).unwrap();
assert_eq!(commands[0].1["path"]["sketch"], json!(source));
let history = h.app.session.undo.len();
let count = h.app.session.doc.features.len();
let preview = h.app.session.preview_visible_world(&commands).unwrap();
let built = crate::preview::compute(&h.app.session, &commands, crate::preview::Colors::from_tokens(&crate::theme::Tokens::dark())).unwrap();
assert!(!built.scene.xray.is_empty(), "construction preview must contain visible geometry");
let plane = preview.after.construct.last().unwrap();
let solvecraft_engine::doc::construct::ConstructGeom::Plane(pl) = plane.geom else { panic!("plane") };
assert!(pl.origin.dist(Vec3::new(20.0, 27.5, 25.0)) < 1e-6);
assert_eq!(h.app.session.doc.features.len(), count);
assert_eq!(h.app.session.undo.len(), history);
h.step(&json!({"key":"Enter"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
let feature = h.app.session.doc.features.last().unwrap().clone();
assert_eq!(h.app.session.undo.len(), history + 1);
h.app.edit_feature(feature.id);
h.frames(2);
let d = h.app.dialog.as_mut().unwrap();
assert!(matches!(d.kind,Kind::PathPlane{sketch:Some(s),..} if s==source));
if let Kind::PathPlane { t, .. } = &mut d.kind {
*t = "0.25".into();
}
h.step(&json!({"key":"Enter"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
assert_eq!(h.app.session.doc.features.len(), count + 1);
let p = solvecraft_engine::view::construction_planes(&h.app.session).into_iter().find(|p| p.0 == feature.id).unwrap().2;
assert!(p.origin.dist(Vec3::new(15.0, 23.75, 17.5)) < 1e-6, "{p:?}");
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.feature(feature.id).unwrap().kind, feature.kind);
}
#[test]
fn cylinder_emboss_empty_target_face_applies_automatic_wrap_and_deboss() {
for deboss in [false, true] {
+23 -1
View File
@@ -186,7 +186,11 @@ fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, V
/// show their result in place of the bodies they change. When the feature fails (a boolean the
/// kernel can't do yet), the tool body is still shown, with the error.
pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job {
if cmds.iter().any(|(command, _)| command == "sketch.redefine") {
if cmds.iter().any(|(command, params)| {
command == "sketch.redefine"
|| command == "construct.plane.along_path"
|| params.get("command").and_then(Value::as_str) == Some("construct.plane.along_path")
}) {
let preview = s.preview_visible_world(cmds).map_err(|error| error.to_string())?;
return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut built = build(&preview.before, &preview.after, colors);
@@ -281,6 +285,24 @@ fn same(a: &Mesh, b: &Mesh) -> bool {
pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built {
let mut sc = GpuScene::default();
let mut replaced = Vec::new();
for construct in &after.construct {
if before.construct.iter().any(|old| old == construct) {
continue;
}
if let solvecraft_engine::doc::construct::ConstructGeom::Plane(plane) = construct.geom {
let (o, x, y) = (plane.origin, plane.x * 10.0, plane.y * 10.0);
let quad = [o - x - y, o + x - y, o + x + y, o - x + y];
let color = [c.added[0], c.added[1], c.added[2], 64];
for triangle in [[quad[0], quad[1], quad[2]], [quad[0], quad[2], quad[3]]] {
for p in triangle {
sc.xray_tri(p.to_f32(), plane.normal().to_f32(), color);
}
}
for (a, b) in quad.iter().zip(quad.iter().cycle().skip(1)).take(4) {
sc.line(a.to_f32(), b.to_f32(), c.added, 1.3, true);
}
}
}
for a in &after.bodies {
let am = a.mesh();
let old = before.body(&a.name).map(|b| b.mesh());
+8 -1
View File
@@ -1809,10 +1809,17 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.tool.is_some() {
crate::tools::on_click(app, &proj, p);
} else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) {
if let Some(sel) = cand.and_then(|c| c.1)
if let Some((hit, Some(sel))) = cand
&& let Some(mut d) = app.dialog.take()
{
d.pick(&app.session, sel);
if let crate::dialogs::Kind::PathPlane { sketch, .. } = &mut d.kind {
*sketch = match hit {
Hit::SketchCurve { sketch, .. } => Some(sketch),
_ => None,
};
d.extra.remove("path");
}
app.dialog = Some(d);
}
} else {
+8 -1
View File
@@ -9,7 +9,7 @@ it.
|---|---|
| Create | extrude (one side, two sides, symmetric, taper, start offset, to a distance or through all; new body, join, cut, intersect), revolve, sweep, loft, box, cylinder, sphere, torus, coil, pipe, rib, web, emboss, hole (simple, counterbore, countersink, tapped; on faces or at sketch points), thread, rectangular/circular/path patterns, mirror |
| Modify | press pull, fillet, chamfer, shell, draft, scale, combine, split body, offset face, replace face, move/copy, align, remove |
| Construct | offset plane, plane at angle |
| Construct | offset plane, plane at angle, plane along path |
| Inspect | measure, section analysis, interference, curvature comb and map, zebra, draft, minimum radius, isocurves, centre of mass |
Extrude automatically selects **Join** for additions when the active component already has a
@@ -60,6 +60,13 @@ Feature references to faces and edges are stored by name, so an upstream edit th
keeps them on the right face (see [Persistent naming](../dev/naming.md)). When a reference can't
be found, the feature warns instead of silently picking another face.
**Plane Along Path** accepts one sketch curve (including a drawn 3D wire) or model edge.
Pick the path in the viewport and enter **Position (0 to 1)**: 0 starts the path, 1 ends it,
and 0.5 places the plane halfway along its length. The plane is perpendicular to the path's
local segment. Preview does not change the design; OK creates one editable timeline feature.
The plane follows later path edits. Curved paths use their sampled polyline; absolute distance,
reference-object placement and multi-curve chains are not yet available in this dialog.
## Editing a feature from a script
For `solid.pattern.circular`, `axis` accepts `X`, `Y`, `Z`, or `{origin, dir}`.