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 <2501648+jyviko@users.noreply.github.com>
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 { .. }
+56 -4
View File
@@ -46,10 +46,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SURFACE", "CREATE")
.icon("surface_loft")
.params("sections: [{sketch, curves?: [curve ids], profiles?}] (2…50 in order; each one chain: a profile without holes, or curves meeting end to end; all open or all closed); name?, body_name? (a surface body: ruled between two sections, smooth through three or more; no caps)"),
planned!("surface.offset", "Offset Surface", "758")
CommandSpec::new("surface.offset", "Offset Surface", offset)
.at("SURFACE", "CREATE")
.icon("surface_offset")
.params("planned: faces: [{body, point}]; distance: expr (signed, along the normals; 0 copies the faces); chain?: bool (the whole tangent chain of each pick); tolerance?: expr (B-spline fit, default 0.01 mm)"),
.params("faces: [[x,y,z]] (points on the faces); body?; distance: expr (signed, along the faces' normals; 0 copies the faces); chain?: bool (each pick's whole smooth chain of faces); tolerance?: expr (how far a fitted face may stray, default 0.01 mm). Planes and cylinders move exactly; other faces are fitted. A new surface body (a solid where its faces close)"),
planned!("surface.ruled", "Ruled Surface", "762")
.at("SURFACE", "CREATE")
.icon("ruled")
@@ -186,6 +186,28 @@ fn surface_loft(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::SurfaceLoft { sections })
}
fn offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.offset";
let faces: Vec<solidcraft_geom::Vec3> = p.get("faces").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if faces.is_empty() || faces.len() > 10_000 {
return Err(bad(cmd, "`faces` must list points on the faces to offset"));
}
let distance = expr(p, "distance").ok_or_else(|| bad(cmd, "`distance` is required"))?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
let tolerance = expr(p, "tolerance");
if let Some(t) = &tolerance {
check_expr(s, t, Kind::Length, cmd, "tolerance")?;
}
let kind = FeatureKind::SurfaceOffset {
faces,
body: str_(p, "body").map(str::to_string),
distance,
chain: bool_(p, "chain").unwrap_or(false),
tolerance,
};
add_feature(s, p, kind)
}
fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
@@ -585,13 +607,43 @@ mod tests {
}
}
/// Offset Surface: a cylinder's side picked once with its chain (both halves) moved out to
/// radius 12; a box top moved up exactly; a zero distance copies; past the axis refused, the
/// design left as it was; undo, redo and reopening.
#[test]
fn offset_surface_of_a_cylinder_and_a_box_top() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 20, "body_name": "Can"}));
run(&mut s, "surface.offset", json!({"faces": [[10, 0, 10]], "chain": true, "distance": 2, "body": "Can", "body_name": "Sleeve"}));
let st = s.world_state();
let sleeve = &st.body("Sleeve").unwrap().body;
assert!(sleeve.is_surface() && sleeve.face_count() == 2);
let want = 2.0 * std::f64::consts::PI * 12.0 * 20.0;
assert!((area(&s, "Sleeve") - want).abs() < want * 1e-3, "{} vs {want}", area(&s, "Sleeve")); // (measured on meshes)
assert!(st.body("Can").is_some(), "the source body stays");
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0], "body_name": "Box"}));
run(&mut s, "surface.offset", json!({"faces": [[35, 5, 10]], "distance": "5 mm", "body_name": "Lid"}));
let bb = solidcraft_kernel::measure(&s.world_state().body("Lid").unwrap().body).unwrap().bbox;
assert!((bb.min.z - 15.0).abs() < 1e-9 && (area(&s, "Lid") - 100.0).abs() < 1e-9);
run(&mut s, "surface.offset", json!({"faces": [[35, 5, 10]], "distance": 0, "body_name": "Copy"}));
assert!((area(&s, "Copy") - 100.0).abs() < 1e-9);
let before = s.doc.features.len();
assert!(s.execute("surface.offset", &json!({"faces": [[10, 0, 10]], "chain": true, "distance": -11, "body": "Can"})).is_err());
assert_eq!(s.doc.features.len(), before);
run(&mut s, "edit.undo", json!({}));
assert!(s.world_state().body("Copy").is_none());
run(&mut s, "edit.redo", json!({}));
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!((area(&cold, "Sleeve") - area(&s, "Sleeve")).abs() < 1e-9);
assert!(s.execute("surface.offset", &json!({"distance": 1})).is_err());
}
/// The planned surface commands are registered but refuse to run: disabled with the
/// issue that tracks them, an error (never a panic) from every caller, and the design left
/// as it was.
#[test]
fn planned_commands_refuse_and_leave_the_design_alone() {
let planned =
["surface.offset", "surface.ruled", "surface.boundary", "surface.blend", "surface.untrim", "surface.fillet", "inspect.continuity"];
let planned = ["surface.ruled", "surface.boundary", "surface.blend", "surface.untrim", "surface.fillet", "inspect.continuity"];
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
let before = s.doc.features.len();
+2
View File
@@ -78,6 +78,7 @@ mod step;
mod step_in;
mod step_out;
mod surface_kind;
mod surface_offset;
mod surfaces;
mod sweep_path;
mod tess;
@@ -116,6 +117,7 @@ pub use step::step_export;
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_orientation_errors, step_validate};
pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products};
pub use surface_kind::{FaceSurface, face_surface};
pub use surface_offset::offset_surface;
pub use surfaces::{
copy_faces, extend, extrude_sheet, loft_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, sweep_sheet,
sweep_sheet_points, thicken, trim, unstitch,
+388
View File
@@ -0,0 +1,388 @@
//! Offset surfaces (Surface › Offset): faces of a body copied out at a signed distance along
//! their normals, as a new surface body. Planes and circular cylinders move exactly (as Thicken
//! moves them); any other face gets a B-spline surface fitted through offset samples on a grid,
//! refined until it is within a tolerance of the true offset everywhere it is checked. An
//! offset as deep as a face's smallest concave radius would fold it over, and is refused,
//! naming that radius. Neighbouring faces moved to the same place along their shared edge
//! (meeting smoothly) keep sharing it; across a sharp edge they come apart. Faces that close up
//! make a solid.
use std::collections::HashMap;
use mt::{BoundedCurve, InnerSpace, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
use solidcraft_geom::Vec3;
use truck_modeling as mt;
use crate::body::{Body, from_p3, p3};
use crate::history::FaceOrigin;
use crate::surfaces::{Offset, map_curve, map_surface, surface_body};
use crate::{KernelError, Result, guard};
/// Grid samples a side of a fitted offset starts with, and the most it is refined to.
const START_GRID: usize = 12;
const MAX_GRID: usize = 96;
/// Samples along an edge of a fitted offset.
const EDGE_SAMPLES: usize = 33;
/// How one face moves.
enum Moved {
Exact(Offset),
/// The fitted surface (absolute parameters of the face's own surface), and the face's
/// outward sign (its orientation).
Fitted {
surface: mt::BSplineSurface<mt::Point3>,
sign: f64,
},
}
/// The picked faces of `body` (indices in its face order) offset by `distance` along their
/// outward normals (negative: against them) into one surface body. `tolerance`: how far a
/// fitted face may stray from the true offset. A distance of zero copies the faces. The
/// history is over `[body]`: each face is the offset of the one it came from.
pub fn offset_surface(body: &Body, faces: &[usize], distance: f64, tolerance: f64) -> Result<Body> {
body.require_brep("offset")?;
if !(distance.is_finite() && distance.abs() < 1e6) {
return Err(KernelError::Invalid("the offset distance must be finite".into()));
}
if !(tolerance.is_finite() && (1e-6..=10.0).contains(&tolerance)) {
return Err(KernelError::Invalid("the offset tolerance must be 0.000001…10 mm".into()));
}
let all: Vec<mt::Face> = body.solid.face_iter().cloned().collect();
let mut picked: Vec<usize> = Vec::new();
for &i in faces {
if i >= all.len() {
return Err(KernelError::Invalid("no such face".into()));
}
if !picked.contains(&i) {
picked.push(i);
}
}
if picked.is_empty() {
return Err(KernelError::Invalid("pick the faces to offset".into()));
}
let chosen: Vec<(usize, &mt::Face)> = picked.iter().filter_map(|i| all.get(*i).map(|f| (*i, f))).collect();
if distance == 0.0 {
let copy = surface_body(chosen.iter().map(|(_, f)| (*f).clone()).collect());
return Ok(crate::record::rebuilt_body(copy, &[&body.solid], &crate::record::Rebuild::default()));
}
let size = body.size().max(1e-6);
let moves: Vec<Moved> = chosen
.iter()
.map(|(_, f)| match Offset::of(f) {
Some(off) => {
if let Offset::Cylinder { r, out, .. } = off
&& r + out * distance <= tolerance
{
return Err(KernelError::Invalid(format!(
"the offset is as deep as the cylinder's radius ({r:.4} mm): it would fold the face over"
)));
}
Ok(Moved::Exact(off))
}
None => fit(f, distance, tolerance),
})
.collect::<Result<_>>()?;
guard("offset surface", || {
let near = |a: Vec3, b: Vec3| a.dist(b) <= tolerance.max(size * 1e-7) * 4.0;
// Moved vertices and edges, shared while every face moves them to the same place.
let mut verts: HashMap<mt::VertexID, Vec<mt::Vertex>> = HashMap::new();
let mut edges: HashMap<mt::EdgeID, Vec<mt::Edge>> = HashMap::new();
let mut out = Vec::with_capacity(chosen.len());
let mut history = Vec::with_capacity(chosen.len());
for ((i, f), mv) in chosen.iter().zip(&moves) {
let map = |p: Vec3| -> Result<Vec3> {
match mv {
Moved::Exact(off) => Ok(off.map(p, distance)),
Moved::Fitted { sign, .. } => offset_point(&f.surface(), p, *sign * distance),
}
};
let mut vertex = |v: &mt::Vertex| -> Result<mt::Vertex> {
let at = map(from_p3(v.point()))?;
let list = verts.entry(v.id()).or_default();
if let Some(found) = list.iter().find(|m| near(from_p3(m.point()), at)) {
return Ok(found.clone());
}
let nv = mt::Vertex::new(p3(at));
list.push(nv.clone());
Ok(nv)
};
let mut wires = Vec::new();
for w in f.absolute_boundaries() {
let mut wire = Vec::new();
for e in w.edge_iter() {
let abs = if e.orientation() { e.clone() } else { e.inverse() };
let (a, b) = (vertex(abs.front())?, vertex(abs.back())?);
let c = abs.curve();
let (t0, t1) = c.range_tuple();
let mid = map(from_p3(c.subs(0.5 * (t0 + t1))))?;
let list = edges.entry(abs.id()).or_default();
let shared = list.iter().find(|m| {
m.front().id() == a.id() && m.back().id() == b.id() && {
let mc = m.curve();
let (s0, s1) = mc.range_tuple();
near(from_p3(mc.subs(0.5 * (s0 + s1))), mid)
}
});
let moved = match shared {
Some(m) => m.clone(),
None => {
let curve = match mv {
Moved::Exact(off) => map_curve(off, &c, distance)?,
Moved::Fitted { .. } => {
let pts: Vec<Vec3> = (0..EDGE_SAMPLES)
.map(|k| map(from_p3(c.subs(t0 + (t1 - t0) * k as f64 / (EDGE_SAMPLES - 1) as f64))))
.collect::<Result<_>>()?;
mt::Curve::BSplineCurve(
crate::build::interpolate_cubic(&pts).ok_or_else(|| KernelError::Failed("offset edge".into()))?,
)
}
};
let m = mt::Edge::new_unchecked(&a, &b, curve);
list.push(m.clone());
m
}
};
wire.push(if e.orientation() { moved } else { moved.inverse() });
}
wires.push(mt::Wire::from(wire));
}
let surface = match mv {
Moved::Exact(off) => map_surface(off, &f.surface(), distance)?,
Moved::Fitted { surface, .. } => mt::Surface::BSplineSurface(surface.clone()),
};
let mut nf = mt::Face::new_unchecked(wires, surface);
if !f.orientation() {
nf.invert();
}
out.push(nf);
history.push(crate::record::generated(vec![FaceOrigin::Offset { operand: 0, face: *i }]));
}
let operand = crate::record::Operand::new(body);
// Faces that close up (every face of a smooth closed body) make a solid, as Stitch makes
// one.
let shell = mt::Shell::from(out);
let result = match shell.shell_condition() {
mt::ShellCondition::Closed => match crate::body::Solid::try_new(vec![shell.clone()]) {
Ok(solid) => Body::new(solid)?,
Err(_) => surface_body(shell.into_iter().collect()),
},
_ => surface_body(shell.into_iter().collect()),
};
crate::record::attach(result, &[operand], Some(history), HashMap::new())
})
}
/// The point `p` on `s` (absolute) moved by `d` along the surface's own normal there.
fn offset_point(s: &mt::Surface, p: Vec3, d: f64) -> Result<Vec3> {
let (u, v) = s.search_nearest_parameter(p3(p), None, 100).ok_or_else(|| KernelError::Failed("offset: a point off its face".into()))?;
let n = from_p3(mt::Point3::new(0.0, 0.0, 0.0) + s.normal(u, v));
let n =
n.normalized().ok_or_else(|| KernelError::Invalid("the face has a point with no normal (a pole or a crease): not supported yet".into()))?;
Ok(from_p3(s.subs(u, v)) + n * d)
}
/// The fitted offset of a face that is not a plane or a circular cylinder.
fn fit(f: &mt::Face, distance: f64, tolerance: f64) -> Result<Moved> {
let s = f.surface();
let sign = if f.orientation() { 1.0 } else { -1.0 };
let d = sign * distance;
// The face's span in its surface's parameters: its boundary, found on the surface.
let (mut u0, mut u1, mut v0, mut v1) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
for e in f.absolute_boundaries().iter().flat_map(|w| w.edge_iter()) {
let c = e.curve();
let (t0, t1) = c.range_tuple();
for k in 0..=16 {
let q = c.subs(t0 + (t1 - t0) * k as f64 / 16.0);
if let Some((u, v)) = s.search_nearest_parameter(q, None, 100) {
(u0, u1, v0, v1) = (u0.min(u), u1.max(u), v0.min(v), v1.max(v));
}
}
}
if !(u0.is_finite() && u1 > u0 && v0.is_finite() && v1 > v0) {
return Err(KernelError::Failed("offset: the face's span on its surface".into()));
}
let at = |a: f64, b: f64| -> Result<Vec3> {
let (u, v) = (u0 + (u1 - u0) * a, v0 + (v1 - v0) * b);
let n = from_p3(mt::Point3::new(0.0, 0.0, 0.0) + s.normal(u, v));
let n = n
.normalized()
.ok_or_else(|| KernelError::Invalid("the face has a point with no normal (a pole or a crease): not supported yet".into()))?;
Ok(from_p3(s.subs(u, v)) + n * d)
};
// Folding over: moving a point by d along its normal shrinks lengths along a principal
// direction by 1 - d·k (k > 0 where the surface bends toward its normal); at zero the
// offset reaches the centre of curvature and folds. Refused with a margin, naming the
// tightest radius on that side.
let mut tightest: Option<f64> = None;
for i in 0..=START_GRID {
for j in 0..=START_GRID {
let (u, v) = (u0 + (u1 - u0) * i as f64 / START_GRID as f64, v0 + (v1 - v0) * j as f64 / START_GRID as f64);
let Some((k1, k2)) = principal_curvatures(&s, u, v) else { continue };
for k in [k1, k2] {
if d * k > 0.0 && 1.0 - d * k <= 0.05 {
let r = 1.0 / k.abs();
tightest = Some(tightest.map_or(r, |t: f64| t.min(r)));
}
}
}
}
if let Some(r) = tightest {
return Err(KernelError::Invalid(format!(
"the offset is as deep as the face's smallest concave radius on that side ({r:.4} mm): it would fold the face over"
)));
}
let mut n = START_GRID;
loop {
let grid: Vec<Vec<Vec3>> =
(0..=n).map(|i| (0..=n).map(|j| at(i as f64 / n as f64, j as f64 / n as f64)).collect::<Result<Vec<_>>>()).collect::<Result<_>>()?;
let surface = interpolate_surface(&grid).ok_or_else(|| KernelError::Failed("offset: fitting the surface".into()))?;
// Checked between the samples, where a fit strays most.
let mut worst = 0.0f64;
for i in 0..n {
for j in 0..n {
let (a, b) = ((i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64);
worst = worst.max(from_p3(surface.subs(a, b)).dist(at(a, b)?));
}
}
if worst <= tolerance {
// Re-parameterised to the face's own surface span, so boundary points found on the
// old surface map onto it.
return Ok(Moved::Fitted { surface: reparameterised(surface, (u0, u1), (v0, v1)), sign });
}
if n >= MAX_GRID {
return Err(KernelError::Invalid(format!(
"the offset can't be fitted within {tolerance} mm (off by up to {worst:.4} mm): try a larger tolerance"
)));
}
n = (n * 2).min(MAX_GRID);
}
}
/// The principal curvatures of `s` at (u, v), signed: positive where the surface bends toward
/// its normal there. `None` where it has no normal.
fn principal_curvatures(s: &mt::Surface, u: f64, v: f64) -> Option<(f64, f64)> {
let (su, sv) = (s.uder(u, v), s.vder(u, v));
let n = s.normal(u, v);
if !(n.magnitude2() > 0.5) {
return None;
}
let (e, f, g) = (su.dot(su), su.dot(sv), sv.dot(sv));
let (l, m, nn) = (s.uuder(u, v).dot(n), s.uvder(u, v).dot(n), s.vvder(u, v).dot(n));
let det = e * g - f * f;
if det.abs() < 1e-18 {
return None;
}
let k = (l * nn - m * m) / det;
let h = (e * nn - 2.0 * f * m + g * l) / (2.0 * det);
let disc = (h * h - k).max(0.0).sqrt();
Some((h + disc, h - disc))
}
/// A cubic B-spline surface through a grid of points at uniform parameters on [0, 1]².
fn interpolate_surface(grid: &[Vec<Vec3>]) -> Option<mt::BSplineSurface<mt::Point3>> {
let rows: Vec<mt::BSplineCurve<mt::Point3>> = grid.iter().map(|r| crate::build::interpolate_cubic(r)).collect::<Option<_>>()?;
let kv = rows.first()?.knot_vec().clone();
let m = rows.first()?.control_points().len();
let mut cols = Vec::with_capacity(m);
for j in 0..m {
let pts: Vec<Vec3> = rows.iter().filter_map(|r| r.control_points().get(j).map(|p| from_p3(*p))).collect();
cols.push(crate::build::interpolate_cubic(&pts)?);
}
let ku = cols.first()?.knot_vec().clone();
let n = cols.first()?.control_points().len();
let net: Vec<Vec<mt::Point3>> = (0..n).map(|i| cols.iter().filter_map(|c| c.control_points().get(i).copied()).collect()).collect();
mt::BSplineSurface::try_new((ku, kv), net).ok()
}
/// `s` (on [0, 1]²) with its knots stretched onto `u` × `v`.
fn reparameterised(s: mt::BSplineSurface<mt::Point3>, u: (f64, f64), v: (f64, f64)) -> mt::BSplineSurface<mt::Point3> {
let stretch = |kv: &mt::KnotVec, (a, b): (f64, f64)| mt::KnotVec::from(kv.iter().map(|k| a + (b - a) * k).collect::<Vec<_>>());
let (ku, kv) = (stretch(s.uknot_vec(), u), stretch(s.vknot_vec(), v));
mt::BSplineSurface::new((ku, kv), s.control_points().clone())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{box_solid, cylinder, measure, sphere};
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-9)
}
fn face_at(b: &Body, p: Vec3) -> usize {
crate::surfaces::nearest_face(b, p).unwrap()
}
fn edges(b: &Body) -> usize {
b.solid.edge_iter().map(|e| e.id()).collect::<std::collections::HashSet<_>>().len()
}
/// Planes move exactly: a box's top 5 up is a 10 × 10 square at z = 15. Two faces across a
/// sharp edge come apart (8 edges, none shared); a zero distance copies.
#[test]
fn planes_move_and_sharp_edges_come_apart() {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let top = face_at(&b, Vec3::new(5.0, 5.0, 10.0));
let up = offset_surface(&b, &[top], 5.0, 0.01).unwrap();
let m = measure(&up).unwrap();
assert!(up.is_surface() && rel(m.area, 100.0) < 1e-9 && (m.bbox.min.z - 15.0).abs() < 1e-9, "{m:?}");
let side = face_at(&b, Vec3::new(5.0, 0.0, 5.0));
let two = offset_surface(&b, &[top, side], 1.0, 0.01).unwrap();
assert!(two.face_count() == 2 && edges(&two) == 8);
let copy = offset_surface(&b, &[top, side], 0.0, 0.01).unwrap();
assert!(copy.face_count() == 2 && rel(measure(&copy).unwrap().area, 200.0) < 1e-9);
}
/// A cylinder's side (two halves) moved out by 2 keeps its halves joined, with radius 12:
/// area 2π·12·20. Moved in past its axis, refused.
#[test]
fn a_cylinder_side_changes_radius() {
let c = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
let d = 10.0 / 2f64.sqrt();
let halves = [face_at(&c, Vec3::new(d, d, 10.0)), face_at(&c, Vec3::new(-d, -d, 10.0))];
let out = offset_surface(&c, &halves, 2.0, 0.01).unwrap();
assert!(out.face_count() == 2);
let want = 2.0 * std::f64::consts::PI * 12.0 * 20.0;
assert!(rel(measure(&out).unwrap().area, want) < 1e-3, "{} vs {want}", measure(&out).unwrap().area); // (measured on meshes)
let joined = offset_surface(&c, &halves, 0.0, 0.01).unwrap();
assert_eq!(edges(&out), edges(&joined), "the halves still share their seams");
assert!(offset_surface(&c, &halves, -10.5, 0.01).is_err());
}
/// A sphere (fitted faces) moved out by 2: area 4π·12², within the tolerance; moved in to
/// its centre, refused with its radius named.
#[test]
fn a_sphere_is_fitted_and_refused_past_its_centre() {
let s = sphere(Vec3::ZERO, 10.0).unwrap();
let all: Vec<usize> = (0..s.face_count()).collect();
let out = offset_surface(&s, &all, 2.0, 0.01).unwrap();
let want = 4.0 * std::f64::consts::PI * 144.0;
assert!(rel(measure(&out).unwrap().area, want) < 2e-3, "{} vs {want}", measure(&out).unwrap().area); // (measured on meshes)
// The patches meet smoothly: their offsets still share every edge, and close up.
assert_eq!(edges(&out), edges(&s));
assert!(!out.is_surface(), "the offset sphere closes into a solid");
assert!(rel(measure(&out).unwrap().volume, 4.0 / 3.0 * std::f64::consts::PI * 1728.0) < 2e-3, "outward"); // (measured on meshes)
let e = offset_surface(&s, &all, -10.0, 0.01).unwrap_err().to_string();
assert!(e.contains("10.0000"), "{e}");
}
/// A doubly curved patch (a saddle, a B-spline height field) moved by 1.5: every point of the
/// offset lies 1.5 from the patch, within the tolerance.
#[test]
fn a_bspline_patch_is_offset_within_the_tolerance() {
let b = box_solid(Vec3::new(-14.0, -14.0, 0.0), Vec3::new(14.0, 14.0, 20.0)).unwrap();
let tool = cylinder(Vec3::new(0.0, -60.0, 50.0), Vec3::Y, 40.0, 120.0).unwrap();
let cup = crate::boolean(&b, &tool, crate::BoolOp::Cut).unwrap().unwrap();
let z = |x: f64| 50.0 - (1600.0 - x * x).sqrt();
let rim = [Vec3::new(14.0, 0.0, z(14.0)), Vec3::new(0.0, 14.0, z(0.0)), Vec3::new(-14.0, 0.0, z(-14.0)), Vec3::new(0.0, -14.0, z(0.0))];
let cover = crate::patch_edges(&cup, &rim).unwrap();
let face = cover.solid.face_iter().next().unwrap().clone();
let out = offset_surface(&cover, &[0], 1.5, 0.01).unwrap();
let mesh = out.tessellate(0.05).unwrap();
let s = face.surface();
for p in mesh.positions.iter().step_by(7) {
let (u, v) = s.search_nearest_parameter(p3(*p), None, 100).unwrap();
let dist = from_p3(s.subs(u, v)).dist(*p);
assert!((dist - 1.5).abs() < 0.03, "{dist} at {p:?}");
}
}
}
+6 -6
View File
@@ -536,7 +536,7 @@ pub fn remove_faces(body: &Body, faces: &[usize]) -> Result<Body> {
}
/// Index of the face nearest a point (over its mesh triangles).
fn nearest_face(body: &Body, p: Vec3) -> Result<usize> {
pub(crate) fn nearest_face(body: &Body, p: Vec3) -> Result<usize> {
let m = body.tessellate(body.size() * 2e-3)?;
let mut best: Option<(f64, u32)> = None;
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
@@ -662,19 +662,19 @@ pub fn stitch(bodies: &[&Body], tol: f64) -> Result<Body> {
/// How a face moves along its normal: a plane slides, a circular cylinder changes radius.
#[derive(Clone, Copy)]
enum Offset {
pub(crate) enum Offset {
Plane { n: Vec3 },
Cylinder { o: Vec3, axis: Vec3, r: f64, out: f64 },
}
impl Offset {
fn of(f: &mt::Face) -> Option<Offset> {
pub(crate) fn of(f: &mt::Face) -> Option<Offset> {
match crate::splitface::analytic_field(&f.oriented_surface())? {
crate::splitface::Analytic::Plane { n } => Some(Offset::Plane { n }),
crate::splitface::Analytic::Cylinder { o, axis, r, out } => Some(Offset::Cylinder { o, axis, r, out }),
}
}
fn map(&self, p: Vec3, d: f64) -> Vec3 {
pub(crate) fn map(&self, p: Vec3, d: f64) -> Vec3 {
match *self {
Offset::Plane { n } => p + n * d,
// Scaled about the axis (control points of a NURBS circle stay exact).
@@ -693,7 +693,7 @@ fn map_pt(off: &Offset, p: mt::Point3, d: f64) -> mt::Point3 {
/// A curve moved by an offset (control points mapped; exact for lines, and for circles square
/// to a cylinder's axis or in a plane).
fn map_curve(off: &Offset, c: &mt::Curve, d: f64) -> Result<mt::Curve> {
pub(crate) fn map_curve(off: &Offset, c: &mt::Curve, d: f64) -> Result<mt::Curve> {
Ok(match c {
mt::Curve::Line(l) => mt::Curve::Line(mt::Line(map_pt(off, l.0, d), map_pt(off, l.1, d))),
mt::Curve::BSplineCurve(b) => {
@@ -723,7 +723,7 @@ fn map_curve(off: &Offset, c: &mt::Curve, d: f64) -> Result<mt::Curve> {
}
/// A surface moved by an offset (control points mapped; exact for planes and NURBS cylinders).
fn map_surface(off: &Offset, s: &mt::Surface, d: f64) -> Result<mt::Surface> {
pub(crate) fn map_surface(off: &Offset, s: &mt::Surface, d: f64) -> Result<mt::Surface> {
Ok(match s {
mt::Surface::Plane(pl) => {
let (o, a, b) = (pl.origin(), pl.u_axis(), pl.v_axis());
+31 -1
View File
@@ -30,6 +30,10 @@ pub enum Surf {
Patch,
Unstitch,
ReverseNormal,
Offset {
distance: String,
chain: bool,
},
Stitch {
tolerance: String,
},
@@ -58,6 +62,7 @@ impl Surf {
Surf::Patch => "PATCH",
Surf::Unstitch => "UNSTITCH",
Surf::ReverseNormal => "REVERSE NORMAL",
Surf::Offset { .. } => "OFFSET SURFACE",
Surf::Stitch { .. } => "STITCH",
Surf::ConvertMesh { .. } => "CONVERT MESH",
Surf::Thicken { .. } => "THICKEN",
@@ -71,7 +76,7 @@ impl Surf {
pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> {
Some(match self {
Surf::Thicken { thickness, .. } => ("Thickness", ValueKind::Length, thickness),
Surf::Extend { distance } | Surf::Extrude { distance, .. } => ("Distance", ValueKind::Length, distance),
Surf::Extend { distance } | Surf::Extrude { distance, .. } | Surf::Offset { distance, .. } => ("Distance", ValueKind::Length, distance),
Surf::Revolve { angle, .. } => ("Angle", ValueKind::Angle, angle),
_ => return None,
})
@@ -90,6 +95,7 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
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)],
Surf::Offset { .. } => vec![SelInput::new("Faces", FACES, true)],
Surf::Stitch { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
Surf::ConvertMesh { .. } => vec![SelInput::new("Mesh body", BODIES, false)],
Surf::Thicken { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
@@ -113,6 +119,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
"surface.patch" => Surf::Patch,
"surface.unstitch" => Surf::Unstitch,
"surface.reverse_normal" => Surf::ReverseNormal,
"surface.offset" => Surf::Offset { distance: "2 mm".into(), chain: false },
"surface.stitch" => Surf::Stitch { tolerance: String::new() },
"mesh.convert" => Surf::ConvertMesh { tolerance: "0.05 mm".into() },
"surface.thicken" => Surf::Thicken { thickness: "2 mm".into(), symmetric: false, operation: 0 },
@@ -164,6 +171,14 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
*symmetric = dir == 1;
ui.end_row();
}
Surf::Offset { distance, chain } => {
row_label(ui, "Distance");
enter |= field(ui, distance);
ui.end_row();
row_label(ui, "Tangent chain");
ui.checkbox(chain, "");
ui.end_row();
}
Surf::Extrude { distance, direction } => {
row_label(ui, "Distance");
enter |= field(ui, distance);
@@ -327,6 +342,18 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
("surface.patch", json!({"edges": edges, "body": body}))
}
}
Surf::Offset { distance, chain } => {
need(0, "the faces to offset")?;
let (body, faces) = body_and_faces(items(inputs, 0))?;
if faces.is_empty() {
return Err("pick faces to offset".into());
}
let mut p = json!({"body": body, "faces": faces, "distance": distance});
if *chain {
p["chain"] = json!(true);
}
("surface.offset", p)
}
Surf::Unstitch | Surf::ReverseNormal => {
need(0, "a body or some of its faces")?;
let (body, faces) = body_and_faces(items(inputs, 0))?;
@@ -459,6 +486,9 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
}
(Surf::Patch, vec![sels])
}
FeatureKind::SurfaceOffset { faces, distance, chain, .. } => {
(Surf::Offset { distance: distance.clone(), chain: *chain }, vec![faces.iter().filter_map(|p| crate::dialogs::face_sel(s, *p)).collect()])
}
FeatureKind::Unstitch { body, faces } | FeatureKind::ReverseNormal { body, faces } => {
let sels: Vec<Sel> = if faces.is_empty() {
vec![Sel::Body { name: body.clone() }]
+25
View File
@@ -939,6 +939,31 @@ fn surface_loft_dialog_makes_a_section_per_sketch() {
assert_eq!(back.inputs[0].items, vec![Sel::SketchCurve { id: "a".into() }, Sel::SketchCurve { id: "b".into() }]);
}
#[test]
fn offset_surface_dialog_takes_faces_a_distance_and_the_chain() {
// A box top picked, moved up 5: a 10 × 10 square at z = 15; Edit Feature brings it back.
let mut app = app_with(json!([{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Box"}}]));
let mut d = start(&mut app, vec![], "surface.offset");
assert!(apply_commands(&app, &d).is_err(), "no faces yet");
d.inputs[0].items = vec![Sel::Face { body: "Box".into(), index: 0, point: Vec3::new(5.0, 5.0, 10.0) }];
if let Kind::Surface(crate::dialogs_surface::Surf::Offset { distance, chain }) = &mut d.kind {
*distance = "5 mm".into();
*chain = true;
}
let (id, p) = apply_commands(&app, &d).unwrap().remove(0);
assert_eq!(
(id.as_str(), &p["body"], &p["faces"], &p["chain"]),
("surface.offset", &json!("Box"), &json!([[5.0, 5.0, 10.0]]), &json!(true)),
"{p}"
);
app.session.execute(&id, &p).unwrap();
let m = app.session.model.state().bodies.last().unwrap().mesh().measure();
assert!((m.area - 100.0).abs() < 1e-6, "{}", m.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.len(), 1);
}
#[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
@@ -17,6 +17,7 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
FeatureKind::SurfaceRevolve { .. } => "surface_revolve",
FeatureKind::SurfaceSweep { .. } => "surface_sweep",
FeatureKind::SurfaceLoft { .. } => "surface_loft",
FeatureKind::SurfaceOffset { .. } => "surface_offset",
FeatureKind::Revolve { .. } => "revolve",
FeatureKind::Fillet { .. } => "fillet",
FeatureKind::Chamfer { .. } => "chamfer",
+3 -3
View File
@@ -1,6 +1,6 @@
# Features
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **341 commands.** Planned (registered, not built yet): 7. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **342 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
## Sketch (98)
@@ -145,7 +145,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Align | `solid.align` | SOLID › MODIFY |
| Remove | `solid.remove` | SOLID › MODIFY |
## Surfaces (11, 6 planned)
## Surfaces (12, 5 planned)
| Command | Id | Toolbar |
|---|---|---|
@@ -158,7 +158,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Surface Revolve | `surface.revolve` | SURFACE › CREATE |
| Surface Sweep | `surface.sweep` | SURFACE › CREATE |
| Surface Loft | `surface.loft` | SURFACE › CREATE |
| Offset Surface *(planned: [#758](https://github.com/bherbruck/solidcraft/issues/758))* | `surface.offset` | SURFACE › CREATE |
| Offset Surface | `surface.offset` | SURFACE › CREATE |
| Ruled Surface *(planned: [#762](https://github.com/bherbruck/solidcraft/issues/762))* | `surface.ruled` | SURFACE › CREATE |
| Boundary Surface *(planned: [#763](https://github.com/bherbruck/solidcraft/issues/763))* | `surface.boundary` | SURFACE › CREATE |
| Blend Surface *(planned: [#764](https://github.com/bherbruck/solidcraft/issues/764))* | `surface.blend` | SURFACE › CREATE |