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

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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

+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() };