Surface Extrude: sheets from open curves and profile walls (#815)

surface.extrude sweeps open curve chains and profile boundaries along the sketch normal into a surface body (walls only, no caps). Saved under format 17.

Refs #757

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Irakli KourtisandClaude Opus 5.5 authored and GitHub committed 2026-10-13 14:54:38 -04:00
1 parent 8dd583930e
commit e540832626
17 files changed
+493 -79

No files matched your search

+1
View File
@@ -321,6 +321,7 @@ impl FeatureKind {
}
FeatureKind::Revolve { .. }
| FeatureKind::Extrude { .. }
| FeatureKind::SurfaceExtrude { .. }
| FeatureKind::Combine { .. }
| FeatureKind::Loft { .. }
| FeatureKind::Sweep { .. }
+1
View File
@@ -47,6 +47,7 @@ impl FeatureKind {
}
}
FeatureKind::Extrude { sketch, .. }
| FeatureKind::SurfaceExtrude { sketch, .. }
| FeatureKind::Revolve { sketch, .. }
| FeatureKind::Emboss { sketch, .. }
| FeatureKind::SheetBase { sketch, .. }
+22 -3
View File
@@ -925,6 +925,18 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<String>,
},
/// A surface body swept straight along the sketch normal from open curve chains and the
/// boundaries of profiles: walls only, no caps.
SurfaceExtrude {
sketch: u64,
/// Profiles whose boundary loops are swept (none: only `curves`).
#[serde(default, skip_serializing_if = "Option::is_none")]
profiles: Option<ProfileSel>,
/// Sketch curve ids, grouped into chains of curves that meet end to end.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
curves: Vec<String>,
extent: Extent,
},
/// A planar surface body extended outward by `distance` past the edges through `edges`.
SurfaceExtend {
body: String,
@@ -1225,6 +1237,7 @@ impl FeatureKind {
use FeatureKind as K;
match self {
K::Extrude { sketch, .. }
| K::SurfaceExtrude { sketch, .. }
| K::Revolve { sketch, .. }
| K::Emboss { sketch, .. }
| K::Rib { sketch, .. }
@@ -1356,6 +1369,7 @@ impl FeatureKind {
FeatureKind::Align { .. } => "AlignFeature",
FeatureKind::Remove { .. } => "RemoveFeature",
FeatureKind::Patch { .. } => "PatchFeature",
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrudeFeature",
FeatureKind::Stitch { .. } => "StitchFeature",
FeatureKind::Thicken { .. } => "ThickenFeature",
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
@@ -1422,6 +1436,7 @@ impl FeatureKind {
FeatureKind::Align { .. } => "Align",
FeatureKind::Remove { .. } => "Remove",
FeatureKind::Patch { .. } => "Patch",
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrude",
FeatureKind::Stitch { .. } => "Stitch",
FeatureKind::Thicken { .. } => "Thicken",
FeatureKind::SurfaceTrim { .. } => "Trim",
@@ -1442,7 +1457,9 @@ impl FeatureKind {
| FeatureKind::Sweep { sketch, profiles, .. }
| FeatureKind::Emboss { sketch, profiles, .. }
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
FeatureKind::Patch { sketch: Some(sk), profiles, .. } => vec![(*sk, profiles)],
FeatureKind::Patch { sketch: Some(sk), profiles, .. } | FeatureKind::SurfaceExtrude { sketch: sk, profiles: Some(profiles), .. } => {
vec![(*sk, profiles)]
}
FeatureKind::Loft { sections, .. } => sections.iter_mut().filter(|s| s.point.is_none()).map(|s| (s.sketch, &mut s.profiles)).collect(),
_ => Vec::new(),
}
@@ -1456,7 +1473,9 @@ impl FeatureKind {
| FeatureKind::Sweep { sketch, profiles, .. }
| FeatureKind::Emboss { sketch, profiles, .. }
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
FeatureKind::Patch { sketch: Some(sketch), profiles, .. } => vec![(*sketch, profiles)],
FeatureKind::Patch { sketch: Some(sketch), profiles, .. } | FeatureKind::SurfaceExtrude { sketch, profiles: Some(profiles), .. } => {
vec![(*sketch, profiles)]
}
FeatureKind::Loft { sections, .. } => {
sections.iter().filter(|section| section.point.is_none()).map(|section| (section.sketch, &section.profiles)).collect()
}
@@ -1504,7 +1523,7 @@ impl FeatureKind {
| FeatureKind::ConstructionPlane { plane }
| FeatureKind::Split { plane, .. }
| FeatureKind::SplitFace { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::Extrude { extent, .. } => {
FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } => {
v.push(&extent.distance);
if let Some(d) = &extent.distance2 {
v.push(d);
+14 -7
View File
@@ -2339,12 +2339,7 @@ fn feature_tools_with_constructor(
};
// To Object gives a signed distance from the start: one side only.
let direction = if extent.to.is_some() { crate::Direction::Positive } else { extent.direction };
let (a, b) = match (&extent.distance2, direction) {
(Some(d2), _) => (-val(vals, d2, Kind::Length)?, d),
(None, crate::Direction::Positive) => (0.0, d),
(None, crate::Direction::Negative) => (-d, 0.0),
(None, crate::Direction::Symmetric) => (-d, d),
};
let (a, b) = extent_sides(vals, extent, d, direction)?;
// A negative distance flips the side.
let (mut lo, mut hi) = if a <= b { (a, b) } else { (b, a) };
// To a curved (or slanted) face: run right through the target body, then keep what
@@ -2892,6 +2887,17 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<k
.collect())
}
/// Where an extrude of distance `d` starts and ends along the sketch normal, before any start
/// offset: one side, the other, both (symmetric), or a second distance on the other side.
pub(crate) fn extent_sides(vals: &BTreeMap<String, Value>, extent: &crate::Extent, d: f64, direction: crate::Direction) -> Result<(f64, f64)> {
Ok(match (&extent.distance2, direction) {
(Some(d2), _) => (-val(vals, d2, Kind::Length)?, d),
(None, crate::Direction::Positive) => (0.0, d),
(None, crate::Direction::Negative) => (-d, 0.0),
(None, crate::Direction::Symmetric) => (-d, d),
})
}
/// Operation and targets of a feature that makes tools.
fn feature_op(f: &Feature) -> (Operation, Vec<String>) {
match &f.kind {
@@ -3509,7 +3515,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::Stitch { .. }
| FeatureKind::Thicken { .. }
| FeatureKind::SurfaceTrim { .. }
| FeatureKind::SurfaceExtend { .. } => surface::eval(doc, vals, f, st),
| FeatureKind::SurfaceExtend { .. }
| FeatureKind::SurfaceExtrude { .. } => surface::eval(doc, vals, f, st),
FeatureKind::Emboss { .. }
| FeatureKind::Rib { .. }
| FeatureKind::ReplaceFace { .. }
+80 -1
View File
@@ -1,6 +1,8 @@
//! Evaluation of the surface features: Patch, Stitch, Thicken and Trim (kernel `surfaces`).
//! Evaluation of the surface features: Patch, Stitch, Thicken, Trim, Extend and Surface Extrude
//! (kernel `surfaces`).
use super::*;
use solidcraft_geom::Seg2;
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
match &f.kind {
@@ -84,6 +86,83 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
let ss = sketch_in_feature_frame(
doc,
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
if extent.through_all || extent.taper.is_some() || extent.to.is_some() || extent.from.is_some() {
return Err(DocError::Invalid("not supported yet: a surface extrude through all, to or from an object, or with a taper".into()));
}
let d = val(vals, &extent.distance, Kind::Length)?;
let off = extent.start_offset.as_deref().map(|e| val(vals, e, Kind::Length)).transpose()?.unwrap_or(0.0);
let (a, b) = extent_sides(vals, extent, d, extent.direction)?;
let mut chains: Vec<Vec<Seg2>> = Vec::new();
if let Some(sel) = profiles {
for r in solidcraft_sketch::merge_regions(&select_profiles(&ss, sel)?) {
chains.extend(std::iter::once(r.outer).chain(r.holes).map(|lp| lp.segs));
}
}
chains.extend(curve_chains(&ss, curves)?);
if chains.is_empty() {
return Err(DocError::Invalid("pick the profiles or curves to extrude".into()));
}
let body = kernel::extrude_sheet(&ss.plane, &chains, a + off, b + off)?;
let name = new_name(st, f, 0);
st.bodies.push(ModelBody::new(name, body, f.id));
Ok(())
}
_ => Err(DocError::Invalid("not a surface feature".into())),
}
}
/// Most sketch curves one feature chains.
const MAX_CHAIN_CURVES: usize = 1000;
/// The sketch curves `ids` (free-form ones exactly where they can be) grouped into chains of segments that meet end to end, each oriented
/// to run one way. Curves are joined greedily at their free ends: where three meet, one of them
/// starts a chain of its own.
fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
if ids.len() > MAX_CHAIN_CURVES {
return Err(DocError::Invalid(format!("at most {MAX_CHAIN_CURVES} curves")));
}
let mut pieces: std::collections::VecDeque<Vec<Seg2>> = ids
.iter()
.map(|id| {
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?;
Ok(ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
})
.collect::<Result<Vec<_>>>()?
.into_iter()
.filter(|p| !p.is_empty())
.collect();
let reversed = |p: Vec<Seg2>| -> Vec<Seg2> { p.iter().rev().map(Seg2::reversed).collect() };
let near = |p: Vec2, q: Vec2| p.dist(q) <= 1e-6;
let mut chains = Vec::new();
while let Some(mut chain) = pieces.pop_front() {
while let (Some(head), Some(tail)) = (chain.first().map(Seg2::start), chain.last().map(Seg2::end)) {
if near(head, tail) {
break;
}
let ends = |p: &Vec<Seg2>| p.first().map(Seg2::start).zip(p.last().map(Seg2::end));
let Some(i) = pieces.iter().position(|p| ends(p).is_some_and(|(s, e)| near(s, tail) || near(e, tail) || near(s, head) || near(e, head)))
else {
break;
};
let Some(p) = pieces.remove(i) else { break };
let Some((s, e)) = ends(&p) else { continue };
if near(s, tail) {
chain.extend(p);
} else if near(e, tail) {
chain.extend(reversed(p));
} else if near(e, head) {
chain = p.into_iter().chain(chain).collect();
} else {
chain = reversed(p).into_iter().chain(chain).collect();
}
}
chains.push(chain);
}
Ok(chains)
}
+2
View File
@@ -100,6 +100,8 @@
//! Under the same version, a sheet metal rule's corner relief shape can also be `tear` (no
//! relief: each bend is cut straight from the bends' corner to where its flange's end
//! starts); a rule that says `notch`, `round` or `rectangular` builds what it did.
//! A `SurfaceExtrude` feature (Surface Extrude: `sketch`, optional `profiles`, `curves` and
//! `extent`, walls only) is first saved in 17; 0.8.0 would refuse the unknown feature.
use serde_json::Value;
+1 -1
View File
@@ -54,7 +54,7 @@ impl FeatureKind {
}
}
},
FeatureKind::Extrude { extent, .. } => {
FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } => {
v.push(("Distance", &mut extent.distance, L));
if let Some(d) = &mut extent.distance2 {
v.push(("Distance 2", d, L));