Thin Extrude: walls of a set thickness along sketch curves (#721) (#991)

Thin Extrude builds walls of a set thickness along sketch curves, open chains and closed loops, with the wall centred, inside, outside or on both sides at two thicknesses. Saved under format 20 (thin on an Extrude); an extrude without thin saves as before. Closes #721.
This commit is contained in:
Lasse Lian authored and GitHub committed 2026-10-14 11:46:16 -04:00
1 parent 86ea018d05
commit 6df69e9def
38 files changed
+1440 -34

No files matched your search

+4
View File
@@ -118,6 +118,7 @@ fn shell_planar_anchor_rebuild_cold_geometry_and_missing_source_errors() {
let boss = doc
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"2"})).unwrap(),
@@ -280,6 +281,7 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
let boss = doc
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
@@ -429,6 +431,7 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
let boss = doc
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
@@ -553,6 +556,7 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
let material = doc
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
+52
View File
@@ -179,6 +179,51 @@ pub struct Extent {
pub from: Option<Vec3>,
}
/// Thin Extrude: a wall of constant thickness along sketch curves instead of the profiles.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ThinWall {
/// The sketch curves the wall follows: open chains and closed loops, joined where they
/// meet end to end.
pub curves: Vec<String>,
/// The wall thickness (expression); with `side: two_sides`, the thickness inside.
pub thickness: String,
#[serde(default, skip_serializing_if = "ThinSide::is_center")]
pub side: ThinSide,
/// With `side: two_sides`, the thickness outside (expression).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thickness2: Option<String>,
}
/// Where a thin wall lies against its curves. A closed loop's inside is the area it encloses;
/// an open chain's is the side it bends toward (a straight one's, the left of its first
/// curve as drawn).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThinSide {
/// Half the thickness either side of the curves.
#[default]
Center,
Inside,
Outside,
/// `thickness` inside and `thickness2` outside.
TwoSides,
}
impl ThinSide {
pub fn is_center(&self) -> bool {
*self == ThinSide::Center
}
pub fn parse(s: &str) -> Option<ThinSide> {
match s.trim().to_ascii_lowercase().replace([' ', '-'], "_").as_str() {
"center" | "centre" | "centered" | "centred" | "symmetric" => Some(ThinSide::Center),
"inside" | "side1" | "side_1" => Some(ThinSide::Inside),
"outside" | "side2" | "side_2" => Some(ThinSide::Outside),
"two_sides" | "both" => Some(ThinSide::TwoSides),
_ => None,
}
}
}
/// Revolve axis.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -365,6 +410,9 @@ pub enum FeatureKind {
/// Target body names for join/cut/intersect (empty = all bodies).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<String>,
/// Thin: a wall along sketch curves instead of the profiles (which are then unused).
#[serde(default, skip_serializing_if = "Option::is_none")]
thin: Option<ThinWall>,
},
Revolve {
sketch: u64,
@@ -1464,6 +1512,8 @@ impl FeatureKind {
/// The feature's profile selections, each with its sketch.
pub fn profile_sels_mut(&mut self) -> Vec<(u64, &mut ProfileSel)> {
match self {
// A thin extrude follows curves; its profiles are not used.
FeatureKind::Extrude { thin: Some(_), .. } => Vec::new(),
FeatureKind::Extrude { sketch, profiles, .. }
| FeatureKind::Revolve { sketch, profiles, .. }
| FeatureKind::Sweep { sketch, profiles, .. }
@@ -1484,6 +1534,8 @@ impl FeatureKind {
/// Profile selections and their source sketches without cloning feature data.
pub fn profile_sels(&self) -> Vec<(u64, &ProfileSel)> {
match self {
// A thin extrude follows curves; its profiles are not used.
FeatureKind::Extrude { thin: Some(_), .. } => Vec::new(),
FeatureKind::Extrude { sketch, profiles, .. }
| FeatureKind::Revolve { sketch, profiles, .. }
| FeatureKind::Sweep { sketch, profiles, .. }
+34 -2
View File
@@ -2551,6 +2551,35 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc
.collect()
}
/// The regions a thin extrude's wall covers in its sketch.
pub(crate) fn thin_regions(vals: &BTreeMap<String, Value>, ss: &SolvedSketch, thin: &crate::ThinWall) -> Result<Vec<Region2>> {
if thin.curves.is_empty() || thin.curves.len() > 1000 {
return Err(DocError::Invalid("a thin extrude needs 1…1000 sketch curves".into()));
}
let t = val(vals, &thin.thickness, Kind::Length)?;
let (inside, outside) = match thin.side {
crate::ThinSide::Center => (t / 2.0, t / 2.0),
crate::ThinSide::Inside => (t, 0.0),
crate::ThinSide::Outside => (0.0, t),
crate::ThinSide::TwoSides => {
let e = thin.thickness2.as_deref().ok_or_else(|| DocError::Invalid("a two-sided thin wall needs `thickness2`".into()))?;
(t, val(vals, e, Kind::Length)?)
}
};
if !(inside >= 0.0 && outside >= 0.0 && inside + outside > 0.0) {
return Err(DocError::Invalid("the wall thickness must be positive".into()));
}
let curves = thin
.curves
.iter()
.map(|id| {
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}` of {}", ss.name)))?;
Ok(ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
})
.collect::<Result<Vec<_>>>()?;
solidcraft_sketch::thin_wall_regions(&curves, inside, outside).map_err(|e| DocError::Invalid(format!("thin wall: {e}")))
}
/// How far apart (mm) profiles must stay for their extruded tools to be cut as one.
const APART_GAP: f64 = 1e-3;
@@ -2564,13 +2593,16 @@ fn feature_tools_with_constructor(
) -> Result<Vec<Traced>> {
let unnamed = |bodies: Vec<Body>| -> Vec<Traced> { bodies.into_iter().map(Traced::unnamed).collect() };
match &f.kind {
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
FeatureKind::Extrude { sketch, profiles, extent, operation, targets, thin } => {
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,
)?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let regions = match thin {
Some(thin) => thin_regions(vals, &ss, thin)?,
None => solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?),
};
// From / To Object: where the face or point lies along the normal, measured over the
// middle of the profiles. The points are in this feature's component frame, so the
// model is seen from there: a target in another, moved component where it is shown.
+9 -1
View File
@@ -446,6 +446,7 @@ mod tests {
let producer = doc
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: source,
profiles: crate::ProfileSel::Indices { indices: vec![0, 1] },
extent,
@@ -555,7 +556,14 @@ mod tests {
let extent = serde_json::from_value(serde_json::json!({"distance":"2"})).unwrap();
let boss = doc
.add_feature(
FeatureKind::Extrude { sketch: sk, profiles: crate::ProfileSel::All, extent, operation: crate::Operation::NewBody, targets: vec![] },
FeatureKind::Extrude {
thin: None,
sketch: sk,
profiles: crate::ProfileSel::All,
extent,
operation: crate::Operation::NewBody,
targets: vec![],
},
Some("Boss"),
)
.unwrap();
@@ -36,6 +36,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
("sketch", json!({"type": "sketch", "plane": plane("sketch"), "sketch": sk.clone()})),
("sketch_along_path", json!({"type": "sketch", "plane": along("sketch"), "sketch": sk})),
("extrude", json!({"type": "extrude", "sketch": 1, "profiles": {"type": "all"}, "extent": extent("extrude")})),
(
"extrude_thin",
json!({"type": "extrude", "sketch": 1, "profiles": {"type": "all"}, "extent": extent("thin"),
"thin": {"curves": ["c1"], "thickness": "thin.thickness", "side": "two_sides", "thickness2": "thin.thickness2"}}),
),
(
"revolve",
json!({"type": "revolve", "sketch": 1, "profiles": {"type": "all"}, "axis": {"type": "world", "axis": "X"}, "angle": "revolve.angle", "angle2": "revolve.angle2"}),
@@ -158,7 +163,7 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value {
let o = [0, 0, 0];
let tool = json!({"body": "B", "point": o});
let extra = match name {
"extrude" | "revolve" | "sweep" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
"extrude" | "extrude_thin" | "revolve" | "sweep" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
"fillet_constant" | "fillet_chord" | "fillet_curvature" | "fillet_variable" | "fillet_spline" | "chamfer" | "shell" | "draft"
| "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_unfold" | "boss" | "lip" | "snap_fit" | "rest"
| "surface_offset" => {
@@ -16,6 +16,16 @@ extrude
Taper angle = extrude.taper (Angle)
To offset = extrude.to_offset (Length)
expressions: extrude.distance, extrude.distance2, extrude.start_offset, extrude.taper, extrude.to_offset
extrude_thin
inputs:
Distance = thin.distance (Length)
Distance 2 = thin.distance2 (Length)
Start offset = thin.start_offset (Length)
Taper angle = thin.taper (Angle)
To offset = thin.to_offset (Length)
Thickness = thin.thickness (Length)
Thickness 2 = thin.thickness2 (Length)
expressions: thin.distance, thin.distance2, thin.start_offset, thin.taper, thin.to_offset, thin.thickness, thin.thickness2
revolve
inputs:
Angle = revolve.angle (Angle)
@@ -7,6 +7,10 @@ extrude
sketches: [1]
remap_refs: sketch
remap_bodies: targets[0]
extrude_thin
sketches: [1]
remap_refs: sketch
remap_bodies: targets[0]
revolve
sketches: [1]
remap_refs: sketch
+9 -2
View File
@@ -138,13 +138,18 @@
//! Under the same version, a fillet's style can be `curvature` (a G2 quintic blend; `radius`
//! is the equivalent circular setback). Fillets of the other styles save as before; 0.9.0
//! would refuse the unknown style.
//! - 20: an Extrude can be thin (`thin`: `curves`, `thickness`, optional `side` and
//! `thickness2`): a wall along sketch curves instead of its profiles. Version 19 designs need
//! no transformation, and an extrude without `thin` saves as before; the version stamp makes
//! 0.10.0 (which reads up to 19) ask for an update instead of ignoring the wall and filling
//! the sketch's profiles.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 19;
pub const FORMAT: u32 = 20;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -161,7 +166,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 18] = [
const JSON_STEPS: [JsonStep; 19] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -180,6 +185,7 @@ const JSON_STEPS: [JsonStep; 18] = [
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
@@ -1048,6 +1054,7 @@ mod tests {
document
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch,
profiles: ProfileSel::All,
extent: Extent {
+1
View File
@@ -423,6 +423,7 @@ mod tests {
document
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: if missing_sketch { 999_999 } else { id },
profiles: ProfileSel::OffsetBoundaries { associations: vec!["deleted-o1".into()] },
extent: crate::Extent {
+22 -1
View File
@@ -81,7 +81,28 @@ macro_rules! feature_walk {
}
}
},
FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } => {
FeatureKind::Extrude { extent, thin, .. } => {
f("Distance", & $($m)? extent.distance, L, I);
if let Some(d) = & $($m)? extent.distance2 {
f("Distance 2", d, L, I);
}
if let Some(d) = & $($m)? extent.start_offset {
f("Start offset", d, L, I);
}
if let Some(d) = & $($m)? extent.taper {
f("Taper angle", d, A, I);
}
if let Some(d) = & $($m)? extent.to_offset {
f("To offset", d, L, I);
}
if let Some(thin) = thin {
f("Thickness", & $($m)? thin.thickness, L, I);
if let Some(t) = & $($m)? thin.thickness2 {
f("Thickness 2", t, L, I);
}
}
}
FeatureKind::SurfaceExtrude { extent, .. } => {
f("Distance", & $($m)? extent.distance, L, I);
if let Some(d) = & $($m)? extent.distance2 {
f("Distance 2", d, L, I);
+2
View File
@@ -308,6 +308,7 @@ mod tests {
document
.add_feature(
crate::FeatureKind::Extrude {
thin: None,
sketch: feature,
profiles: selection.clone(),
extent: crate::Extent {
@@ -433,6 +434,7 @@ mod tests {
document
.add_feature(
crate::FeatureKind::Extrude {
thin: None,
sketch: feature,
profiles: selection.clone(),
extent: crate::Extent {
+2
View File
@@ -29,6 +29,7 @@ fn plate_doc() -> (Document, u64) {
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
doc.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: s,
profiles: ProfileSel::All,
extent: Extent {
@@ -120,6 +121,7 @@ fn box_fillet_cut_timeline() {
let s2 = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
doc.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: s2,
profiles: ProfileSel::Points { points: vec![Vec2::new(20.0, 15.0)] },
extent: Extent {