From 4d4b3a715f72490d4a0ee084289ff004040a445b Mon Sep 17 00:00:00 2001 From: selune <35337584+0xlillith@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:44:19 +0200 Subject: [PATCH] Create construction planes along drawn 3D sketch paths (#240) Co-authored-by: bherbruck Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 6 +++ crates/doc/src/construct.rs | 8 ++++ crates/engine/src/construct_tests.rs | 41 +++++++++++++++++ crates/ui-egui/src/dialogs.rs | 64 +++++++++++++++++++++++++++ crates/ui-egui/src/preselect_tests.rs | 52 ++++++++++++++++++++++ crates/ui-egui/src/preview.rs | 24 +++++++++- crates/ui-egui/src/viewport.rs | 9 +++- docs/book/src/guide/features.md | 9 +++- 8 files changed, 210 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 553f95e6..0480d352 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/crates/doc/src/construct.rs b/crates/doc/src/construct.rs index d6a7ee8e..9f040d05 100644 --- a/crates/doc/src/construct.rs +++ b/crates/doc/src/construct.rs @@ -202,6 +202,14 @@ fn sketch_world(ctx: &Ctx, sketch: u64, q: Vec2) -> Result { fn sketch_curve_points(ctx: &Ctx, sketch: u64, curve: &str) -> Result> { 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 { diff --git a/crates/engine/src/construct_tests.rs b/crates/engine/src/construct_tests.rs index 5e15bc28..511ae520 100644 --- a/crates/engine/src/construct_tests.rs +++ b/crates/engine/src/construct_tests.rs @@ -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() { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8f336a01..f67eb70d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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, + }, /// 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, 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, 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 = 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) -> Option { + 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() }; diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index 0c67046d..b0343591 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -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] { diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index 0d9fbef6..d1eb49f8 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -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()); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 4e050db4..373a4f79 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -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 { diff --git a/docs/book/src/guide/features.md b/docs/book/src/guide/features.md index 35dd3c9e..5eca2e89 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -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}`.