Surface Loft: sheets through open or closed sections (#847)

Surface Loft lofts open or closed curve chains, one section per sketch in pick order, into surface bodies. Adds the SurfaceLoft feature (format 18 note), the surface.loft command, a dialog with Edit Feature support, and kernel and engine tests.

Part of #757.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-13 21:07:16 -04:00
1 parent 7c85970bfc
commit 897b7d93b5
15 files changed
+365 -55

No files matched your search

+38 -3
View File
@@ -1,5 +1,5 @@
//! Dialogs for the surface tools and face splitting (#19): Surface Extrude, Revolve and Sweep,
//! Patch, Stitch, Thicken, Extend, Trim and Split Face. Each has a live preview and Edit Feature.
//! Dialogs for the surface tools and face splitting (#19): Surface Extrude, Revolve, Sweep and
//! Loft, Patch, Stitch, Thicken, Extend, Trim and Split Face. Each has a live preview and Edit Feature.
use serde_json::{Map, Value, json};
use solidcraft_engine::Sel;
@@ -26,6 +26,7 @@ pub enum Surf {
axis: Option<Value>,
},
Sweep,
Loft,
Patch,
Unstitch,
ReverseNormal,
@@ -53,6 +54,7 @@ impl Surf {
Surf::Extrude { .. } => "SURFACE EXTRUDE",
Surf::Revolve { .. } => "SURFACE REVOLVE",
Surf::Sweep => "SURFACE SWEEP",
Surf::Loft => "SURFACE LOFT",
Surf::Patch => "PATCH",
Surf::Unstitch => "UNSTITCH",
Surf::ReverseNormal => "REVERSE NORMAL",
@@ -84,6 +86,7 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)],
Surf::Revolve { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Axis", AXES, false)],
Surf::Sweep => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Path", CURVES, true)],
Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)],
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)],
Surf::ReverseNormal => vec![SelInput::new("Surface, or faces to turn over", BODIES | FACES, true)],
@@ -106,6 +109,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
"surface.extrude" => Surf::Extrude { distance: "10 mm".into(), direction: 0 },
"surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None },
"surface.sweep" => Surf::Sweep,
"surface.loft" => Surf::Loft,
"surface.patch" => Surf::Patch,
"surface.unstitch" => Surf::Unstitch,
"surface.reverse_normal" => Surf::ReverseNormal,
@@ -168,7 +172,7 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
combo(ui, "sx_dir", &EXTRUDE_SIDES.map(|(label, _)| label), direction);
ui.end_row();
}
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep => {}
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep | Surf::Loft => {}
}
enter
}
@@ -281,6 +285,34 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
p["path"] = json!(path);
("surface.sweep", p)
}
Surf::Loft => {
need(0, "the sections, one sketch each")?;
// One section per sketch, in the order the sketches were first picked.
let st = app.session.world_state();
let sketch_of = |x: &Sel| match x {
Sel::Profile { sketch, .. } => Some(*sketch),
Sel::SketchCurve { id } => st.sketches.iter().rev().find(|ss| ss.sketch.curve_index(id).is_some()).map(|ss| ss.feature),
_ => None,
};
let mut order: Vec<u64> = Vec::new();
for x in items(inputs, 0) {
let sk = sketch_of(x).ok_or("a picked curve is no longer in a sketch")?;
if !order.contains(&sk) {
order.push(sk);
}
}
if order.len() < 2 {
return Err("pick sections in two or more sketches".into());
}
let sections: Vec<Value> = order
.iter()
.map(|sk| {
let picks: Vec<Sel> = items(inputs, 0).iter().filter(|x| sketch_of(x) == Some(*sk)).cloned().collect();
sheet_picks(app, &picks)
})
.collect::<Result<_, String>>()?;
("surface.loft", json!({ "sections": sections }))
}
Surf::Patch => {
need(0, "a boundary (sketch profiles or a loop of edges)")?;
let profiles: Vec<(u64, usize)> =
@@ -405,6 +437,9 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
Surf::Sweep,
vec![sheet_sels(app, *sketch, profiles.as_ref(), curves), path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()],
),
FeatureKind::SurfaceLoft { sections } => {
(Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch, sec.profiles.as_ref(), &sec.curves)).collect()])
}
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
let sels = sheet_sels(app, *sketch, profiles.as_ref(), curves);
let direction = match extent.direction {
+29
View File
@@ -910,6 +910,35 @@ fn surface_sweep_dialog_takes_curves_and_a_path() {
assert_eq!(back.inputs[1].items, vec![Sel::SketchCurve { id: "up".into() }]);
}
#[test]
fn surface_loft_dialog_makes_a_section_per_sketch() {
// A line in each of two sketches 5 apart: two sections, a 10 × 5 strip; Edit Feature brings
// the curves back.
let mut app = app_with(json!([
{"command": "construct.plane.offset", "params": {"base": "XY", "offset": 5, "name": "Mid"}},
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.line", "params": {"points": [[0, 0], [10, 0]], "ids": ["a"]}},
{"command": "sketch.finish", "params": {}},
{"command": "sketch.create", "params": {"plane": "Mid"}},
{"command": "sketch.line", "params": {"points": [[0, 0], [10, 0]], "ids": ["b"]}},
{"command": "sketch.finish", "params": {}}
]));
let mut d = start(&mut app, vec![], "surface.loft");
d.inputs[0].items = vec![Sel::SketchCurve { id: "a".into() }];
assert!(apply_commands(&app, &d).is_err(), "one section");
d.inputs[0].items.push(Sel::SketchCurve { id: "b".into() });
let (id, p) = apply_commands(&app, &d).unwrap().remove(0);
assert_eq!(id, "surface.loft");
assert_eq!(p["sections"].as_array().map(Vec::len), Some(2), "{p}");
assert_eq!((&p["sections"][0]["curves"], &p["sections"][1]["curves"]), (&json!(["a"]), &json!(["b"])), "{p}");
app.session.execute(&id, &p).unwrap();
let area = app.session.model.state().bodies.last().unwrap().mesh().measure().area;
assert!((area - 50.0).abs() < 1e-6, "{area}");
let fid = app.session.doc.features.last().unwrap().id;
let back = crate::dialogs::for_feature(&app, fid, None).unwrap();
assert_eq!(back.inputs[0].items, vec![Sel::SketchCurve { id: "a".into() }, Sel::SketchCurve { id: "b".into() }]);
}
#[test]
fn patch_dialog_fills_a_loop_of_edges_that_is_not_planar() {
// A box with its top cut away by a sideways cylinder: its rim is a closed loop of four
+1
View File
@@ -16,6 +16,7 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
FeatureKind::ReverseNormal { .. } => "reverse_normal",
FeatureKind::SurfaceRevolve { .. } => "surface_revolve",
FeatureKind::SurfaceSweep { .. } => "surface_sweep",
FeatureKind::SurfaceLoft { .. } => "surface_loft",
FeatureKind::Revolve { .. } => "revolve",
FeatureKind::Fillet { .. } => "fillet",
FeatureKind::Chamfer { .. } => "chamfer",