diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 02958115..c94afc87 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -13,6 +13,8 @@ use solvecraft_geom::Vec3; use truck_modeling::{self as mt, builder}; use crate::body::{Body, Solid, from_p3, p3, v3}; +use crate::history::{FaceOrigin, History}; +use crate::record::{self, Rebuild}; use crate::{KernelError, Result, guard}; #[derive(Clone, Copy, PartialEq)] @@ -89,7 +91,8 @@ fn rebuild_face_dropping(f: &mt::Face, subst: &HashMap, dr crate::heal::absolute_face(f, wires).ok_or_else(|| KernelError::Failed("blend: a face could not be rebuilt".into())) } -fn blend_one(solid: &Solid, edge: &mt::Edge, size: f64, r: f64, shape: Shape) -> Result { +/// Blend one edge of `solid`, with what was built from it (see [`Rebuild`]). +fn blend_one(solid: &Solid, edge: &mt::Edge, size: f64, r: f64, shape: Shape) -> Result<(Solid, Rebuild)> { if !matches!(edge.curve(), mt::Curve::Line(_)) { return Err(unsupported("only straight edges can be blended")); } @@ -203,7 +206,7 @@ fn blend_geometry( size: f64, r: f64, shape: Shape, -) -> Result { +) -> Result<(Solid, Rebuild)> { let s = setback(r, phi, shape); let tol = (size * 1e-7).max(1e-9); // End faces and the side edges at each end. A side edge that the fillet's setback uses up @@ -351,6 +354,9 @@ fn blend_geometry( subst.insert(e.id(), ne); } } + // The side edges at the ends are pieces of themselves; the rails and corner curves are new. + let mut rec = Rebuild::default(); + rec.pieces(&subst); let mut new_faces: Vec = Vec::with_capacity(faces.len() + 1); for (i, f) in faces.iter().enumerate() { if (i == i1 && rail_a.is_some()) || (i == i2 && rail_b.is_some()) { @@ -371,6 +377,7 @@ fn blend_geometry( } else { (*f).clone() }; + rec.face(&nf, f); new_faces.push(nf); } // The blend face uses each new edge opposite to its neighbour. @@ -399,13 +406,15 @@ fn blend_geometry( mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))? } }; + rec.blend(&blend_face, [edge.id()]); new_faces.push(blend_face); let mut shells = solid.boundaries().clone(); let new_shell: mt::Shell = new_faces.into(); if let Some(sh) = shells.get_mut(si) { *sh = new_shell; } - Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("blend produced an invalid solid: {e}"))) + let solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("blend produced an invalid solid: {e}")))?; + Ok((solid, rec)) } fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result { @@ -418,8 +427,10 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul if edges.len() > 1000 { return Err(KernelError::Invalid("too many edges".into())); } + // Each step's result carries its history over `[body]`. + let counts = record::counts(&[body]); // Coplanar neighbours as one face (as after a join), so corners look as they should. - let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?; + let mut cur = crate::boolean_record::healed(body)?; // Every edge of a convex planar body: rounded as a whole (sphere corners). if shape == Shape::Round && edges.len() >= 6 { let solid = cur.deep_copy(); @@ -436,7 +447,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul if hit.len() == all.len() && let Ok(b) = crate::polyhedron::round_all_edges(&cur, r) { - return Ok(b); + return Ok(record::after(b, &cur, &counts)); } } } @@ -449,8 +460,8 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul { let _ = groups; let loop_pts: Vec = edges.iter().filter(|p| !rest.iter().any(|q| q.dist(**p) < 1e-12)).copied().collect(); - cur = blend(&cur, &rest, r, shape, what)?; - return blend(&cur, &loop_pts, r, shape, what); + cur = record::after(blend(&cur, &rest, r, shape, what)?, &cur, &counts); + return Ok(record::after(blend(&cur, &loop_pts, r, shape, what)?, &cur, &counts)); } if let Some(groups) = loop_groups(&cur, edges)? { for g in groups { @@ -458,7 +469,10 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul let solid = cur.deep_copy(); let ids = edge_ids(&cur, &solid, &g)?; match crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round) { - Some(res) => cur = Body::new(guard(what, || res)?)?, + Some(res) => { + let (made, rec) = guard(what, || res)?; + cur = record::after(record::rebuilt(made, &[&solid], &rec)?, &cur, &counts); + } None => return Err(unsupported("the edges are not a whole loop of a planar face")), } } @@ -477,10 +491,10 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul let solid = cur.deep_copy(); if todo.len() >= 2 && let Some((ids, covered)) = run_on_loop(&cur, &solid, &todo)? - && let Some(Ok(res)) = crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round) - && let Ok(b) = guard(what, || Ok(res)).and_then(Body::new) + && let Some(Ok((res, rec))) = crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round) + && let Ok(b) = guard(what, || Ok(res)).and_then(|res| record::rebuilt(res, &[&solid], &rec)) { - cur = b; + cur = record::after(b, &cur, &counts); todo.retain(|p| !covered.iter().any(|q| q.dist(*p) < 1e-12)); continue; } @@ -517,7 +531,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul None => Err(e), }, }; - match attempt.and_then(|(o, js)| Body::new(o).map(|b| (b, js))) { + match attempt.and_then(|((o, rec), js)| record::rebuilt(o, &[&solid], &rec).map(|b| (b, js))) { Ok(x) => { done = Some(x); break; @@ -531,7 +545,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul } match done { Some((b, js)) => { - cur = b; + cur = record::after(b, &cur, &counts); let mut k = 0; todo.retain(|_| { let keep = !js.contains(&k); @@ -810,15 +824,21 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { if edges.is_empty() || edges.len() > 1000 { return Err(KernelError::Invalid("select 1…1000 edges".into())); } + // Each step's result carries its history over `[body]`. + let counts = record::counts(&[body]); // All cutters at once (their union mitres the corners), else one edge at a time. - let tools: Result> = edges.iter().enumerate().map(|(i, p)| chamfer_tool(body, *p, distance, i)).collect(); + let tools: Result> = edges.iter().enumerate().map(|(i, p)| chamfer_tool(body, *p, distance, i)).collect(); if let Ok(tools) = tools { - let mut acc: Option = None; + // The union so far, with its history over the cutters it joined. + let mut acc: Option<(Body, Option)> = None; let mut ok = true; - for t in tools { + for t in &tools { let next = match &acc { - None => Some(t), - Some(a) => crate::ops::boolean(a, &t, crate::BoolOp::Union).ok().flatten(), + None => Some((t.body.clone(), Some(History::identity(&t.body)))), + Some((a, h)) => crate::ops::boolean(a, &t.body, crate::BoolOp::Union).ok().flatten().map(|u| { + let h = crate::boolean_record::through(&u, 0, h.as_ref()); + (u, h) + }), }; if next.is_none() { ok = false; @@ -827,10 +847,11 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { acc = next; } if ok - && let Some(tool) = acc + && let Some((tool, h)) = acc && let Ok(Some(r)) = crate::ops::boolean(body, &tool, crate::BoolOp::Cut) { - return Ok(r); + let h = cut_history(crate::boolean_record::through(&r, 1, h.as_ref()), body, &tools); + return Ok(r.with_checked_history(h, &counts)); } } // At a pocket rim the cutters cannot extend beyond the selected edges. Rebuild @@ -842,28 +863,49 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { return Ok(result); } // Coplanar neighbours as one face (as after a join), so corners look as they should. - let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?; + let mut cur = crate::boolean_record::healed(body)?; if let Some(groups) = loop_groups(&cur, edges)? { for g in groups { let size = cur.size(); let solid = cur.deep_copy(); let ids = edge_ids(&cur, &solid, &g)?; match crate::loopblend::loop_blend(&solid, &ids, size, distance, false) { - Some(res) => cur = Body::new(guard("chamfer", || res)?)?, + Some(res) => { + let (made, rec) = guard("chamfer", || res)?; + cur = record::after(record::rebuilt(made, &[&solid], &rec)?, &cur, &counts); + } None => return Err(unsupported("the edges are not a whole loop of a planar face")), } } return Ok(cur); } for p in edges { - cur = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| crate::ops::boolean(&cur, &t, crate::BoolOp::Cut)) { + let step = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| cut_one(&cur, &t)) { Ok(Some(b)) => b, _ => blend(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer")?, }; + cur = record::after(step, &cur, &counts); } Ok(cur) } +/// `cur` cut by one cutter, with its history over `[cur]` (`None`: nothing left). +fn cut_one(cur: &Body, t: &Cutter) -> Result> { + let counts = record::counts(&[cur]); + Ok(crate::ops::boolean(cur, &t.body, crate::BoolOp::Cut)?.map(|r| { + let h = cut_history(r.history().cloned(), cur, std::slice::from_ref(t)); + r.with_checked_history(h, &counts) + })) +} + +/// A cut of `cur` by cutters (their union), from its history `h` over `[cur, cutter 0, …]`, as +/// over `[cur]`: each cutter's chamfer face is made in place of its edge, and nothing else of a +/// cutter may be left (`None` where something is). +fn cut_history(h: Option, cur: &Body, tools: &[Cutter]) -> Option { + let parts: Vec = std::iter::once(History::identity(cur)).chain(tools.iter().map(Cutter::history)).collect(); + crate::boolean_record::composed(h?.over(&parts.iter().collect::>())) +} + /// Geometry of a straight convex edge between two planar faces. struct EdgeFrame { p0: Vec3, @@ -875,7 +917,8 @@ struct EdgeFrame { t2: Vec3, } -fn edge_frame(cur: &Body, p: Vec3) -> Result { +/// The edge nearest `p` (its index in `cur`'s edge order) and its frame. +fn edge_frame(cur: &Body, p: Vec3) -> Result<(usize, EdgeFrame)> { let size = cur.size(); let tol = (size * 2e-3).max(1e-3); let Some((idx, dist)) = cur.nearest_edge(p, tol)? else { return Err(KernelError::Invalid("the body has no edges".into())) }; @@ -903,7 +946,7 @@ fn edge_frame(cur: &Body, p: Vec3) -> Result { if n1.dot(t2) > 1e-9 || n2.dot(t1) > 1e-9 || t1.dot(t2) < -1.0 + 1e-6 { return Err(unsupported("only convex edges")); } - Ok(EdgeFrame { p0, p1, d, n1, n2, t1, t2 }) + Ok((idx, EdgeFrame { p0, p1, d, n1, n2, t1, t2 })) } /// The prism that cuts a chamfer of size `s` on the edge nearest `p`. `k` varies the parts that @@ -933,20 +976,41 @@ pub fn chamfer_sides(body: &Body, edges: &[Vec3], d1: f64, side: ChamferSide, fl if edges.is_empty() || edges.len() > 1000 { return Err(KernelError::Invalid("select 1…1000 edges".into())); } - let mut cur = body.clone(); + let counts = record::counts(&[body]); + let mut cur = crate::boolean_record::operand(body, 0, &counts); for (k, p) in edges.iter().enumerate() { let t = chamfer_tool_sides(&cur, *p, d1, Some((side, flip)), k)?; - cur = crate::ops::boolean(&cur, &t, crate::BoolOp::Cut)?.ok_or_else(|| KernelError::Failed("the chamfer removed everything".into()))?; + let step = cut_one(&cur, &t)?.ok_or_else(|| KernelError::Failed("the chamfer removed everything".into()))?; + cur = record::after(step, &cur, &counts); } Ok(cur) } -pub(crate) fn chamfer_tool(cur: &Body, p: Vec3, s: f64, k: usize) -> Result { +/// The prism that cuts a chamfer: the body, which of its faces is the chamfer plane, and the +/// edge (of the body it chamfers, in its edge order) that face is made in place of. +pub(crate) struct Cutter { + body: Body, + face: Option, + edge: usize, +} + +impl Cutter { + /// The cutter as made from the body it chamfers: its chamfer face in place of the edge, its + /// other faces (all outside that body) from nothing of it. + fn history(&self) -> History { + let faces = (0..self.body.face_count()) + .map(|i| record::generated(if Some(i) == self.face { vec![FaceOrigin::Edge { operand: 0, edge: self.edge }] } else { Vec::new() })) + .collect(); + record::from_faces(&self.body.solid, 1, faces) + } +} + +pub(crate) fn chamfer_tool(cur: &Body, p: Vec3, s: f64, k: usize) -> Result { chamfer_tool_sides(cur, p, s, None, k) } -fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, bool)>, k: usize) -> Result { - let mut f = edge_frame(cur, p)?; +fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, bool)>, k: usize) -> Result { + let (edge, mut f) = edge_frame(cur, p)?; // Setbacks along each face: equal, or by the second side's rule. let (s1, s2) = match sides { None => (s, s), @@ -997,11 +1061,16 @@ fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, b } let loc: Vec = [a_ext, b_ext, e_far].iter().map(|q| plane.to_local(*q)).collect(); let mut lp = solvecraft_geom::Loop2::polygon(&loc); + // The chamfer plane is swept from the segment a → b: first, or last once turned round. + let mut chamfer_segment = 0; if lp.signed_area() < 0.0 { lp = lp.reversed(); + chamfer_segment = lp.segs.len().saturating_sub(1); } let region = solvecraft_geom::Region2 { outer: lp, holes: vec![] }; - crate::build::extrude(&plane, &[region], 0.0, len + 2.0 * ext)?.pop().ok_or_else(|| KernelError::Failed("chamfer tool".into())) + let body = crate::build::extrude(&plane, &[region], 0.0, len + 2.0 * ext)?.pop().ok_or_else(|| KernelError::Failed("chamfer tool".into()))?; + let face = body.history().and_then(|h| h.faces.iter().position(|f| f.origins == [FaceOrigin::Profile { segment: chamfer_segment }])); + Ok(Cutter { body, face, edge }) } #[cfg(test)] diff --git a/crates/kernel/src/blend_corner.rs b/crates/kernel/src/blend_corner.rs index 92d0ade6..0180f2cb 100644 --- a/crates/kernel/src/blend_corner.rs +++ b/crates/kernel/src/blend_corner.rs @@ -33,13 +33,15 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result { if edges.iter().any(|(p, r)| !(p.is_finite() && r.is_finite() && *r > 1e-6 && *r < 1e6)) { return Err(KernelError::Invalid("fillet size must be positive".into())); } - let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?; + // Each step's result carries its history over `[body]`. + let counts = crate::record::counts(&[body]); + let mut cur = crate::boolean_record::healed(body)?; let mut sel = edges.to_vec(); let mut first: Option = None; // Earlier rounds at the edges' ends come off and are blended again with the new edges. for _ in 0..4 { let err = match build(&cur, &sel, true) { - Ok(b) => return Ok(b), + Ok(b) => return Ok(crate::record::after(b, &cur, &counts)), Err(e) => e, }; let rounds = rounds_at(&cur, &sel); @@ -48,7 +50,7 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result { } first.get_or_insert(err); let idx: Vec = rounds.iter().map(|r| r.0).collect(); - cur = crate::delete_faces(&cur, &idx)?; + cur = crate::record::after(crate::delete_faces(&cur, &idx)?, &cur, &counts); sel.extend(rounds.iter().map(|r| (r.1, r.2))); } Err(first.unwrap_or_else(|| unsupported("these fillets"))) @@ -57,9 +59,9 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result { /// Local equal-distance chamfers, including pocket rims where adjacent walls have /// different slopes. The planar strips trim each other at each selected corner. pub(crate) fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { - let cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?; + let cur = crate::boolean_record::healed(body)?; let selected: Vec<_> = edges.iter().map(|p| (*p, distance)).collect(); - build(&cur, &selected, false) + Ok(crate::record::after(build(&cur, &selected, false)?, &cur, &crate::record::counts(&[body]))) } /// A face of the shell being rebuilt, or a new face. @@ -311,7 +313,8 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result { // New end points: (edge, vertex, face for a filleted edge's contact line). let mut endpt: HashMap<(mt::EdgeID, mt::VertexID, Option), mt::Vertex> = HashMap::new(); let mut pool: HashMap)>> = HashMap::new(); - let mut patches: Vec = Vec::new(); + // Corner patches, with the edges meeting at their corner. + let mut patches: Vec<(mt::Face, [mt::EdgeID; 3])> = Vec::new(); let face_with = |a: mt::EdgeID, b: mt::EdgeID| -> Result { let fa = on_edge.get(&a).cloned().unwrap_or_default(); let fb = on_edge.get(&b).cloned().unwrap_or_default(); @@ -440,7 +443,8 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result { let c3 = edge(&v23, &v31, mt::Curve::NurbsCurve(t3))?; let surf = cp.surf; let wire: mt::Wire = vec![c2.clone(), c3.clone(), c1.inverse()].into(); - patches.push(mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("corner patch: {e}")))?); + patches + .push((mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("corner patch: {e}")))?, [*e1, *e2, *e3])); list.push((c1, vec![Owner::Cyl(*e1), Owner::Patch])); list.push((c2, vec![Owner::Cyl(*e2), Owner::Patch])); list.push((c3, vec![Owner::Cyl(*e3), Owner::Patch])); @@ -468,6 +472,7 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result { Ok(x) }; let moved = |e: &mt::Edge| touched.contains_key(&e.absolute_front().id()) || touched.contains_key(&e.absolute_back().id()); + let mut rec = crate::record::Rebuild::default(); let mut new_faces: Vec = Vec::with_capacity(faces.len() + sels.len() + patches.len()); for (fi, f) in faces.iter().enumerate() { if !f.vertex_iter().any(|v| touched.contains_key(&v.id())) { @@ -482,7 +487,10 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result { let ne = if sels.contains_key(&e.id()) { line_of(e, Some(fi))? } else if moved(e) { - line_of(e, None)? + // The edge itself, shortened at the corner. + let piece = line_of(e, None)?; + rec.piece(&piece, e); + piece } else { news.push(e.clone()); continue; @@ -501,7 +509,9 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result { } wires.push(mt::Wire::from(out)); } - new_faces.push(mt::Face::try_new(wires, f.oriented_surface()).map_err(|e| KernelError::Failed(format!("fillet: face: {e}")))?); + let rebuilt = mt::Face::try_new(wires, f.oriented_surface()).map_err(|e| KernelError::Failed(format!("fillet: face: {e}")))?; + rec.face(&rebuilt, f); + new_faces.push(rebuilt); } // The blends: back along the first face's contact line, round the corner, along the // second's, round the other corner. @@ -518,10 +528,13 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result { w.extend(connect(first.back(), second.front(), p1, Owner::Cyl(*id))?); w.push(second.clone()); w.extend(connect(second.back(), first.front(), p2, Owner::Cyl(*id))?); - new_faces.push(mt::Face::try_new(vec![w.into()], s.surf.clone()).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?); + let blend = mt::Face::try_new(vec![w.into()], s.surf.clone()).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?; + rec.blend(&blend, [*id]); + new_faces.push(blend); } - // Corner patches run each section opposite to its blend. - for mut p in patches { + // Corner patches run each section opposite to its blend; each is made in place of the + // edges meeting at its corner. + for (mut p, corner) in patches { let uses = |f: &mt::Face, id: mt::EdgeID| { f.boundaries().iter().flat_map(|w| w.edge_iter().cloned().collect::>()).find(|x| x.id() == id).map(|x| x.front().id()) }; @@ -535,14 +548,15 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result { let wires: Vec = p.boundaries().iter().map(|w| w.inverse()).collect(); p = mt::Face::try_new(wires, surf).map_err(|e| KernelError::Failed(format!("corner patch: {e}")))?; } + rec.blend(&p, corner); new_faces.push(p); } let mut shells = solid.boundaries().clone(); if let Some(sh) = shells.get_mut(si) { *sh = new_faces.into(); } - let out = Body::new(Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("fillet produced an invalid solid: {e}")))?)?; - Ok(out) + let out = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("fillet produced an invalid solid: {e}")))?; + crate::record::rebuilt(out, &[&solid], &rec) }) } diff --git a/crates/kernel/src/blend_variable.rs b/crates/kernel/src/blend_variable.rs index 3874a69d..02c5aa44 100644 --- a/crates/kernel/src/blend_variable.rs +++ b/crates/kernel/src/blend_variable.rs @@ -229,6 +229,9 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: & subst.insert(e.id(), ne); } } + // The side edges at the ends are pieces of themselves; the contacts and end arcs are new. + let mut rec = crate::record::Rebuild::default(); + rec.pieces(&subst); let mut new_faces: Vec = Vec::with_capacity(faces.len() + 1); for (i, f) in faces.iter().enumerate() { let nf = if i == fr.i1 { @@ -246,6 +249,7 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: & } else { (*f).clone() }; + rec.face(&nf, f); new_faces.push(nf); } // The blend surface: rational quadratic arcs (tangent points and the edge point, middle @@ -279,12 +283,14 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: & vec![la.clone(), c1.clone(), lb.inverse(), c0.inverse()].into() }; let blend = mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?; + rec.blend(&blend, [edge.id()]); new_faces.push(blend); let mut shells = solid.boundaries().clone(); if let Some(sh) = shells.get_mut(si) { *sh = new_faces.into(); } - Body::new(Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("variable fillet produced an invalid solid: {e}")))?) + let out = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("variable fillet produced an invalid solid: {e}")))?; + crate::record::rebuilt(out, &[solid], &rec) }) } @@ -312,9 +318,11 @@ pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result { None => groups.push((r, vec![*p])), } } - let mut cur = body.clone(); + // Each step's result carries its history over `[body]`. + let counts = crate::record::counts(&[body]); + let mut cur = crate::boolean_record::operand(body, 0, &counts); for (r, pts) in groups { - cur = crate::fillet(&cur, &pts, r)?; + cur = crate::record::after(crate::fillet(&cur, &pts, r)?, &cur, &counts); } Ok(cur) } diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index c24450ac..f35b5e34 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -480,6 +480,12 @@ impl Body { self } + /// This body with no history: an operation whose history is not known. + pub(crate) fn without_history(mut self) -> Self { + self.history = None; + self + } + pub fn face_sources(&self) -> Option<&[Option]> { self.face_sources.as_deref().map(Vec::as_slice) } diff --git a/crates/kernel/src/boolean_history_tests.rs b/crates/kernel/src/boolean_history_tests.rs index 958328e1..390eb2d4 100644 --- a/crates/kernel/src/boolean_history_tests.rs +++ b/crates/kernel/src/boolean_history_tests.rs @@ -48,7 +48,7 @@ fn closest_on_triangle(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 { } /// Distance from `p` to face `f` of a mesh. -fn to_face(m: &Mesh, f: usize, p: Vec3) -> f64 { +pub(crate) fn to_face(m: &Mesh, f: usize, p: Vec3) -> f64 { m.triangles .iter() .zip(&m.tri_face) @@ -61,34 +61,51 @@ fn to_face(m: &Mesh, f: usize, p: Vec3) -> f64 { } /// Distance from `p` to edge `e` of a mesh. -fn to_edge(m: &Mesh, e: usize, p: Vec3) -> f64 { +pub(crate) fn to_edge(m: &Mesh, e: usize, p: Vec3) -> f64 { m.edges[e].windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min) } +/// How far, in a check, a face or edge may lie from what it came from. +#[derive(Clone, Copy)] +pub(crate) struct Reach { + /// A face made in place of an edge, or an offset of a face, lies within this of it. + pub made: f64, + /// A face or edge the operation moved lies within this of the one it came from. + pub moved: f64, +} + /// The result's history, checked against it and its operands, with every origin checked /// against the geometry: each face's triangles lie on its origin faces, each origin face holds /// some of them; each edge lies along its origin edges, or on both faces of a meeting. `tol`: /// the meshes' tolerance. fn verified<'a>(r: &'a Body, operands: &[&Body], tol: f64) -> &'a History { - let h = r.history().expect("the boolean reports its history"); + checked(r, operands, tol, Reach { made: 0.0, moved: 0.0 }, &|_| false) +} + +/// [`verified`] for any operation: a face lies on its origin faces (within `at.moved` where the +/// operation moves faces), within `at.made` of the edges it is made in place of and of the faces +/// it is an offset of; an edge lies along its origin edges (within `at.moved`). Faces from +/// nothing are allowed only where `generated` says. +pub(crate) fn checked<'a>(r: &'a Body, operands: &[&Body], tol: f64, at: Reach, generated: &dyn Fn(usize) -> bool) -> &'a History { + let h = r.history().expect("the operation reports its history"); h.check(r, operands).unwrap(); let rm = r.tessellate(tol).unwrap(); let ms: Vec = operands.iter().map(|b| b.tessellate(tol).unwrap()).collect(); let near = 4.0 * tol + r.size() * 1e-6; let on = |o: &FaceOrigin, p: Vec3| match *o { - FaceOrigin::Face { operand, face } => to_face(&ms[operand], face, p) < near, + FaceOrigin::Face { operand, face } => to_face(&ms[operand], face, p) < near.max(at.moved), + FaceOrigin::Edge { operand, edge } => to_edge(&ms[operand], edge, p) < at.made + near, + FaceOrigin::Offset { operand, face } => to_face(&ms[operand], face, p) < at.made + near, _ => false, }; + let along = |o: &EdgeOrigin, p: &Vec3| match *o { + EdgeOrigin::Edge { operand, edge } => to_edge(&ms[operand], edge, *p) < near.max(at.moved), + EdgeOrigin::Meet { a, b } => on(&a, *p) && on(&b, *p), + }; for (i, f) in h.faces.iter().enumerate() { - assert!(!f.origins.is_empty(), "face {i} has no origin"); - let centres: Vec = rm - .triangles - .iter() - .zip(&rm.tri_face) - .filter(|(_, x)| **x as usize == i) - .map(|(t, _)| t.map(|k| rm.positions[k as usize]).iter().fold(Vec3::ZERO, |s, p| s + *p) * (1.0 / 3.0)) - .collect(); - for c in ¢res { + assert!(!f.origins.is_empty() || generated(i), "face {i} has no origin"); + let centres = centres(&rm, i); + for c in centres.iter().filter(|_| !f.origins.is_empty()) { assert!(f.origins.iter().any(|o| on(o, *c)), "face {i} ({f:?}) at {c:?} lies on none of its origins"); } for o in &f.origins { @@ -98,33 +115,33 @@ fn verified<'a>(r: &'a Body, operands: &[&Body], tol: f64) -> &'a History { for (j, e) in h.edges.iter().enumerate() { let pts = &rm.edges[j]; for o in &e.origins { - let ok = |p: &Vec3| match *o { - EdgeOrigin::Edge { operand, edge } => to_edge(&ms[operand], edge, *p) < near, - EdgeOrigin::Meet { a, b } => on(&a, *p) && on(&b, *p), - }; match o { // A joined edge runs along all of its origins, each along part of it. - EdgeOrigin::Edge { .. } if e.origins.len() > 1 => assert!(pts.iter().any(ok), "edge {j}: {o:?} is not along it"), - _ => assert!(pts.iter().all(ok), "edge {j} ({e:?}) does not lie along {o:?}"), + EdgeOrigin::Edge { .. } if e.origins.len() > 1 => assert!(pts.iter().any(|p| along(o, p)), "edge {j}: {o:?} is not along it"), + _ => assert!(pts.iter().all(|p| along(o, p)), "edge {j} ({e:?}) does not lie along {o:?}"), } } if e.origins.len() > 1 { for p in pts { - assert!( - e.origins.iter().any(|o| match *o { - EdgeOrigin::Edge { operand, edge } => to_edge(&ms[operand], edge, *p) < near, - EdgeOrigin::Meet { a, b } => on(&a, *p) && on(&b, *p), - }), - "edge {j}: {p:?} lies along none of {e:?}" - ); + assert!(e.origins.iter().any(|o| along(o, p)), "edge {j}: {p:?} lies along none of {e:?}"); } } } h } +/// The centres of face `f`'s triangles. +pub(crate) fn centres(m: &Mesh, f: usize) -> Vec { + m.triangles + .iter() + .zip(&m.tri_face) + .filter(|(_, x)| **x as usize == f) + .map(|(t, _)| t.map(|k| m.positions[k as usize]).iter().fold(Vec3::ZERO, |s, p| s + *p) * (1.0 / 3.0)) + .collect() +} + /// How many faces of each change. -fn changes(h: &History) -> [usize; 5] { +pub(crate) fn changes(h: &History) -> [usize; 5] { let mut n = [0; 5]; for f in &h.faces { n[match f.change { diff --git a/crates/kernel/src/boolean_record.rs b/crates/kernel/src/boolean_record.rs index d1ca1301..6d78375f 100644 --- a/crates/kernel/src/boolean_record.rs +++ b/crates/kernel/src/boolean_record.rs @@ -148,6 +148,20 @@ pub(crate) fn heal_body(body: &Body, solid: Solid, size: f64, keep: &[Vec] heal_build(solid, size, keep, faces, edges, counts) } +/// `body` healed as the local modifiers take it (coplanar neighbours as one face, as after a +/// join; faces split on purpose kept apart), with its history over `[body]`. +pub(crate) fn healed(body: &Body) -> Result { + let counts = record::counts(&[body]); + heal_body(&operand(body, 0, &counts), body.deep_copy(), body.size(), body.split_keep(), &counts) +} + +/// `body` copied as it is, unhealed (`Body::deep_copy`, which keeps its face and edge order), +/// with its history over `[body]`: every face and edge kept. +pub(crate) fn copied(body: &Body) -> Result { + let copy = Body::new(body.deep_copy())?; + Ok(operand(©, 0, &record::counts(&[body]))) +} + /// The faces and edges of `a`, then of the tool `b` turned inside out as a void (`inner`, a /// copy of `b`, with `inner_edges` its edges by identity before turning): the history of /// `a` with `b`'s inside taken out where `b` lies wholly within it. @@ -167,4 +181,4 @@ pub(crate) fn void( #[cfg(test)] #[path = "boolean_history_tests.rs"] -mod tests; +pub(crate) mod tests; diff --git a/crates/kernel/src/coplanar.rs b/crates/kernel/src/coplanar.rs index 8064336c..e91feaa8 100644 --- a/crates/kernel/src/coplanar.rs +++ b/crates/kernel/src/coplanar.rs @@ -43,7 +43,7 @@ fn planes(b: &Body) -> Vec<(usize, PlaneFace)> { /// Push the planar faces of `b` lying in the plane (n, d) — n their outward normal — along n /// by `delta` (negative: inward). Their neighbours must stand perpendicular to the plane. The -/// result's history is over `[b]` (none where the faces had to be offset instead). +/// result's history is over `[b]`. pub(crate) fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result { // The exact rebuild, unless it came out unsound (a curved side it couldn't re-sweep). match push_faces_exact(b, n, d, delta) @@ -66,7 +66,6 @@ pub(crate) fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result _ => false, }) .collect(); - // An offset reports no history yet. crate::offset::offset_body(b, |i, _| if planes.get(i).copied().unwrap_or(false) { delta } else { 0.0 }).map_err(|_| e) } } @@ -539,6 +538,29 @@ fn same_cylinder(x: &CylFace, y: &CylFace, tol: f64) -> bool { x.axis.dot(y.axis).abs() > 1.0 - 1e-9 && (x.radius - y.radius).abs() < tol && (off - x.axis * off.dot(x.axis)).len() < tol } +/// The history over `[a, b]` of `r`, made in [`bore_union`]: `joined` (the target joined with +/// `narrowed`, the tool with its bore narrowed) less `cut`, the cylinder whose wall restores the +/// tool's bore faces `bore` (the tool is operand `tool_operand`). Nothing of the cylinder but its +/// wall may be left. +fn recut(r: &Body, joined: &Body, narrowed: &Body, cut: &Body, bore: &[usize], tool_operand: usize) -> Option { + // Over [target, tool]. + let joined = crate::boolean_record::through(joined, 1, narrowed.history())?; + let wall = || record::region(bore.iter().map(|&face| FaceOrigin::Face { operand: 1, face }).collect(), false); + let faces = cut + .history()? + .faces + .iter() + .map(|f| match f.origins.first() { + Some(FaceOrigin::Profile { .. }) => wall(), + _ => record::generated(Vec::new()), + }) + .collect(); + let cut = record::from_faces(&cut.solid, 2, faces); + let h = crate::boolean_record::of_pair(r, &Some(joined), &Some(cut))?; + let map = if tool_operand == 1 { [Some(0), Some(1)] } else { [Some(1), Some(0)] }; + crate::boolean_record::composed(h.renumbered(&map, 2)) +} + /// Do the bodies have cylindrical faces on a common cylinder? pub(crate) fn has_coincident_cylinders(a: &Body, b: &Body) -> bool { let tol = (a.size().max(b.size()) * 1e-7).max(1e-9) * 10.0; @@ -554,7 +576,8 @@ pub(crate) fn has_coincident_cylinders(a: &Body, b: &Body) -> bool { /// samples are plausibility checks; a whole-boundary guard rejects additional boundaries /// crossing the recut. Original common-bore trims retain the legacy eligibility policy. /// This is not a general emptiness or Boolean-equivalence certificate. Each direction -/// returns a result only when `accept` takes it; otherwise this returns `None`. +/// returns a result only when `accept` takes it; otherwise this returns `None`. The result's +/// history is over `[a, b]`. pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) -> Option { let size = a.size().max(b.size()); let tol = (size * 1e-7).max(1e-9) * 10.0; @@ -563,9 +586,10 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) -> let (ia, ib) = (ma.inside_index(), mb.inside_index()); let empty = |p: Vec3| !ia.contains(p) && !ib.contains(p); let (ca, cb) = (cylinders(a), cylinders(b)); - // (body to narrow, its mesh and inside test, the other body, their faces on the cylinder). - let ways = [(b, &mb, &ib, a, &cb, &ca), (a, &ma, &ia, b, &ca, &cb)]; - for (tool, mesh, inside, target, ct, co) in ways { + // (body to narrow, its operand number, its mesh and inside test, the other body, their faces + // on the cylinder). + let ways = [(b, 1, &mb, &ib, a, &cb, &ca), (a, 0, &ma, &ia, b, &ca, &cb)]; + for (tool, tool_operand, mesh, inside, target, ct, co) in ways { for c in ct { if !co.iter().any(|o| same_cylinder(c, o, tol)) { continue; @@ -644,7 +668,9 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) -> } let Some(joined) = crate::ops::boolean(target, &narrowed, BoolOp::Union)? else { return Ok(None) }; let cut = crate::build::cylinder(at(lo, 0.0, 0.0), c.axis, c.radius, hi - lo)?; - crate::ops::boolean(&joined, &cut, BoolOp::Cut) + let Some(r) = crate::ops::boolean(&joined, &cut, BoolOp::Cut)? else { return Ok(None) }; + let h = recut(&r, &joined, &narrowed, &cut, &bore, tool_operand); + Ok(Some(r.with_checked_history(h, &record::counts(&[a, b])))) }; let (result, diagnostics) = crate::capture_boolean_diagnostics(run); match result { diff --git a/crates/kernel/src/curveblend.rs b/crates/kernel/src/curveblend.rs index a5ebf5ac..332ec3fc 100644 --- a/crates/kernel/src/curveblend.rs +++ b/crates/kernel/src/curveblend.rs @@ -12,6 +12,7 @@ use solvecraft_geom::Vec3; use truck_modeling::{self as mt, builder}; use crate::body::{Solid, from_p3, p3}; +use crate::record::Rebuild; use crate::{KernelError, Result}; fn unsupported(msg: &str) -> KernelError { @@ -316,12 +317,13 @@ fn trim_curved_seam(seam: &mt::Edge, a: Option, b: Option Result { +/// Blend the closed chain of edges (ids in any order) with radius `r`; with what it built from +/// `solid` (each blend face in place of its chain edge). +pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<(Solid, Rebuild)> { curve_blend_with_limit(solid, ids, size, r, round, MAX_SAMPLES) } -fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, max_samples: usize) -> Result { +fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, max_samples: usize) -> Result<(Solid, Rebuild)> { if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) { return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into())); } @@ -696,6 +698,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, seam_subst.insert(id, builder::line(&a, &b)); } let mut out: Vec = Vec::new(); + let mut rec = Rebuild::default(); + rec.pieces(&seam_subst); for (fi, f) in faces.iter().enumerate() { let corners: Vec = end_faces.iter().filter(|(_, g)| *g == fi).map(|(k, _)| *k).collect(); if !corners.is_empty() { @@ -734,7 +738,9 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } wires.push(mt::Wire::from(es)); } - out.push(crate::heal::absolute_face(f, wires).ok_or_else(|| fail("an end face could not be rebuilt"))?); + let rebuilt = crate::heal::absolute_face(f, wires).ok_or_else(|| fail("an end face could not be rebuilt"))?; + rec.face(&rebuilt, f); + out.push(rebuilt); continue; } let on1 = side1.contains(&fi); @@ -761,7 +767,9 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } wires.push(mt::Wire::from(es)); } - out.push(crate::heal::absolute_face(f, wires).ok_or_else(|| fail("a side face could not be rebuilt"))?); + let rebuilt = crate::heal::absolute_face(f, wires).ok_or_else(|| fail("a side face could not be rebuilt"))?; + rec.face(&rebuilt, f); + out.push(rebuilt); } // Blend faces: each runs its side-1 contact the other way from side 1's face. for (k, bf) in blends.iter().enumerate() { @@ -785,13 +793,14 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, vec![e1.clone(), ak1.clone(), e2.inverse(), ak.inverse()].into() }; let face = mt::Face::try_new(vec![wire], bf.clone()).map_err(|e| fail(&e.to_string()))?; + rec.blend(&face, [chain_e.id()]); out.push(face); } let mut shells = solid.boundaries().clone(); if let Some(sh) = shells.get_mut(si) { *sh = out.into(); } - Solid::try_new(shells).map_err(|e| fail(&format!("invalid solid: {e}"))) + Ok((Solid::try_new(shells).map_err(|e| fail(&format!("invalid solid: {e}")))?, rec)) }) } diff --git a/crates/kernel/src/delete_face.rs b/crates/kernel/src/delete_face.rs index 1accf995..9620fda1 100644 --- a/crates/kernel/src/delete_face.rs +++ b/crates/kernel/src/delete_face.rs @@ -55,7 +55,7 @@ fn key(p: Vec3, q: f64) -> Key { } /// Delete faces (indices in the body's face order, as in its mesh's `tri_face`) and heal the -/// gap by extending the neighbouring planes. +/// gap by extending the neighbouring planes. The result's history is over `[body]`. pub fn delete_faces(body: &Body, faces: &[usize]) -> Result { body.require_brep("Delete Face")?; let all: Vec = body.solid.face_iter().cloned().collect(); @@ -194,6 +194,9 @@ pub fn delete_faces(body: &Body, faces: &[usize]) -> Result { lines.insert((ka, kb), e.clone()); e }; + // Neighbours rebuilt from themselves, moved edges pieces of themselves; the edges that + // close the gap are new. + let mut rec = crate::record::Rebuild::default(); let mut out: Vec = Vec::new(); for (fi, f) in all.iter().enumerate() { if del.contains(&fi) { @@ -256,7 +259,9 @@ pub fn delete_faces(body: &Body, faces: &[usize]) -> Result { new_edges.push(e.clone()); } else if matches!(e.oriented_curve(), mt::Curve::Line(_)) { if a.dist(b) > q * 10.0 { - new_edges.push(line(a, b, &mut verts)); + let moved = line(a, b, &mut verts); + rec.piece(&moved, e); + new_edges.push(moved); } } else { return Err(fail(format!("face {fi} has a curved edge that would have to stretch"))); @@ -285,11 +290,12 @@ pub fn delete_faces(body: &Body, faces: &[usize]) -> Result { } _ => face, }; + rec.face(&face, f); out.push(face); } let shell: mt::Shell = out.into(); let solid = Solid::try_new(vec![shell]).map_err(|e| fail(format!("the neighbouring faces do not close the gap ({e})")))?; - Body::new(solid) + crate::record::rebuilt(solid, &[&body.solid], &rec) }) } diff --git a/crates/kernel/src/heal.rs b/crates/kernel/src/heal.rs index ab25c048..45366e20 100644 --- a/crates/kernel/src/heal.rs +++ b/crates/kernel/src/heal.rs @@ -407,12 +407,7 @@ fn planar_surfaces(shell: &mt::Shell, tol: f64) -> Option { /// Heal a solid; returns the input unchanged when nothing applies or healing fails. #[cfg(test)] pub fn heal(solid: Solid, size: f64) -> Solid { - heal_keep(solid, size, &[]) -} - -/// [`heal`], keeping faces apart along lines they were split along on purpose. -pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec]) -> Solid { - healed(solid, size, keep, false).0 + healed(solid, size, &[], false).0 } /// What healing made each face and edge of its result from. @@ -467,8 +462,9 @@ impl HealMap { } } -/// [`heal_keep`], with what each face and edge of the result was made from (`None` when that -/// could not be followed). +/// Heal a solid (the input unchanged when nothing applies or healing fails), keeping faces apart +/// along lines they were split along on purpose (`keep`), with what each face and edge of the +/// result was made from (`None` when that could not be followed). pub(crate) fn heal_traced(solid: Solid, size: f64, keep: &[Vec]) -> (Solid, Option) { healed(solid, size, keep, true) } diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 803b2723..2d914411 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -40,6 +40,8 @@ mod loopblend; mod measure; mod mesh_convert; mod meshbody; +#[cfg(test)] +mod modify_history_tests; mod numbered; mod offset; mod ops; diff --git a/crates/kernel/src/loopblend.rs b/crates/kernel/src/loopblend.rs index 98d40abe..fb540bf7 100644 --- a/crates/kernel/src/loopblend.rs +++ b/crates/kernel/src/loopblend.rs @@ -11,6 +11,7 @@ use solvecraft_geom::Vec3; use truck_modeling::{self as mt, builder}; use crate::body::{Solid, from_p3, p3, v3}; +use crate::record::Rebuild; use crate::{KernelError, Result}; fn unsupported(msg: &str) -> KernelError { @@ -141,8 +142,9 @@ fn face_normal(f: &mt::Face, p: Vec3) -> Option { } /// Blend the loop of `edges` (by id) if it is a whole smooth boundary loop of a planar face with -/// perpendicular walls. `None` when the edges are not such a loop. -pub(crate) fn loop_blend(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Option> { +/// perpendicular walls, with what it built from `solid` (each blend face in place of its loop +/// edge). `None` when the edges are not such a loop. +pub(crate) fn loop_blend(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Option> { let tol = (size * 1e-6).max(1e-7); for (si, shell) in solid.boundaries().iter().enumerate() { let faces: Vec<&mt::Face> = shell.face_iter().collect(); @@ -184,7 +186,7 @@ fn build( r: f64, round: bool, run: Option<(usize, usize)>, -) -> Result { +) -> Result<(Solid, Rebuild)> { let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?; let wire = f.boundaries().get(wi).cloned().ok_or_else(|| KernelError::Failed("loop".into()))?; let all_edges: Vec = wire.edge_iter().cloned().collect(); @@ -550,17 +552,21 @@ fn build( crate::heal::absolute_face(g, wires).ok_or_else(|| KernelError::Failed("loop blend: a face could not be rebuilt".into())) }; let mut out: Vec = Vec::new(); + let mut rec = Rebuild::default(); + rec.pieces(&seam_subst); for (gi, g) in faces.iter().enumerate() { - if gi == fi { - out.push(rebuild(g, &face_rails)?); + let rebuilt = if gi == fi { + rebuild(g, &face_rails)? } else if walls.contains(&gi) { let wr: Vec> = wall_rails.iter().cloned().map(Some).collect(); - out.push(rebuild(g, &wr)?); + rebuild(g, &wr)? } else if g.edge_iter().any(|e| seam_subst.contains_key(&e.id())) { - out.push(rebuild(g, &[])?); + rebuild(g, &[])? } else { - out.push((*g).clone()); - } + (*g).clone() + }; + rec.face(&rebuilt, g); + out.push(rebuilt); } // Blend faces. use mt::{ParametricSurface3D, SearchNearestParameter}; @@ -612,11 +618,14 @@ fn build( None => vec![pr.clone(), wr.clone(), pr_next.inverse()].into(), }; let face = mt::Face::try_new(vec![w.clone()], surf.clone()).map_err(|e| KernelError::Failed(format!("blend face: {e}")))?; + // In place of the loop edge this piece runs along (a full circle's two halves both). + rec.blend(&face, owner.get(i).and_then(|o| edges.get(*o)).map(|e| e.id())); out.push(face); } let mut shells = solid.boundaries().clone(); if let Some(sh) = shells.get_mut(si) { *sh = out.into(); } - Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("loop blend produced an invalid solid: {e}"))) + let solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("loop blend produced an invalid solid: {e}")))?; + Ok((solid, rec)) } diff --git a/crates/kernel/src/modify_history_tests.rs b/crates/kernel/src/modify_history_tests.rs new file mode 100644 index 00000000..419232c7 --- /dev/null +++ b/crates/kernel/src/modify_history_tests.rs @@ -0,0 +1,378 @@ +//! Modifiers' histories (fillets, chamfers, shell, draft, offset, split, delete, surfaces): every +//! face and edge origin checked against the geometry of the operands (in tests only: the history +//! itself is recorded, never sampled), and the exact origins asserted where the outcome is known. + +use solvecraft_geom::{Loop2, Plane, Region2, Vec2, Vec3}; + +use crate::body::Body; +use crate::boolean_record::tests::{self, Reach, changes, to_edge, to_face}; +use crate::history::{Change, EdgeOrigin, FaceOrigin, History}; +use crate::{BoolOp, boolean, box_solid, cylinder, extrude}; + +const TOL: f64 = 0.05; + +const fn face(face: usize) -> FaceOrigin { + FaceOrigin::Face { operand: 0, face } +} + +const fn edge(edge: usize) -> FaceOrigin { + FaceOrigin::Edge { operand: 0, edge } +} + +const fn reach(made: f64) -> Reach { + Reach { made, moved: 0.0 } +} + +/// [`checked`] at this module's mesh tolerance. +fn checked<'a>(r: &'a Body, operands: &[&Body], at: Reach, generated: &dyn Fn(usize) -> bool) -> &'a History { + tests::checked(r, operands, TOL, at, generated) +} + +/// [`checked`] for a modifier of one operand whose every face comes from something of it. +fn verified<'a>(r: &'a Body, operand: &Body, at: Reach) -> &'a History { + checked(r, &[operand], at, &|_| false) +} + +/// The faces whose origins are exactly `origins`. +fn with(h: &History, origins: &[FaceOrigin]) -> Vec { + (0..h.faces.len()).filter(|i| h.faces[*i].origins == origins).collect() +} + +/// The origins of every face made in place of edges (blends). +fn blends(h: &History) -> Vec> { + h.faces.iter().filter(|f| f.origins.iter().any(|o| matches!(o, FaceOrigin::Edge { .. }))).map(|f| f.origins.clone()).collect() +} + +/// The index of the edge of `b` nearest `p`. +fn edge_at(b: &Body, p: Vec3) -> usize { + b.nearest_edge(p, TOL).unwrap().unwrap().0 +} + +/// The index of the face of `b` holding `p`. +fn face_at(b: &Body, p: Vec3) -> usize { + let m = b.tessellate(TOL).unwrap(); + (0..b.face_count()).min_by(|x, y| to_face(&m, *x, p).total_cmp(&to_face(&m, *y, p))).unwrap() +} + +fn cube() -> Body { + box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap() +} + +fn square(x0: f64, y0: f64, x1: f64, y1: f64) -> Loop2 { + Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]) +} + +/// An L-shaped prism (not convex), 20 high. +fn ell() -> Body { + let outer = Loop2::polygon(&[ + Vec2::ZERO, + Vec2::new(40.0, 0.0), + Vec2::new(40.0, 15.0), + Vec2::new(15.0, 15.0), + Vec2::new(15.0, 30.0), + Vec2::new(0.0, 30.0), + ]); + extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 20.0).unwrap().pop().unwrap() +} + +#[test] +fn fillet_one_edge() { + let b = cube(); + let at = Vec3::new(20.0, 30.0, 20.0); + let r = crate::fillet(&b, &[at], 5.0).unwrap(); + let h = verified(&r, &b, reach(5.0)); + assert_eq!(with(h, &[edge(edge_at(&b, at))]).len(), 1); + // Two faces along the edge and the two at its ends are trimmed, two are untouched. + assert_eq!(changes(h), [2, 4, 0, 0, 1]); +} + +#[test] +fn fillet_an_edge_a_cut_split() { + let b = cube(); + // A slot through the back leaves two pieces of the top back edge. + let notch = box_solid(Vec3::new(15.0, 25.0, -5.0), Vec3::new(25.0, 35.0, 25.0)).unwrap(); + let cut = boolean(&b, ¬ch, BoolOp::Cut).unwrap().unwrap(); + let at = Vec3::new(5.0, 30.0, 20.0); + let piece = edge_at(&cut, at); + assert_eq!(cut.history().unwrap().edges[piece].origins, [EdgeOrigin::Edge { operand: 0, edge: edge_at(&b, at) }]); + let r = crate::fillet(&cut, &[at], 3.0).unwrap(); + let h = verified(&r, &cut, reach(3.0)); + assert_eq!(blends(h), [vec![edge(piece)]]); + // Through the cut: the blend stands in place of the box's edge, which the cut split. + let mut through = h.clone(); + through.substitute(0, cut.history().unwrap()).unwrap(); + through.check(&r, &[&b, ¬ch]).unwrap(); + assert_eq!(blends(&through), [vec![edge(edge_at(&b, at))]]); +} + +#[test] +fn fillet_three_edges_at_a_corner() { + let b = cube(); + let pts = [Vec3::new(20.0, 30.0, 20.0), Vec3::new(40.0, 15.0, 20.0), Vec3::new(40.0, 30.0, 10.0)]; + let r = crate::fillet_radii(&b, &pts.map(|p| (p, 4.0))).unwrap(); + let h = verified(&r, &b, reach(4.0 * 1.5)); + let edges: Vec = pts.iter().map(|p| edge_at(&b, *p)).collect(); + for e in &edges { + assert_eq!(with(h, &[edge(*e)]).len(), 1, "{h:?}"); + } + // The corner patch stands in place of the three edges meeting there. + let mut corner: Vec = edges.iter().map(|e| edge(*e)).collect(); + corner.sort(); + assert_eq!(with(h, &corner).len(), 1, "{:?}", blends(h)); +} + +#[test] +fn fillet_a_whole_loop() { + let b = cube(); + let pts = [Vec3::new(20.0, 0.0, 20.0), Vec3::new(40.0, 15.0, 20.0), Vec3::new(20.0, 30.0, 20.0), Vec3::new(0.0, 15.0, 20.0)]; + let r = crate::fillet(&b, &pts, 3.0).unwrap(); + let h = verified(&r, &b, reach(3.0 * 1.5)); + let mut made: Vec = blends(h).into_iter().flatten().collect(); + made.sort(); + let mut want: Vec = pts.iter().map(|p| edge(edge_at(&b, *p))).collect(); + want.sort(); + assert_eq!(made, want); +} + +#[test] +fn fillet_every_edge_of_a_box() { + let b = cube(); + let pts: Vec = b.edges(TOL).unwrap().iter().map(|e| e.mid).collect(); + let r = crate::fillet(&b, &pts, 2.0).unwrap(); + let h = verified(&r, &b, reach(2.0 * 2.0)); + // Six faces shrunk, twelve cylinders, eight corners of three edges each. + assert_eq!(changes(h), [0, 6, 0, 0, 20]); + assert_eq!(blends(h).iter().filter(|o| o.len() == 1).count(), 12); + assert_eq!(blends(h).iter().filter(|o| o.len() == 3).count(), 8); +} + +#[test] +fn fillet_a_rolling_ball_chain() { + let main = cylinder(Vec3::new(-40.0, 0.0, 0.0), Vec3::X, 15.0, 80.0).unwrap(); + let branch = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 35.0).unwrap(); + let tee = boolean(&main, &branch, BoolOp::Union).unwrap().unwrap(); + let at = Vec3::new(0.0, 10.0, 125f64.sqrt()); + let r = crate::fillet(&tee, &[at], 3.0).unwrap(); + let h = verified(&r, &tee, reach(3.0 * 2.0)); + assert!(!blends(h).is_empty()); + assert!(blends(h).iter().all(|o| o.len() == 1)); +} + +#[test] +fn fillet_variable_and_chord() { + let b = cube(); + let at = Vec3::new(20.0, 30.0, 20.0); + let r = crate::fillet_variable(&b, at, Vec3::new(0.0, 30.0, 20.0), 2.0, 5.0).unwrap(); + let h = verified(&r, &b, reach(5.0)); + assert_eq!(blends(h), [vec![edge(edge_at(&b, at))]]); + let r = crate::fillet_chord(&b, &[at, Vec3::new(40.0, 15.0, 0.0)], 4.0).unwrap(); + let h = verified(&r, &b, reach(4.0)); + assert_eq!(blends(h).len(), 2); +} + +#[test] +fn chamfer_cuts_one_edge() { + let b = cube(); + let at = Vec3::new(20.0, 30.0, 20.0); + let r = crate::chamfer(&b, &[at], 4.0).unwrap(); + let h = verified(&r, &b, reach(4.0)); + let chamfer = with(h, &[edge(edge_at(&b, at))]); + assert_eq!(chamfer.len(), 1, "{h:?}"); + assert_eq!(h.faces[chamfer[0]].change, Change::Generated); + assert_eq!(r.face_count(), 7); +} + +#[test] +fn chamfer_cutters_mitre_at_a_corner() { + let b = cube(); + let pts = [Vec3::new(20.0, 30.0, 20.0), Vec3::new(40.0, 15.0, 20.0)]; + let r = crate::chamfer(&b, &pts, 3.0).unwrap(); + let h = verified(&r, &b, reach(3.0 * 1.5)); + for p in pts { + assert_eq!(with(h, &[edge(edge_at(&b, p))]).len(), 1, "{h:?}"); + } + // The two chamfers meet along the mitre. + let (a, c) = (edge(edge_at(&b, pts[0])), edge(edge_at(&b, pts[1]))); + assert!(h.edges.iter().any(|e| e.origins == [EdgeOrigin::Meet { a, b: c }] || e.origins == [EdgeOrigin::Meet { a: c, b: a }])); +} + +#[test] +fn chamfer_with_two_sides() { + let b = cube(); + let at = Vec3::new(20.0, 30.0, 20.0); + let r = crate::chamfer_sides(&b, &[at], 2.0, crate::ChamferSide::Distance(4.0), false).unwrap(); + let h = verified(&r, &b, reach(4.0)); + assert_eq!(blends(h), [vec![edge(edge_at(&b, at))]]); +} + +#[test] +fn delete_a_fillet() { + let b = cube(); + let r = crate::fillet(&b, &[Vec3::new(20.0, 30.0, 20.0)], 5.0).unwrap(); + let round = with(r.history().unwrap(), &[edge(edge_at(&b, Vec3::new(20.0, 30.0, 20.0)))]); + let back = crate::delete_faces(&r, &round).unwrap(); + let h = verified(&back, &r, Reach { made: 0.0, moved: 6.0 }); + // Every face is one of the filleted body's, trimmed back out to the sharp edge. + assert_eq!(h.faces.len(), 6); + assert!(h.faces.iter().all(|f| f.origins.len() == 1)); + // The faces either side of the round meet again where they met nowhere in its input. + let (top, back_face) = (face_at(&r, Vec3::new(20.0, 10.0, 20.0)), face_at(&r, Vec3::new(20.0, 30.0, 5.0))); + let (x, y) = (face(top), face(back_face)); + assert!(h.edges.iter().any(|e| e.origins == [EdgeOrigin::Meet { a: x.min(y), b: x.max(y) }])); +} + +#[test] +fn shell_lines_the_walls_with_offsets() { + let b = cube(); + let top = Vec3::new(20.0, 15.0, 20.0); + let r = crate::shell(&b, &[top], 2.0).unwrap(); + let h = verified(&r, &b, reach(2.0)); + // Five inner walls, each an offset of the face it lines; the top is left as a rim. + let top = face_at(&b, top); + for f in (0..6).filter(|f| *f != top) { + let walls = with(h, &[FaceOrigin::Offset { operand: 0, face: f }]); + assert_eq!(walls.len(), 1, "{h:?}"); + assert_eq!(h.faces[walls[0]].change, Change::Generated); + } + assert!(with(h, &[FaceOrigin::Offset { operand: 0, face: top }]).is_empty()); + assert_eq!(changes(h), [0, 6, 0, 0, 5]); +} + +#[test] +fn shell_of_a_body_that_is_not_convex() { + let b = ell(); + let r = crate::shell(&b, &[Vec3::new(5.0, 5.0, 20.0)], 2.0).unwrap(); + // An inner wall runs past its face by the thickness at a concave corner. + let h = verified(&r, &b, reach(2.0 * 1.5)); + let offsets = h.faces.iter().filter(|f| matches!(f.origins.as_slice(), [FaceOrigin::Offset { .. }])).count(); + assert_eq!(offsets, 7, "{h:?}"); +} + +#[test] +fn draft_tilts_faces() { + let b = cube(); + let side = Vec3::new(40.0, 15.0, 10.0); + let r = crate::draft(&b, &[side], &Plane::XY, Vec3::Z, 5f64.to_radians()).unwrap(); + let h = verified(&r, &b, Reach { made: 0.0, moved: 2.0 }); + assert_eq!(changes(h), [0, 6, 0, 0, 0]); + assert_eq!(with(h, &[face(face_at(&b, side))]).len(), 1); + // Walls of a body that is not convex. + let l = ell(); + let wall = Vec3::new(40.0, 7.0, 10.0); + let r = crate::draft(&l, &[wall], &Plane::XY, Vec3::Z, 5f64.to_radians()).unwrap(); + let h = verified(&r, &l, Reach { made: 0.0, moved: 2.0 }); + assert_eq!(with(h, &[face(face_at(&l, wall))]).len(), 1); +} + +#[test] +fn offset_moves_a_face() { + let b = ell(); + let top = Vec3::new(5.0, 5.0, 20.0); + let r = crate::offset_faces(&b, &[top], 3.0).unwrap(); + let h = verified(&r, &b, Reach { made: 0.0, moved: 3.0 * 1.1 }); + assert_eq!(h.faces.len(), b.face_count()); + assert_eq!(with(h, &[face(face_at(&b, top))]).len(), 1); + assert!(h.edges.iter().all(|e| matches!(e.origins.as_slice(), [EdgeOrigin::Edge { .. }]))); +} + +#[test] +fn split_a_face() { + let b = cube(); + let top = face_at(&b, Vec3::new(20.0, 15.0, 20.0)); + let plane = Plane::new(Vec3::new(10.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap(); + let (r, map) = crate::split_faces_with_map(&b, &[top], &crate::SplitTool::Plane(plane)).unwrap(); + let h = verified(&r, &b, reach(0.0)); + // Two pieces of the top; the faces whose edges the split cut are whole, as is the rest. + assert_eq!(with(h, &[face(top)]), map.iter().enumerate().filter(|(_, f)| **f == top).map(|(i, _)| i).collect::>()); + assert_eq!(changes(h), [5, 0, 2, 0, 0]); + for (i, f) in map.iter().enumerate() { + assert_eq!(h.faces[i].origins, [face(*f)]); + } + // The split line runs between pieces of one face: it comes from nothing. + assert_eq!(h.edges.iter().filter(|e| e.origins.is_empty()).count(), 1); +} + +#[test] +fn split_a_body() { + let b = cube(); + let plane = Plane::new(Vec3::new(10.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap(); + let parts = crate::split_body(&b, &crate::SplitTool::Plane(plane)).unwrap(); + assert_eq!(parts.len(), 2); + for p in &parts { + let h = checked(p, &[&b], reach(0.0), &|i| p.history().is_some_and(|h| h.faces[i].change == Change::Generated)); + // One section face, new; the rest the box's faces or pieces of them. + assert_eq!(h.faces.iter().filter(|f| f.origins.is_empty()).count(), 1); + } +} + +#[test] +fn surfaces_keep_their_faces() { + let b = cube(); + let top = Vec3::new(20.0, 15.0, 20.0); + let copy = crate::copy_faces(&b, &[top]).unwrap(); + let h = verified(©, &b, reach(0.0)); + assert_eq!(h.faces[0].origins, [face(face_at(&b, top))]); + assert_eq!(h.faces[0].change, Change::Kept); + // Trimmed: the copy, cut back (its other piece is gone). + let plane = Plane::new(Vec3::new(10.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap(); + let t = crate::trim(©, &crate::SplitTool::Plane(plane), Vec3::new(5.0, 15.0, 20.0)).unwrap(); + let h = verified(&t, ©, reach(0.0)); + assert_eq!(h.faces[0].origins, [face(0)]); + assert_eq!(h.faces[0].change, Change::Modified); + // Extended: the face and its edges moved out. + let x = crate::extend(©, &[Vec3::new(40.0, 15.0, 20.0)], 5.0).unwrap(); + let h = verified(&x, ©, Reach { made: 0.0, moved: 5.0 * 1.1 }); + assert_eq!(h.faces[0].origins, [face(0)]); + assert!(h.edges.iter().all(|e| e.origins.len() == 1)); + // Patched: a new face on the body's own edges. + let open = crate::copy_faces( + &b, + &[ + Vec3::new(20.0, 15.0, 0.0), + Vec3::new(20.0, 0.0, 10.0), + Vec3::new(20.0, 30.0, 10.0), + Vec3::new(0.0, 15.0, 10.0), + Vec3::new(40.0, 15.0, 10.0), + ], + ) + .unwrap(); + let rim = [Vec3::new(20.0, 0.0, 20.0), Vec3::new(40.0, 15.0, 20.0), Vec3::new(20.0, 30.0, 20.0), Vec3::new(0.0, 15.0, 20.0)]; + let lid = crate::patch_edges(&open, &rim).unwrap(); + let h = checked(&lid, &[&open], reach(0.0), &|_| true); + assert_eq!(h.faces[0].change, Change::Generated); + assert!(h.edges.iter().all(|e| matches!(e.origins.as_slice(), [EdgeOrigin::Edge { operand: 0, .. }]))); + // Stitched: each face the one it was sewn from, the seam the edges sewn together. + let closed = crate::stitch(&[&open, &lid], 0.0).unwrap(); + let h = checked(&closed, &[&open, &lid], reach(0.0), &|_| false); + assert!(h.faces.iter().all(|f| f.change == Change::Kept && f.origins.len() == 1)); + assert_eq!(h.edges.iter().filter(|e| e.origins.len() == 2).count(), 4); + let patch = crate::patch_region(&Plane::XY, &Region2 { outer: square(0.0, 0.0, 10.0, 10.0), holes: vec![] }).unwrap(); + assert_eq!(patch.history().unwrap().operands, 0); +} + +#[test] +fn thicken_offsets_the_surface() { + let s = crate::patch_region(&Plane::XY, &Region2 { outer: square(0.0, 0.0, 10.0, 10.0), holes: vec![] }).unwrap(); + let t = crate::thicken(&s, 2.0, false).unwrap(); + let h = verified(&t, &s, reach(2.0)); + assert_eq!(with(h, &[face(0)]).len(), 1); + assert_eq!(with(h, &[FaceOrigin::Offset { operand: 0, face: 0 }]).len(), 1); + assert_eq!(blends(h).len(), 4); +} + +#[test] +fn mirrored_copy_keeps_every_face_and_edge() { + let b = ell(); + let m = [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [100.0, 0.0, 0.0, 1.0]]; + let r = crate::transform_matrix(&b, m).unwrap(); + let h = r.history().unwrap(); + h.check(&r, &[&b]).unwrap(); + // Each edge is the mirror image of the one it says it is. + let (rm, bm) = (r.tessellate(TOL).unwrap(), b.tessellate(TOL).unwrap()); + for (j, e) in h.edges.iter().enumerate() { + let [EdgeOrigin::Edge { edge, .. }] = e.origins[..] else { panic!("edge {j}: {e:?}") }; + for p in &rm.edges[j] { + assert!(to_edge(&bm, edge, Vec3::new(100.0 - p.x, p.y, p.z)) < 1e-6, "edge {j} is not the mirror of edge {edge}"); + } + } +} diff --git a/crates/kernel/src/offset.rs b/crates/kernel/src/offset.rs index dac14dd2..b8225c59 100644 --- a/crates/kernel/src/offset.rs +++ b/crates/kernel/src/offset.rs @@ -649,7 +649,9 @@ pub(crate) fn arc_mid(e: &mt::Edge, tol: f64) -> Option> { } /// The body with each face moved along its outward normal by `shift(face index, normal at a -/// face point)`, keeping the topology. Faces must be planes or cylinders. +/// face point)`, keeping the topology. Faces must be planes or cylinders. The result's history +/// is over `[b]`: each face is the face it was moved from, each edge the edge it was moved from; +/// faces moved to nothing are gone, and their neighbours meet along new edges. pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Result { let faces: Vec = b.solid.face_iter().cloned().collect(); let size = b.size(); @@ -900,6 +902,8 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul let mut links: HashMap<(mt::VertexID, usize, usize), mt::Edge> = HashMap::new(); // Old edge → new edge, and whether it runs the other way (merged with its twin). let mut edges: HashMap = HashMap::new(); + // Moved faces and edges; an edge where neighbours of a collapsed face meet is new. + let mut rec = crate::record::Rebuild::default(); let mut merged: HashMap<(mt::VertexID, mt::VertexID), mt::Edge> = HashMap::new(); for e in b.solid.edge_iter() { if edges.contains_key(&e.id()) { @@ -949,7 +953,9 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul } pts.push(from_p3(c.point())); let curve = crate::build::interpolate_cubic(&pts).ok_or_else(|| fail("edge curve"))?; - edges.insert(e.id(), (mt::Edge::new(a, c, mt::Curve::BSplineCurve(curve)), false)); + let ne = mt::Edge::new(a, c, mt::Curve::BSplineCurve(curve)); + rec.piece(&ne, &e); + edges.insert(e.id(), (ne, false)); continue; }; let ne = match kind { @@ -962,6 +968,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul builder::circle_arc(a, c, p3(m2)) } }; + rec.piece(&ne, &e); edges.insert(e.id(), (ne, false)); } let mut out = Vec::new(); @@ -1027,6 +1034,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul if !f.orientation() { nf.invert(); } + rec.face(&nf, f); out.push(nf); } // One shell per shell of the body (pieces, voids), less the collapsed faces. @@ -1039,14 +1047,15 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul shells.push(it.by_ref().take(keep).collect::>().into()); } let solid = Solid::try_new(shells).map_err(|e| fail(&e.to_string()))?; - Body::new(solid) + crate::record::rebuilt(solid, &[&b.solid], &rec) }) } /// Draft walls parallel to `pull` (planes, and cylinders about axes along `pull`) by `angle` /// about their line on the neutral plane: at height h along `pull` a wall moves along its /// outward normal by −h·tan(angle), so planes tilt and cylinders become cones. The other -/// faces must be caps square to `pull`; the topology is kept. +/// faces must be caps square to `pull`; the topology is kept, and the result's history is over +/// `[b]` (each face and edge the one it was moved from). pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solvecraft_geom::Plane, pull: Vec3, angle: f64) -> Result { let faces: Vec = b.solid.face_iter().cloned().collect(); let size = b.size(); @@ -1166,6 +1175,7 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solvecraft_geom: guard("draft", || { let verts: HashMap = newpos.iter().map(|(k, p)| (*k, builder::vertex(p3(*p)))).collect(); let mut edges: HashMap = HashMap::new(); + let mut rec = crate::record::Rebuild::default(); for e in b.solid.edge_iter() { if edges.contains_key(&e.id()) { continue; @@ -1181,6 +1191,7 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solvecraft_geom: builder::circle_arc(a, c, p3(m2)) } }; + rec.piece(&ne, &e); edges.insert(e.id(), ne); } let mut out = Vec::new(); @@ -1234,11 +1245,12 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solvecraft_geom: nf } }; + rec.face(&nf, f); out.push(nf); } let shell: mt::Shell = out.into(); let solid = Solid::try_new(vec![shell]).map_err(|e| fail(&e.to_string()))?; - Body::new(solid) + crate::record::rebuilt(solid, &[&b.solid], &rec) }) } diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 08602b9c..3355ba4d 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; use solvecraft_geom::Vec3; use truck_modeling as mt; +use std::collections::HashMap; + use crate::body::{Body, Solid, from_p3}; use crate::boolean_diagnostics::{BooleanFallback, BooleanStrategy, capture_boolean_diagnostics, merge, record}; use crate::boolean_record::{self as recorded, Traced}; @@ -401,8 +403,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { BORES.with(|c| c.set(false)); if let Some(body) = r { merge(diagnostics); - // Narrowing a bore (an offset) reports no history yet. - return Ok(Some(body.with_checked_history(None, &counts))); + return Ok(Some(body)); } } let mut empty_votes = 0; @@ -696,22 +697,28 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result { if m.iter().flatten().any(|x| !x.is_finite()) { return Err(KernelError::Invalid("non-finite transform".into())); } - let placed = match body.mesh_transformed(&m) { - Some(b) => b, - None => guard("transform", || { - let mat = mt::Matrix4::new( - m[0][0], m[0][1], m[0][2], m[0][3], m[1][0], m[1][1], m[1][2], m[1][3], m[2][0], m[2][1], m[2][2], m[2][3], m[3][0], m[3][1], - m[3][2], m[3][3], - ); - // An affine placement retains face order and appearance metadata. - let sources = body.transformed_sources(m)?; - Body::new(placed_topology(&body.solid, mat)?).map(|b| { - b.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources).inherit_regularization_protection(body) - }) - })?, - }; - let history = crate::history::History::identity(&placed); - placed.with_history(history, &[body]) + if let Some(placed) = body.mesh_transformed(&m) { + let history = crate::history::History::identity(&placed); + return placed.with_history(history, &[body]); + } + guard("transform", || { + let mat = mt::Matrix4::new( + m[0][0], m[0][1], m[0][2], m[0][3], m[1][0], m[1][1], m[1][2], m[1][3], m[2][0], m[2][1], m[2][2], m[2][3], m[3][0], m[3][1], m[3][2], + m[3][3], + ); + // An affine placement retains face order and appearance metadata. Its edges are the + // body's by identity: a reflection turns the solid inside out, which walks each face's + // edges the other way and so changes the edge order. + let sources = body.transformed_sources(m)?; + let (solid, copies) = placed_topology_mapped(&body.solid, mat)?; + let mut rec = record::Rebuild::default(); + rec.pieces(&copies); + for (copy, face) in solid.face_iter().zip(body.solid.face_iter()) { + rec.whole(copy, face); + } + let placed = record::rebuilt(solid, &[&body.solid], &rec)?; + Ok(placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources).inherit_regularization_protection(body)) + }) } /// `source` moved by `matrix`, keeping its face and edge order (transforms report an identity @@ -719,8 +726,12 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result { /// builders: a STEP periodic face may traverse a seam edge twice, and those reject that wire /// while cloning even though placement preserves its topology. pub(crate) fn placed_topology(source: &Solid, matrix: mt::Matrix4) -> Result { + placed_topology_mapped(source, matrix).map(|(solid, _)| solid) +} + +/// [`placed_topology`], with each edge of `source` (by identity) and its copy. +fn placed_topology_mapped(source: &Solid, matrix: mt::Matrix4) -> Result<(Solid, HashMap)> { use mt::{Transform, Transformed}; - use std::collections::HashMap; let mut vertices = HashMap::new(); let mut edges = HashMap::new(); let mut shells = Vec::new(); @@ -771,7 +782,7 @@ pub(crate) fn placed_topology(source: &Solid, matrix: mt::Matrix4) -> Result Result<(Vec, Vec<(usize, Vec) /// The convex polyhedron bounded by the half-spaces. pub fn convex_polyhedron(hs: &[HalfSpace]) -> Result { + polyhedron(hs).map(|(b, _)| b) +} + +/// [`convex_polyhedron`], with the half-space each of its faces lies on (in face order). +fn polyhedron(hs: &[HalfSpace]) -> Result<(Body, Vec)> { let (verts, faces_idx) = combinatorics(hs)?; + let on: Vec = faces_idx.iter().map(|(hi, _)| *hi).collect(); guard("polyhedron", || { let tv: Vec = verts.iter().map(|v| builder::vertex(p3(*v))).collect(); let mut edges: HashMap<(usize, usize), mt::Edge> = HashMap::new(); @@ -102,25 +110,27 @@ pub fn convex_polyhedron(hs: &[HalfSpace]) -> Result { } let shell: mt::Shell = faces.into(); let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("polyhedron: {e}")))?; - Body::new(solid) + Ok((Body::new(solid)?, on)) }) } -/// The half-spaces of a convex body with only planar faces, or an error. -pub fn planar_convex(b: &Body) -> Result> { +/// The half-spaces of a convex body with only planar faces, each with a point on it and the +/// body's faces on its plane (in face order), or an error. +pub fn planar_convex(b: &Body) -> Result)>> { b.require_brep("this operation")?; let tol = (b.size() * 1e-3).max(1e-3); let faces = b.faces(tol)?; - let mut out: Vec<(HalfSpace, Vec3)> = Vec::new(); + let mut out: Vec<(HalfSpace, Vec3, Vec)> = Vec::new(); for f in &faces { let n = f.plane_normal.ok_or_else(|| KernelError::Failed("not supported yet: bodies with curved faces".into()))?; let d = n.dot(f.centroid); - if !out.iter().any(|(h, _)| h.n.dot(n) > 1.0 - 1e-9 && (h.d - d).abs() < tol) { - out.push((HalfSpace { n, d }, f.centroid)); + match out.iter_mut().find(|(h, ..)| h.n.dot(n) > 1.0 - 1e-9 && (h.d - d).abs() < tol) { + Some((.., on)) => on.push(f.index), + None => out.push((HalfSpace { n, d }, f.centroid, vec![f.index])), } } let verts: Vec = b.solid.vertex_iter().map(|v| from_p3(v.point())).collect(); - for (h, _) in &out { + for (h, ..) in &out { if verts.iter().any(|v| h.n.dot(*v) > h.d + tol) { return Err(KernelError::Failed("not supported yet: shell and draft of non-convex bodies".into())); } @@ -128,7 +138,59 @@ pub fn planar_convex(b: &Body) -> Result> { Ok(out) } -/// Hollow a convex planar body: walls of `thickness` inside, faces near `open` removed. +/// The faces of `b` on half-space `k`'s plane (`groups[k]`) as the face over them: moved, or +/// merged when several. +fn on_plane(under: &[usize]) -> FaceProv { + record::region(under.iter().map(|&face| FaceOrigin::Face { operand: 0, face }).collect(), false) +} + +/// The edges of `b` between faces on two of its planes (`groups[k]`: its faces on plane `k`), +/// by pair of planes (the lower first). +fn plane_edges(b: &Body, groups: &[Vec]) -> HashMap<(usize, usize), Vec> { + let plane_of: HashMap = groups.iter().enumerate().flat_map(|(k, g)| g.iter().map(move |f| (*f, k))).collect(); + let mut out: HashMap<(usize, usize), Vec> = HashMap::new(); + for (edge, fs) in crate::history::edge_faces(&b.solid).iter().enumerate() { + if let [p, q] = fs[..] + && let (Some(&x), Some(&y)) = (plane_of.get(&p), plane_of.get(&q)) + && x != y + { + out.entry((x.min(y), x.max(y))).or_default().push(edge); + } + } + out +} + +/// The history over `[b]` of a convex polyhedron `poly` whose face `j` lies on the plane of +/// half-space `on[j]`, made from the faces `groups[k]` of `b` on half-space `k`'s plane: each +/// face the faces under it, each edge along the edges of `b` between faces of its two planes, +/// or new where they met nowhere. +fn planes_history(poly: &Body, on: &[usize], groups: &[Vec], b: &Body) -> Option { + let faces: Vec = on.iter().map(|k| groups.get(*k).map(|under| on_plane(under))).collect::>()?; + let along = plane_edges(b, groups); + let mut h = record::from_faces(&poly.solid, 1, faces); + for e in &mut h.edges { + if let [x, y] = e.faces[..] + && let (Some(&kx), Some(&ky)) = (on.get(x), on.get(y)) + && let Some(edges) = along.get(&(kx.min(ky), kx.max(ky))) + { + e.origins = edges.iter().map(|&edge| EdgeOrigin::Edge { operand: 0, edge }).collect(); + } + } + Some(h) +} + +/// The history over `[b]` of a cavity `poly` whose face `j` lies on half-space `on[j]`, moved +/// in from the faces `groups[k]` of `b`: each face an offset of the faces it lines, each edge new. +fn cavity_history(poly: &Body, on: &[usize], groups: &[Vec]) -> Option { + let faces = on + .iter() + .map(|k| groups.get(*k).map(|under| record::generated(under.iter().map(|&face| FaceOrigin::Offset { operand: 0, face }).collect()))) + .collect::>()?; + Some(record::from_faces(&poly.solid, 1, faces)) +} + +/// Hollow a convex planar body: walls of `thickness` inside, faces near `open` removed. The +/// result's history is over `[b]`: the inner walls are offsets of the faces they line. pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result { shell_impl(b, open, thickness)?.with_shell_planar_sources(b, thickness, open) } @@ -145,16 +207,40 @@ fn shell_impl(b: &Body, open: &[Vec3], thickness: f64) -> Result { let size = b.size(); let inner: Vec = hs .iter() - .map(|(h, c)| { + .map(|(h, c, _)| { let removed = open.iter().any(|p| (h.n.dot(*p) - h.d).abs() < size * 1e-4 + 1e-6 && p.dist(*c) < size * 2.0); if removed { HalfSpace { n: h.n, d: h.d + thickness + size * 0.05 } } else { HalfSpace { n: h.n, d: h.d - thickness } } }) .collect(); - if !hs.iter().zip(&inner).any(|((h, _), i)| i.d > h.d) { + if !hs.iter().zip(&inner).any(|((h, ..), i)| i.d > h.d) { return Err(KernelError::Invalid("select at least one face to remove".into())); } - let cavity = convex_polyhedron(&inner)?; - crate::ops::boolean(b, &cavity, crate::BoolOp::Cut)?.ok_or_else(|| KernelError::Failed("the shell removed everything".into())) + let (cavity, on) = polyhedron(&inner)?; + let groups: Vec> = hs.into_iter().map(|(.., under)| under).collect(); + let walls = cavity_history(&cavity, &on, &groups); + let r = crate::ops::boolean(b, &cavity, crate::BoolOp::Cut)?.ok_or_else(|| KernelError::Failed("the shell removed everything".into()))?; + Ok(hollowed(r, b, walls)) +} + +/// `r`, `b` less a cavity whose history over `[b]` is `walls`, with its history over `[b]`. +fn hollowed(r: Body, b: &Body, walls: Option) -> Body { + let h = crate::boolean_record::of_pair(&r, &crate::boolean_record::kept(b, 0, 1), &walls); + r.with_checked_history(h, &record::counts(&[b])) +} + +/// The history over `[b]` of a cavity `offset` made from `b` by moving its faces (whose history +/// over `[b]` says so): each face an offset of the face it was moved from, each edge new. +fn offsets(offset: &Body) -> Option { + let h = offset.history()?; + let faces: Vec = h + .faces + .iter() + .map(|f| match f.origins.as_slice() { + [FaceOrigin::Face { operand, face }] => Some(record::generated(vec![FaceOrigin::Offset { operand: *operand, face: *face }])), + _ => None, + }) + .collect::>()?; + Some(record::from_faces(&offset.solid, h.operands, faces)) } /// Tilt the faces near `faces` by `angle` about their line on the neutral plane, so that they @@ -172,7 +258,7 @@ pub fn draft(b: &Body, faces: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) let size = b.size(); let mut out = Vec::new(); let mut moved = 0; - for (h, c) in &hs { + for (h, c, _) in &hs { let chosen = faces.iter().any(|p| (h.n.dot(*p) - h.d).abs() < size * 1e-4 + 1e-6 && p.dist(*c) < size * 2.0); if !chosen { out.push(*h); @@ -191,12 +277,16 @@ pub fn draft(b: &Body, faces: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) if moved == 0 { return Err(KernelError::Invalid("no faces to draft".into())); } - convex_polyhedron(&out) + // Each half-space in place of the one it was made from: its faces moved (or as they were). + let (r, on) = polyhedron(&out)?; + let groups: Vec> = hs.into_iter().map(|(.., under)| under).collect(); + let h = planes_history(&r, &on, &groups, b); + Ok(r.with_checked_history(h, &record::counts(&[b]))) } /// Draft of a non-convex body: walls (planes, cylinders) next to caps square to `pull`. fn draft_walls(b: &Body, at: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) -> Result { - let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?; + let healed = crate::boolean_record::healed(b)?; let size = healed.size(); let mesh = healed.tessellate((size * 1e-3).max(1e-3))?; let mut chosen: Vec = Vec::new(); @@ -216,13 +306,22 @@ fn draft_walls(b: &Body, at: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) - _ => return Err(KernelError::Invalid(format!("no face at {:?}", [p.x, p.y, p.z]))), } } - crate::offset::draft_walls(&healed, &chosen, neutral, pull, angle) + Ok(record::after(crate::offset::draft_walls(&healed, &chosen, neutral, pull, angle)?, &healed, &record::counts(&[b]))) } /// Round every edge of a convex body with planar faces by `r`: faces shrink, each edge becomes a /// cylinder and each corner a sphere patch (the body is the inner polyhedron grown by a ball). +/// The result's history is over `[b]`: each cylinder in place of the edges of `b` between its +/// two planes' faces, each corner patch in place of those round its corner. pub fn round_all_edges(b: &Body, r: f64) -> Result { - let hs: Vec = planar_convex(b)?.into_iter().map(|(h, _)| h).collect(); + let planes = planar_convex(b)?; + let hs: Vec = planes.iter().map(|(h, ..)| *h).collect(); + let groups: Vec> = planes.iter().map(|(.., under)| under.clone()).collect(); + // The edges of `b` between faces on planes f and g. + let edges = plane_edges(b, &groups); + let along = |f: usize, g: usize| -> Vec { + edges.get(&(f.min(g), f.max(g))).into_iter().flatten().map(|&edge| FaceOrigin::Edge { operand: 0, edge }).collect() + }; let inner: Vec = hs.iter().map(|h| HalfSpace { n: h.n, d: h.d - r }).collect(); let (q, faces) = combinatorics(&inner)?; if faces.len() != hs.len() { @@ -269,6 +368,8 @@ pub fn round_all_edges(b: &Body, r: f64) -> Result { if f < g { e } else { e.inverse() } }; let mut out: Vec = Vec::new(); + // What each face is made from (in `out`'s order). + let mut made: Vec = Vec::new(); // Planar faces. for (hi, lp) in &faces { let n = lp.len(); @@ -278,6 +379,7 @@ pub fn round_all_edges(b: &Body, r: f64) -> Result { w.push(line(*hi, a, bb, &mut vert)); } out.push(builder::try_attach_plane(&[w.into()]).map_err(|e| KernelError::Failed(format!("rounded face: {e}")))?); + made.push(on_plane(groups.get(*hi).map_or(&[], Vec::as_slice))); } use mt::{ParametricSurface3D, SearchNearestParameter}; let orient = |mut surf: mt::Surface, at: Vec3, outward: Vec3| -> mt::Surface { @@ -310,6 +412,7 @@ pub fn round_all_edges(b: &Body, r: f64) -> Result { let mid = (qa + qb) * 0.5 + out_dir * r; let surf = orient(swept.oriented_surface(), mid, out_dir); out.push(mt::Face::try_new(vec![w.into()], surf).map_err(|e| KernelError::Failed(format!("rounded edge: {e}")))?); + made.push(record::generated(along(f, g))); } // Corner sphere patches: the faces around each inner vertex, counter-clockwise. for (qi, c) in q.iter().enumerate() { @@ -331,10 +434,18 @@ pub fn round_all_edges(b: &Body, r: f64) -> Result { mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(*c), v3(side)))); let surf = orient(surf, *c + axis * r, axis); out.push(mt::Face::try_new(vec![w.into()], surf).map_err(|e| KernelError::Failed(format!("rounded corner: {e}")))?); + let mut corner: Vec = + (0..k).filter_map(|i| Some((*around.get(i)?, *around.get((i + 1) % k)?))).flat_map(|(f, g)| along(f, g)).collect(); + corner.sort(); + corner.dedup(); + made.push(record::generated(corner)); } let shell: mt::Shell = out.into(); let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("rounded body: {e}")))?; - Body::new(solid) + let body = Body::new(solid)?; + // Every face from something of `b` (a blend between planes that met nowhere has none). + let history = made.iter().all(|f| !f.origins.is_empty()).then(|| record::from_faces(&body.solid, 1, made)); + Ok(body.with_checked_history(history, &record::counts(&[b]))) }) } @@ -345,12 +456,9 @@ fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result { // Face paint and authored splits are construction boundaries. Retain their // eligibility veto through the temporary body used for the cavity Cut. // Paint indices stay in source order by avoiding healing in that case. - let solid = if b.paint().is_some_and(|paint| !paint.faces.is_empty()) { - b.deep_copy() - } else { - crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()) - }; - let healed = Body::new(solid)?.with_paint(b.paint().cloned()).with_split_lines(b.split_lines().cloned()).inherit_regularization_protection(b); + let healed = + if b.paint().is_some_and(|paint| !paint.faces.is_empty()) { crate::boolean_record::copied(b)? } else { crate::boolean_record::healed(b)? }; + let healed = healed.with_paint(b.paint().cloned()).with_split_lines(b.split_lines().cloned()).inherit_regularization_protection(b); let size = healed.size(); let mesh = healed.tessellate((size * 1e-3).max(1e-3))?; // Faces to open: the face of the triangle nearest each point. @@ -422,7 +530,10 @@ fn shell_opened(b: &Body, healed: &Body, open: &[Vec3], opened: &[usize], thickn return Ok(x); } match r { - Ok(Some(r)) => Ok(r), + Ok(Some(r)) => { + // Over `[healed]`, the cavity's faces offsets of the faces they line; then over `[b]`. + Ok(record::after(hollowed(r, &healed, offsets(&cavity)), &healed, &record::counts(&[b]))) + } Ok(None) => Err(KernelError::Failed("the shell removed everything".into())), // Healing can leave faces the boolean dislikes (revolved patches): the body as it was, // its faces to open found again. @@ -445,15 +556,17 @@ fn shell_opened(b: &Body, healed: &Body, open: &[Vec3], opened: &[usize], thickn } let opened = opened_raw; let cavity = offset_planar(b, |fi, _| if opened.contains(&fi) { margin } else { -thickness }).map_err(|_| e.clone())?; - crate::ops::boolean(b, &cavity, crate::BoolOp::Cut) + let r = crate::ops::boolean(b, &cavity, crate::BoolOp::Cut) .map_err(|_| e)? - .ok_or_else(|| KernelError::Failed("the shell removed everything".into())) + .ok_or_else(|| KernelError::Failed("the shell removed everything".into()))?; + Ok(hollowed(r, b, offsets(&cavity))) } } } /// A shell made from the cavity with every face moved in by the thickness (open faces too), -/// joined with each open face's moved copy swept out through the body. +/// joined with each open face's moved copy swept out through the body. No history: the +/// opening's walls, swept from the cavity's edges, come from nothing of `b`. fn shell_by_opening(b: &Body, opened: &[usize], thickness: f64, reach: f64) -> Result { let closed = offset_planar(b, |_, _| -thickness)?; let faces: Vec = closed.solid.face_iter().cloned().collect(); @@ -472,7 +585,8 @@ fn shell_by_opening(b: &Body, opened: &[usize], thickness: f64, reach: f64) -> R let prism = Body::new(prism)?; cavity = crate::ops::boolean(&cavity, &prism, crate::BoolOp::Union)?.ok_or_else(|| KernelError::Failed("shell: opening".into()))?; } - crate::ops::boolean(b, &cavity, crate::BoolOp::Cut)?.ok_or_else(|| KernelError::Failed("the shell removed everything".into())) + let r = crate::ops::boolean(b, &cavity, crate::BoolOp::Cut)?.ok_or_else(|| KernelError::Failed("the shell removed everything".into()))?; + Ok(r.without_history()) } fn unsupported_shell(m: &str) -> KernelError { @@ -525,7 +639,7 @@ pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result { if !distance.is_finite() || distance.abs() > 1e6 { return Err(KernelError::Invalid("offset distance".into())); } - let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?.with_split_lines(b.splits.clone()); + let healed = crate::boolean_record::healed(b)?.with_split_lines(b.splits.clone()); let size = healed.size(); let mesh = healed.tessellate((size * 1e-3).max(1e-3))?; let mut chosen: Vec = Vec::new(); @@ -547,7 +661,7 @@ pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result { } let chosen = crate::offset::with_same_surface(&healed, &chosen); match offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 }) { - Ok(r) => Ok(r), + Ok(r) => Ok(record::after(r, &healed, &record::counts(&[b]))), // A piece of a split face (its neighbour stays): the piece swept along its normal // joins on (outward) or cuts in, with walls along the split lines. Err(e) if !healed.split_keep().is_empty() => press_pieces(&healed, &chosen, distance).map_err(|_| e), @@ -555,6 +669,8 @@ pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result { } } +/// No history: the walls along the split lines are swept from edges, and come from nothing of +/// `b`. fn press_pieces(b: &Body, chosen: &[usize], distance: f64) -> Result { let faces: Vec = b.solid.face_iter().cloned().collect(); let mut cur = b.clone(); @@ -565,5 +681,5 @@ fn press_pieces(b: &Body, chosen: &[usize], distance: f64) -> Result { let op = if distance > 0.0 { crate::BoolOp::Union } else { crate::BoolOp::Cut }; cur = crate::ops::boolean(&cur, &tool, op)?.ok_or_else(|| KernelError::Failed("offset: the piece moved off the body".into()))?; } - Ok(cur.with_split_lines(b.splits.clone())) + Ok(cur.with_split_lines(b.splits.clone()).without_history()) } diff --git a/crates/kernel/src/record.rs b/crates/kernel/src/record.rs index c0590db8..378e7639 100644 --- a/crates/kernel/src/record.rs +++ b/crates/kernel/src/record.rs @@ -13,7 +13,7 @@ use std::collections::{BTreeSet, HashMap}; use truck_modeling as mt; use crate::Result; -use crate::body::{Body, edge_order}; +use crate::body::{Body, Solid, edge_order}; use crate::history::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, History}; /// A face as built, with its provenance (`None` where the construction could not be followed). @@ -51,22 +51,23 @@ pub(crate) fn generated(origins: Vec) -> FaceProv { /// origins; Split when its one origin lies under other result faces too; Modified otherwise; /// Generated with no origin. pub(crate) fn regions(faces: Vec<(BTreeSet, bool)>) -> Vec { - let mut faces: Vec = faces - .into_iter() - .map(|(origins, whole)| { - let change = match origins.len() { - 0 => Change::Generated, - 1 if whole => Change::Kept, - 1 => Change::Modified, - _ => Change::Merged, - }; - FaceProv { origins: origins.into_iter().collect(), change } - }) - .collect(); + let mut faces: Vec = faces.into_iter().map(|(origins, whole)| region(origins, whole)).collect(); mark_shared_split(&mut faces); faces } +/// A result face that is a region of the input faces `origins` (see [`regions`]), before +/// faces sharing an origin are marked Split. +pub(crate) fn region(origins: BTreeSet, whole: bool) -> FaceProv { + let change = match origins.len() { + 0 => Change::Generated, + 1 if whole => Change::Kept, + 1 => Change::Modified, + _ => Change::Merged, + }; + FaceProv { origins: origins.into_iter().collect(), change } +} + /// Faces of one origin that is under other faces too, and that still say Kept or Modified, /// are Split (also where histories are put side by side: pieces of one body can share a /// source). @@ -171,6 +172,190 @@ pub(crate) fn attach(body: Body, operands: &[Operand], faces: Option Body { + let own = step.history.take().map(std::sync::Arc::unwrap_or_clone); + let h = own.zip(prev.history()).and_then(|(h, inner)| crate::boolean_record::composed(h.substituted(0, inner))); + step.with_checked_history(h, counts) +} + +/// The history over `operands` operands of a body made by building `solid`, whose faces (in its +/// face order) come from `faces`: each edge new, where its two faces meet ([`meeting`]). +pub(crate) fn from_faces(solid: &Solid, operands: usize, faces: Vec) -> History { + let edges = edge_order(solid.face_iter()).1.into_iter().map(|between| EdgeProv { origins: meeting(&between, &faces), faces: between }).collect(); + History { operands, faces, edges } +} + +/// What a result face of a local rebuild was made from, by identity: an operand face it was +/// rebuilt from, or an operand edge it was made in place of (a blend). +#[derive(Clone, Copy, Debug)] +pub(crate) enum Was { + Face(mt::FaceID), + Edge(mt::EdgeID), +} + +/// What an operation built from its operands' topology, recorded by identity while the +/// operands and the result are both alive: the faces it made (each from what [`Was`], and +/// whether it is the whole of the one operand face it was rebuilt from: the same region) and +/// the edges it made as (parts of, or joins of) operand edges. A result face or edge that is an +/// operand's own (the very same topology, kept by the building code) is that, whatever was +/// recorded for it. Every +/// other edge is new, and comes from the meeting of the two faces it separates +/// ([`Rebuild::history`]). The record holds what it names, so that no identity it was given is +/// reused by later topology while it lives. +#[derive(Debug, Default)] +pub(crate) struct Rebuild { + faces: HashMap, bool)>, + edges: HashMap)>, +} + +impl Rebuild { + /// `new` is `from` rebuilt (trimmed differently, moved). + pub(crate) fn face(&mut self, new: &mt::Face, from: &mt::Face) { + self.made(new, vec![Was::Face(from.id())]); + } + + /// `new` is `from` rebuilt over the same region (its boundary edges cut or re-made). + pub(crate) fn whole(&mut self, new: &mt::Face, from: &mt::Face) { + self.faces.insert(new.id(), (new.clone(), vec![Was::Face(from.id())], true)); + } + + /// `new` is made in place of the operand edges `edges` (a blend; a corner patch lists the + /// edges meeting at its corner). + pub(crate) fn blend(&mut self, new: &mt::Face, edges: impl IntoIterator) { + self.made(new, edges.into_iter().map(Was::Edge).collect()); + } + + /// `new` is made from what `was` says (nothing: new to the operation). + pub(crate) fn made(&mut self, new: &mt::Face, was: Vec) { + self.faces.insert(new.id(), (new.clone(), was, false)); + } + + /// `new` is (a piece of, or the moved) edge `of`: an operand edge, or an edge recorded as + /// made from operand edges (a piece of a piece is a piece of the operand edge). + pub(crate) fn piece(&mut self, new: &mt::Edge, of: &mt::Edge) { + self.piece_of(new, of.id()); + } + + /// Every piece in `subst` (edge → the edge made in its place). + pub(crate) fn pieces<'a>(&mut self, subst: impl IntoIterator) { + for (old, new) in subst { + self.piece_of(new, *old); + } + } + + fn piece_of(&mut self, new: &mt::Edge, of: mt::EdgeID) { + let roots = self.edges.get(&of).map_or_else(|| vec![of], |(_, roots)| roots.clone()); + let (_, slot) = self.edges.entry(new.id()).or_insert_with(|| (new.clone(), Vec::new())); + for root in roots { + if !slot.contains(&root) { + slot.push(root); + } + } + } + + /// The history of `result` (in its face order; edges by identity) made from `operands` + /// (their solids, in the operation's operand order): faces and edges that are an operand's + /// own by identity (several operands' when they share it), the rest as recorded; an edge + /// recorded nowhere separates two faces that met nowhere before, so it comes from their + /// meeting ([`meeting`]). `None` when a face is neither an operand's nor recorded, or a record + /// names topology no operand has. + pub(crate) fn history(&self, result: &Solid, operands: &[&Solid]) -> Option<(Vec, HashMap>)> { + let mut face_at: HashMap> = HashMap::new(); + let mut edge_at: HashMap> = HashMap::new(); + for (operand, s) in operands.iter().enumerate() { + for (id, face) in face_index(s) { + face_at.entry(id).or_default().push(FaceOrigin::Face { operand, face }); + } + for (id, edge) in edge_index(s) { + edge_at.entry(id).or_default().push((operand, edge)); + } + } + let was = |w: &Was| -> Option> { + Some(match *w { + Was::Face(id) => face_at.get(&id)?.clone(), + Was::Edge(id) => edge_at.get(&id)?.iter().map(|&(operand, edge)| FaceOrigin::Edge { operand, edge }).collect(), + }) + }; + let mut faces = result + .face_iter() + .map(|f| { + let (origins, whole): (BTreeSet, bool) = match face_at.get(&f.id()) { + Some(own) => (own.iter().copied().collect(), true), + None => { + let (_, made, whole) = self.faces.get(&f.id())?; + (made.iter().map(was).collect::>>()?.into_iter().flatten().collect(), *whole) + } + }; + let on_faces = origins.iter().filter(|o| matches!(o, FaceOrigin::Face { .. })).count(); + let change = match on_faces { + 0 => Change::Generated, + 1 if origins.len() == 1 && whole => Change::Kept, + 1 if origins.len() == 1 => Change::Modified, + _ => Change::Merged, + }; + Some(FaceProv { origins: origins.into_iter().collect(), change }) + }) + .collect::>>()?; + mark_shared_split(&mut faces); + let edge_origins = |id: &mt::EdgeID| edge_at.get(id).map(|at| at.iter().map(|&(operand, edge)| EdgeOrigin::Edge { operand, edge })); + let (edges, bounds) = edge_order(result.face_iter()); + let mut origins = HashMap::with_capacity(edges.len()); + for (e, between) in edges.iter().zip(bounds) { + let of: Vec = if let Some(own) = edge_origins(&e.id()) { + own.collect() + } else if let Some((_, roots)) = self.edges.get(&e.id()) { + let mut set = BTreeSet::new(); + for root in roots { + set.extend(edge_origins(root)?); + } + set.into_iter().collect() + } else { + meeting(&between, &faces) + }; + origins.insert(e.id(), of); + } + Some((faces, origins)) + } +} + +/// The origins of a new edge between the result faces `between` (whose provenance is in +/// `faces`): the meeting of the two faces, when each comes from one origin and they differ; +/// else none (a seam, an edge between pieces of one face). +pub(crate) fn meeting(between: &[usize], faces: &[FaceProv]) -> Vec { + let side = |k: usize| match between.get(k).and_then(|f| faces.get(*f)).map(|p| p.origins.as_slice()) { + Some([o]) => Some(*o), + _ => None, + }; + match (between.len(), side(0), side(1)) { + (2, Some(a), Some(b)) if a != b => vec![EdgeOrigin::Meet { a: a.min(b), b: a.max(b) }], + _ => Vec::new(), + } +} + +/// The face and edge counts of the solids `operands`, for [`traced`]. +pub(crate) fn solid_counts(operands: &[&Solid]) -> Vec<(usize, usize)> { + operands.iter().map(|s| (s.face_iter().count(), edge_order(s.face_iter()).0.len())).collect() +} + +/// The body `Body::new` makes of `result`, which an operation built from `operands` (their +/// solids) as `rec` records, with that history (none where it could not be recorded). +pub(crate) fn rebuilt(result: Solid, operands: &[&Solid], rec: &Rebuild) -> Result { + let (faces, edges) = rec.history(&result, operands).unzip(); + build(result, &solid_counts(operands), faces, edges.as_ref()) +} + +/// [`rebuilt`] for a body made as it is (a surface, which `Body::new` does not take): its +/// solid is the one the operation built. +pub(crate) fn rebuilt_body(body: Body, operands: &[&Solid], rec: &Rebuild) -> Body { + let (faces, edges) = rec.history(&body.solid, operands).unzip(); + let counts = solid_counts(operands); + traced(body, &counts, faces, edges.as_ref()) +} + /// Ruled faces (truck's `try_wire_homotopy` of wires `a` and `b`), each with the provenance of /// the edges it spans: every face holds one edge of each wire, and comes from the profile /// segments under both (`a_numbers`, `b_numbers`: by edge, in wire order). diff --git a/crates/kernel/src/splitface.rs b/crates/kernel/src/splitface.rs index e5dc2049..5b04383e 100644 --- a/crates/kernel/src/splitface.rs +++ b/crates/kernel/src/splitface.rs @@ -14,6 +14,7 @@ use truck_meshalgo::tessellation::RobustMeshableShape; use truck_modeling as mt; use crate::body::{Body, from_p3, p3}; +use crate::record::Rebuild; use crate::{KernelError, Result, guard}; /// What splits the faces. @@ -457,8 +458,9 @@ fn inside(poly: &[(f64, f64)], q: (f64, f64)) -> bool { /// Split one face (index `fi` in `faces`) by one field; `faces` is updated (the face replaced /// by its pieces, boundary edges cut in every face). -/// Returns how many lines split it and how many faces now stand in its place (at `fi`). -fn split_one(faces: &mut Vec, fi: usize, g: &Field, size: f64, new_edges: &mut Vec) -> Result<(usize, usize)> { +/// Returns how many lines split it and how many faces now stand in its place (at `fi`). The +/// pieces of cut edges are recorded in `rec`. +fn split_one(faces: &mut Vec, fi: usize, g: &Field, size: f64, new_edges: &mut Vec, rec: &mut Rebuild) -> Result<(usize, usize)> { let Some(face) = faces.get(fi).cloned() else { return Ok((0, 1)) }; let chains = trace(&face, g, size)?; if chains.is_empty() { @@ -659,6 +661,11 @@ fn split_one(faces: &mut Vec, fi: usize, g: &Field, size: f64, new_edg if made == 0 { return Ok((0, 1)); } + // In the order they were cut: a piece cut again is a piece of the edge it came from. + for (c, (a, b)) in &all_cuts { + rec.piece(a, c); + rec.piece(b, c); + } let count = pieces.len(); // Neighbours take the cut edges. let rest: Vec = faces.iter().enumerate().filter(|(k, _)| *k != fi).map(|(_, f)| recut(f, &all_cuts)).collect(); @@ -802,10 +809,14 @@ pub(crate) struct Imprint { origin: Vec, new_edges: Vec, made: HashMap, + /// The faces and edges made from the body's (each face over the same region as the body + /// face it came from, or a piece of it; cut edges' pieces). + rec: Rebuild, } fn imprint(body: &Body, faces: &[usize], fields: &[Field], size: f64) -> Result { - let mut out = Imprint { shells: Vec::new(), origin: Vec::new(), new_edges: Vec::new(), made: HashMap::new() }; + let mut out = Imprint { shells: Vec::new(), origin: Vec::new(), new_edges: Vec::new(), made: HashMap::new(), rec: Rebuild::default() }; + let body_faces: Vec<&mt::Face> = body.solid.face_iter().collect(); let mut base = 0; for sh in body.solid.boundaries() { let mut fs: Vec = sh.face_iter().cloned().collect(); @@ -822,7 +833,7 @@ fn imprint(body: &Body, faces: &[usize], fields: &[Field], size: f64) -> Result< continue; } let src = origin.get(k).copied().unwrap_or(0); - let (m, pieces) = split_one(&mut fs, k, g, size, &mut out.new_edges)?; + let (m, pieces) = split_one(&mut fs, k, g, size, &mut out.new_edges, &mut out.rec)?; *out.made.entry(src).or_default() += m; // The pieces stand at k..k + pieces; positions after shift. target = target.iter().take(k).copied().chain(std::iter::repeat_n(true, pieces)).chain(target.iter().skip(k + 1).copied()).collect(); @@ -830,6 +841,11 @@ fn imprint(body: &Body, faces: &[usize], fields: &[Field], size: f64) -> Result< k += pieces; } } + for (f, src) in fs.iter().zip(&origin) { + if let Some(from) = body_faces.get(*src) { + out.rec.whole(f, from); + } + } out.shells.push(fs); out.origin.extend(origin); } @@ -837,7 +853,8 @@ fn imprint(body: &Body, faces: &[usize], fields: &[Field], size: f64) -> Result< } /// Split the given faces (indices in `body`'s face order) where the tool meets them. The solid -/// is unchanged. A listed face the tool misses is an error naming it. +/// is unchanged. A listed face the tool misses is an error naming it. The result's history is +/// over `[body]`: each piece of a split face is Split from it, every other face Kept. pub fn split_faces(body: &Body, faces: &[usize], tool: &SplitTool) -> Result { split_faces_with_map(body, faces, tool).map(|(b, _)| b) } @@ -877,20 +894,18 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul } let shells = std::mem::take(&mut imp.shells); let solid = mt::Solid::new_unchecked(shells.into_iter().map(mt::Shell::from).collect()); - Ok(( - Body { - solid: std::sync::Arc::new(solid), - coarse_missing: None, - mesh: None, - color: body.color, - paint: None, - splits: body.splits.clone(), - face_sources: None, - regularization_protected: true, - history: None, - }, - imp, - )) + let split = Body { + solid: std::sync::Arc::new(solid), + coarse_missing: None, + mesh: None, + color: body.color, + paint: None, + splits: body.splits.clone(), + face_sources: None, + history: None, + regularization_protected: true, + }; + Ok((crate::record::rebuilt_body(split, &[&body.solid], &imp.rec), imp)) }) } @@ -979,7 +994,8 @@ fn components(faces: Vec) -> Vec> { /// Split a body into the parts on either side of a tool: a plane, or another face's surface /// (extended). Faces are divided where the tool meets them and every part is closed with the /// piece of the tool's surface inside the body; parts come in face order, those on the tool's -/// back first. A body the tool misses comes back whole. +/// back first. A body the tool misses comes back whole. Each part's history is over `[body]`: +/// its faces the body's (or pieces of them), the section new. pub fn split_body(body: &Body, tool: &SplitTool) -> Result> { body.require_brep("split body")?; if body.solid.boundaries().len() != 1 { @@ -1022,8 +1038,9 @@ pub fn split_body(body: &Body, tool: &SplitTool) -> Result> { let imp = imprint(body, &all, &fields, size)?; let new_edges = imp.new_edges; if imp.made.values().all(|m| *m == 0) || new_edges.is_empty() { - return Ok(vec![body.clone()]); + return Ok(vec![crate::boolean_record::operand(body, 0, &crate::record::counts(&[body]))]); } + let mut rec = imp.rec; let faces: Vec = imp.shells.into_iter().flatten().collect(); // The section: loops of the new edges on the tool surface, outer loops counter-clockwise // about its normal and holes inside them. @@ -1128,6 +1145,10 @@ pub fn split_body(body: &Body, tool: &SplitTool) -> Result> { front.push(f); } } + // The section is new: it comes from nothing of the body. + for f in §ion { + rec.made(f, Vec::new()); + } // The section closes the back side facing the tool's normal, the front side facing away. back.extend(section.iter().cloned()); front.extend(section.iter().map(|f| f.inverse())); @@ -1138,7 +1159,7 @@ pub fn split_body(body: &Body, tool: &SplitTool) -> Result> { if shell.shell_condition() != mt::ShellCondition::Closed { return Err(KernelError::Failed("a part of the split does not close".into())); } - parts.push(Body::new(mt::Solid::new_unchecked(vec![shell]))?.with_color(body.color)); + parts.push(crate::record::rebuilt(mt::Solid::new_unchecked(vec![shell]), &[&body.solid], &rec)?.with_color(body.color)); } } Ok(parts) diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index d0780ac7..e5e4b605 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -11,6 +11,7 @@ use truck_modeling as mt; use crate::body::{Body, from_p3, p3}; use crate::history::{Change, EdgeOrigin, FaceOrigin}; +use crate::record::{Rebuild, rebuilt_body}; use crate::{KernelError, Result, guard}; impl Body { @@ -22,7 +23,11 @@ impl Body { /// A body of faces as they are (no orientation fix: a surface has no inside). fn surface_body(faces: Vec) -> Body { - let shell = mt::Shell::from(faces); + surface_of(mt::Shell::from(faces)) +} + +/// A body of a shell as it is. +fn surface_of(shell: mt::Shell) -> Body { Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), coarse_missing: None, @@ -36,30 +41,23 @@ fn surface_body(faces: Vec) -> Body { } } -/// A body from faces: a solid when they close, else a surface. -fn body_of(faces: Vec) -> Result { +/// A body from faces: a solid when they close, else a surface; with the history of the +/// operation that made the faces from `operands` as `rec` records. +fn body_of(faces: Vec, operands: &[&mt::Solid], rec: &Rebuild) -> Result { let shell = mt::Shell::from(faces); if shell.shell_condition() == mt::ShellCondition::Closed { - Body::new(mt::Solid::new_unchecked(vec![shell])) + crate::record::rebuilt(mt::Solid::new_unchecked(vec![shell]), operands, rec) } else { - Ok(Body { - solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), - coarse_missing: None, - mesh: None, - color: None, - paint: None, - splits: None, - face_sources: None, - regularization_protected: false, - history: None, - }) + Ok(rebuilt_body(surface_of(shell), operands, rec)) } } -/// A planar surface filling a sketch region. +/// A planar surface filling a sketch region. Its history has no operands: the face is new. pub fn patch_region(plane: &Plane, region: &Region2) -> Result { let f = crate::build::face(plane, region)?; - Ok(surface_body(vec![f])) + let mut rec = Rebuild::default(); + rec.made(&f, Vec::new()); + Ok(rebuilt_body(surface_body(vec![f]), &[], &rec)) } /// The edge of a body nearest a point (by samples along each edge). @@ -78,7 +76,8 @@ pub(crate) fn edge_near(body: &Body, p: Vec3) -> Option { best.filter(|(d, _)| *d <= body.size() * 0.05 + 1e-6).map(|(_, e)| e) } -/// A planar surface filling a closed loop of a body's edges (each picked by a point on it). +/// A planar surface filling a closed loop of a body's edges (each picked by a point on it). Its +/// history is over `[body]`: the face is new, its edges the body's. pub fn patch_edges(body: &Body, at: &[Vec3]) -> Result { body.require_brep("patch")?; if at.is_empty() || at.len() > 10_000 { @@ -114,11 +113,15 @@ pub fn patch_edges(body: &Body, at: &[Vec3]) -> Result { let wire: mt::Wire = chain.into(); let face = mt::builder::try_attach_plane(&[wire]) .map_err(|_| KernelError::Invalid("not supported yet: patching a loop that is not planar".into()))?; - Ok(surface_body(vec![face])) + // A new face on the body's own edges. + let mut rec = Rebuild::default(); + rec.made(&face, Vec::new()); + Ok(rebuilt_body(surface_body(vec![face]), &[&body.solid], &rec)) }) } -/// Faces of a body copied out as a surface body (faces picked by points on them). +/// Faces of a body copied out as a surface body (faces picked by points on them). Its history is +/// over `[body]`: the faces and their edges are the body's. pub fn copy_faces(body: &Body, at: &[Vec3]) -> Result { body.require_brep("copy faces")?; let faces: Vec<&mt::Face> = body.solid.face_iter().collect(); @@ -130,7 +133,8 @@ pub fn copy_faces(body: &Body, at: &[Vec3]) -> Result { chosen.push(i); } } - Ok(surface_body(chosen.into_iter().filter_map(|i| faces.get(i).map(|f| (*f).clone())).collect())) + let copied = surface_body(chosen.into_iter().filter_map(|i| faces.get(i).map(|f| (*f).clone())).collect()); + Ok(rebuilt_body(copied, &[&body.solid], &Rebuild::default())) } /// Index of the face nearest a point (over its mesh triangles). @@ -148,7 +152,8 @@ fn nearest_face(body: &Body, p: Vec3) -> Result { } /// Sew surface bodies along edges that coincide (within `tol`): one body, a solid when the -/// faces close up. Faces are turned so that neighbours agree. +/// faces close up. Faces are turned so that neighbours agree. The result's history is over +/// `bodies`: each face the one it was sewn from, each edge the edges sewn into it. pub fn stitch(bodies: &[&Body], tol: f64) -> Result { if bodies.is_empty() { return Err(KernelError::Invalid("nothing to stitch".into())); @@ -180,6 +185,7 @@ pub fn stitch(bodies: &[&Body], tol: f64) -> Result { }; let mut edges: Vec<(mt::Edge, mt::Point3)> = Vec::new(); let mut out = Vec::with_capacity(faces.len()); + let mut rec = Rebuild::default(); for f in &faces { let mut wires = Vec::new(); for w in f.absolute_boundaries() { @@ -210,6 +216,7 @@ pub fn stitch(bodies: &[&Body], tol: f64) -> Result { x } }; + rec.piece(&ne, &abs); nw.push(if e.orientation() { ne } else { ne.inverse() }); } wires.push(mt::Wire::from(nw)); @@ -218,6 +225,7 @@ pub fn stitch(bodies: &[&Body], tol: f64) -> Result { if !f.orientation() { nf.invert(); } + rec.whole(&nf, f); out.push(nf); } // Neighbours run their shared edges opposite ways. @@ -249,7 +257,8 @@ pub fn stitch(bodies: &[&Body], tol: f64) -> Result { } } let out: Vec = out.into_iter().zip(flip).map(|(f, fl)| if fl == Some(true) { f.inverse() } else { f }).collect(); - body_of(out) + let operands: Vec<&mt::Solid> = bodies.iter().map(|b| &*b.solid).collect(); + body_of(out, &operands, &rec) }) } @@ -505,7 +514,7 @@ pub fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result { } /// A surface body trimmed by a tool: its faces are cut where the tool meets them and the -/// pieces on the side of `keep` are kept. +/// pieces on the side of `keep` are kept. The result's history is over `[body]`. pub fn trim(body: &Body, tool: &crate::SplitTool, keep: Vec3) -> Result { body.require_brep("trim")?; let total = body.solid.face_iter().count(); @@ -523,12 +532,15 @@ pub fn trim(body: &Body, tool: &crate::SplitTool, keep: Vec3) -> Result { if kept.is_empty() { return Err(KernelError::Invalid("nothing is left on that side of the tool".into())); } - Ok(surface_body(kept)) + // The kept pieces are the split's own; its history carries them back to `body`. + let trimmed = rebuilt_body(surface_body(kept), &[&split.solid], &Rebuild::default()); + Ok(crate::record::after(trimmed, &split, &crate::record::counts(&[body]))) }) } /// A planar surface extended: the chosen straight edges (picked by points on them) move -/// outward in the face's plane by `distance`, their neighbours lengthened to meet them. +/// outward in the face's plane by `distance`, their neighbours lengthened to meet them. The +/// result's history is over `[body]`: the face and each edge the one it was moved from. pub fn extend(body: &Body, at: &[Vec3], distance: f64) -> Result { body.require_brep("extend")?; if !(distance.is_finite() && distance > 0.0) { @@ -577,10 +589,13 @@ pub fn extend(body: &Body, at: &[Vec3], distance: f64) -> Result { let p = meet(*prev, *cur).unwrap_or(cur.0); corners.push(mt::Vertex::new(p3(p))); } + let mut rec = Rebuild::default(); let wire: mt::Wire = (0..m) .filter_map(|i| { - let (a, b) = (corners.get(i)?, corners.get((i + 1) % m)?); - Some(mt::Edge::new_unchecked(a, b, mt::Curve::Line(mt::Line(a.point(), b.point())))) + let (a, b, old) = (corners.get(i)?, corners.get((i + 1) % m)?, edges.get(i)?); + let e = mt::Edge::new_unchecked(a, b, mt::Curve::Line(mt::Line(a.point(), b.point()))); + rec.piece(&e, old); + Some(e) }) .collect::>() .into(); @@ -588,7 +603,8 @@ pub fn extend(body: &Body, at: &[Vec3], distance: f64) -> Result { if f.oriented_surface().normal(0.0, 0.0).dot(crate::body::v3(n)) < 0.0 { f = f.inverse(); } - Ok(surface_body(vec![f])) + rec.face(&f, face); + Ok(rebuilt_body(surface_body(vec![f]), &[&body.solid], &rec)) }) }