Offset Surface: faces copied out at a distance, exact or fitted (#758) (#872)

Offsets picked faces along their normals into a new surface body: planes and cylinders move exactly, other faces get a fitted B-spline within tolerance, and offsets that would fold over are refused. Adds the SurfaceOffset feature (noted under format 18), the surface.offset engine command and an Offset Surface dialog.

Closes #758.

Co-authored-by: jyviko <[email protected]>
This commit is contained in:
Irakli Kourtisandjyviko authored and GitHub committed 2026-10-13 22:34:16 -04:00
1 parent 15b5b51bf5
commit 2b70ed7b72
15 files changed
+574 -19

No files matched your search

+4 -3
View File
@@ -237,9 +237,10 @@ impl FeatureKind {
}
}
FeatureKind::Chamfer { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::Shell { faces, .. } | FeatureKind::OffsetFace { faces, .. } | FeatureKind::DeleteFace { faces, .. } => {
faces.iter_mut().for_each(pt)
}
FeatureKind::Shell { faces, .. }
| FeatureKind::OffsetFace { faces, .. }
| FeatureKind::DeleteFace { faces, .. }
| FeatureKind::SurfaceOffset { faces, .. } => faces.iter_mut().for_each(pt),
FeatureKind::Draft { faces, neutral, pull, .. } => {
faces.iter_mut().for_each(pt);
map_plane_ref(m, neutral);
+1
View File
@@ -305,6 +305,7 @@ impl FeatureKind {
| K::SplitFace { body, .. }
| K::OffsetFace { body, .. }
| K::DeleteFace { body, .. }
| K::SurfaceOffset { body, .. }
| K::ReplaceFace { body, .. }
| K::SheetFlange { body, .. }
| K::SheetHem { body, .. }
+23
View File
@@ -987,6 +987,19 @@ pub enum FeatureKind {
SurfaceLoft {
sections: Vec<SheetSection>,
},
/// The faces at `faces` copied out at a signed `distance` along their normals as a new
/// surface body (0 copies them); `chain` takes each pick's whole smooth chain of faces;
/// fitted faces within `tolerance`.
SurfaceOffset {
faces: Vec<Vec3>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
distance: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
chain: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
tolerance: Option<String>,
},
/// A planar surface body extended outward by `distance` past the edges through `edges`.
SurfaceExtend {
body: String,
@@ -1331,6 +1344,7 @@ impl FeatureKind {
| K::Scale { .. }
| K::OffsetFace { .. }
| K::DeleteFace { .. }
| K::SurfaceOffset { .. }
| K::BoundingSolid { .. }
| K::ReplaceFace { .. }
| K::Align { .. }
@@ -1410,6 +1424,7 @@ impl FeatureKind {
FeatureKind::Scale { .. } => "ScaleFeature",
FeatureKind::OffsetFace { .. } => "OffsetFacesFeature",
FeatureKind::DeleteFace { .. } => "DeleteFaceFeature",
FeatureKind::SurfaceOffset { .. } => "OffsetSurfaceFeature",
FeatureKind::BoundingSolid { .. } => "BoundingSolidFeature",
FeatureKind::Pipe { .. } => "PipeFeature",
FeatureKind::Emboss { .. } => "EmbossFeature",
@@ -1482,6 +1497,7 @@ impl FeatureKind {
FeatureKind::Scale { .. } => "Scale",
FeatureKind::OffsetFace { .. } => "OffsetFace",
FeatureKind::DeleteFace { .. } => "DeleteFace",
FeatureKind::SurfaceOffset { .. } => "OffsetSurface",
FeatureKind::BoundingSolid { .. } => "BoundingSolid",
FeatureKind::Pipe { .. } => "Pipe",
FeatureKind::Emboss { .. } => "Emboss",
@@ -1572,6 +1588,7 @@ impl FeatureKind {
| FeatureKind::Draft { faces, .. }
| FeatureKind::OffsetFace { faces, .. }
| FeatureKind::DeleteFace { faces, .. }
| FeatureKind::SurfaceOffset { faces, .. }
| FeatureKind::SplitFace { faces, .. }
| FeatureKind::ReplaceFace { faces, .. } => faces.iter_mut().collect(),
FeatureKind::Hole { position, points: None, to, .. } => std::iter::once(position).chain(to.iter_mut()).collect(),
@@ -1747,6 +1764,12 @@ impl FeatureKind {
}
FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v),
FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } | FeatureKind::DeleteFace { .. } => {}
FeatureKind::SurfaceOffset { distance, tolerance, .. } => {
v.push(distance);
if let Some(t) = tolerance {
v.push(t);
}
}
FeatureKind::Stitch { tolerance, .. } => v.push(tolerance),
FeatureKind::MeshConvert { tolerance, .. } => v.push(tolerance),
FeatureKind::Thicken { thickness, .. } => v.push(thickness),
+2 -1
View File
@@ -3638,7 +3638,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::ReverseNormal { .. }
| FeatureKind::SurfaceRevolve { .. }
| FeatureKind::SurfaceSweep { .. }
| FeatureKind::SurfaceLoft { .. } => surface::eval(doc, vals, f, st),
| FeatureKind::SurfaceLoft { .. }
| FeatureKind::SurfaceOffset { .. } => surface::eval(doc, vals, f, st),
FeatureKind::Emboss { .. }
| FeatureKind::Rib { .. }
| FeatureKind::ReplaceFace { .. }
+24 -1
View File
@@ -1,5 +1,6 @@
//! Evaluation of the surface features: Patch, Stitch, Thicken, Trim, Extend, Surface Extrude,
//! Revolve, Sweep and Loft, Unstitch and Reverse Normal (kernel `surfaces`).
//! Revolve, Sweep and Loft, Offset, Unstitch and Reverse Normal (kernel `surfaces`,
//! `surface_offset`).
use super::*;
use solidcraft_geom::Seg2;
@@ -211,6 +212,28 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
push_sheet(st, f, traced);
Ok(())
}
FeatureKind::SurfaceOffset { faces, body, distance, chain, tolerance } => {
let d = val(vals, distance, Kind::Length)?;
let tol = match tolerance {
Some(t) => val(vals, t, Kind::Length)?,
None => 0.01,
};
let i = body_at(st, body, faces)?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let picks = if *chain { smooth_chain(&mb, faces) } else { faces.clone() };
let mut picked: Vec<usize> = Vec::new();
for p in &picks {
let fi =
crate::appearance::face_index_at(&mb, *p).ok_or_else(|| DocError::Invalid(format!("Offset: no face of {} at {p:?}", mb.name)))?;
if !picked.contains(&fi) {
picked.push(fi);
}
}
let made = Traced::step(kernel::offset_surface(&mb.body, &picked, d, tol)?, &[&mb.traced()]);
let name = new_name(st, f, 0);
st.bodies.push(ModelBody::traced_new(name, made, f.id));
Ok(())
}
_ => Err(DocError::Invalid("not a surface feature".into())),
}
}
+2
View File
@@ -122,6 +122,8 @@
//! `path_sketch`, `path`) is also first saved in 18.
//! A `SurfaceLoft` feature (Surface Loft: `sections`, each a `sketch` with optional `profiles`
//! and `curves`) is also first saved in 18.
//! A `SurfaceOffset` feature (Offset Surface: `faces` points, optional `body`, `distance`,
//! `chain` and `tolerance`) is also first saved in 18.
use serde_json::Value;
+6
View File
@@ -278,6 +278,12 @@ impl FeatureKind {
FeatureKind::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v),
FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)),
FeatureKind::Unstitch { .. } | FeatureKind::ReverseNormal { .. } => {}
FeatureKind::SurfaceOffset { distance, tolerance, .. } => {
v.push(("Distance", distance, L));
if let Some(t) = tolerance {
v.push(("Tolerance", t, L));
}
}
FeatureKind::Patch { .. } => {}
FeatureKind::Combine { .. }
| FeatureKind::SheetBase { .. }