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:
8 files changed
+210
-3
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`.
|
||||
|
||||
Reference in new issue
Block a user