diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 79bf49e4..f6df3fdf 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -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); diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index 037c582f..90ffd7d5 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -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, .. } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 5f26388c..a4a03a9f 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -987,6 +987,19 @@ pub enum FeatureKind { SurfaceLoft { sections: Vec, }, + /// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + distance: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + chain: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + tolerance: Option, + }, /// 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), diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 5af5dc54..4d2009f6 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3638,7 +3638,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, 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 { .. } diff --git a/crates/doc/src/eval_surface.rs b/crates/doc/src/eval_surface.rs index faf8a787..e633f3f2 100644 --- a/crates/doc/src/eval_surface.rs +++ b/crates/doc/src/eval_surface.rs @@ -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, 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 = 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())), } } diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 778a62f4..ebfe03b9 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -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; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index fcc1fdd5..9e63a2e3 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -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 { .. } diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index 31810cc0..88622ad1 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -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 { add_feature(s, p, FeatureKind::SurfaceLoft { sections }) } +fn offset(s: &mut Session, p: &Value) -> Result { + let cmd = "surface.offset"; + let faces: Vec = 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> { 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(); diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index c10460e1..4119156b 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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, diff --git a/crates/kernel/src/surface_offset.rs b/crates/kernel/src/surface_offset.rs new file mode 100644 index 00000000..9d941d23 --- /dev/null +++ b/crates/kernel/src/surface_offset.rs @@ -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, + 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.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 = body.solid.face_iter().cloned().collect(); + let mut picked: Vec = 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 = 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::>()?; + 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> = HashMap::new(); + let mut edges: HashMap> = 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 { + 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 { + 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 = (0..EDGE_SAMPLES) + .map(|k| map(from_p3(c.subs(t0 + (t1 - t0) * k as f64 / (EDGE_SAMPLES - 1) as f64)))) + .collect::>()?; + 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 { + 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 { + 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 { + 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 = 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> = + (0..=n).map(|i| (0..=n).map(|j| at(i as f64 / n as f64, j as f64 / n as f64)).collect::>>()).collect::>()?; + 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]) -> Option> { + let rows: Vec> = grid.iter().map(|r| crate::build::interpolate_cubic(r)).collect::>()?; + 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 = 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> = (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, u: (f64, f64), v: (f64, f64)) -> mt::BSplineSurface { + let stretch = |kv: &mt::KnotVec, (a, b): (f64, f64)| mt::KnotVec::from(kv.iter().map(|k| a + (b - a) * k).collect::>()); + 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::>().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(©).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 = (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:?}"); + } + } +} diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index 4810df11..0b21a619 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -536,7 +536,7 @@ pub fn remove_faces(body: &Body, faces: &[usize]) -> Result { } /// Index of the face nearest a point (over its mesh triangles). -fn nearest_face(body: &Body, p: Vec3) -> Result { +pub(crate) fn nearest_face(body: &Body, p: Vec3) -> Result { 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 { /// 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 { + pub(crate) fn of(f: &mt::Face) -> Option { 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 { +pub(crate) fn map_curve(off: &Offset, c: &mt::Curve, d: f64) -> Result { 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 { } /// 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 { +pub(crate) fn map_surface(off: &Offset, s: &mt::Surface, d: f64) -> Result { Ok(match s { mt::Surface::Plane(pl) => { let (o, a, b) = (pl.origin(), pl.u_axis(), pl.v_axis()); diff --git a/crates/ui-egui/src/dialogs_surface.rs b/crates/ui-egui/src/dialogs_surface.rs index 9d008fa6..7b14a25b 100644 --- a/crates/ui-egui/src/dialogs_surface.rs +++ b/crates/ui-egui/src/dialogs_surface.rs @@ -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 { 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)> { "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 { + 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 = if faces.is_empty() { vec![Sel::Body { name: body.clone() }] diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index 3c085023..cd5974a5 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -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 diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index fda5106c..65e8e601 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -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", diff --git a/docs/features.md b/docs/features.md index a781e064..2675dea4 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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 ''`, 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 ''`, 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 |