diff --git a/crates/kernel/src/boolean_history_tests.rs b/crates/kernel/src/boolean_history_tests.rs index 390eb2d4..c9d4d578 100644 --- a/crates/kernel/src/boolean_history_tests.rs +++ b/crates/kernel/src/boolean_history_tests.rs @@ -7,7 +7,7 @@ use std::collections::BTreeSet; use solvecraft_geom::{Mesh, Vec3}; use crate::body::Body; -use crate::history::{Change, EdgeOrigin, FaceOrigin, History}; +use crate::history::{Change, EdgeOrigin, FaceOrigin, History, Side}; use crate::{BoolOp, boolean, box_solid}; const fn face(operand: usize, face: usize) -> FaceOrigin { @@ -98,9 +98,9 @@ pub(crate) fn checked<'a>(r: &'a Body, operands: &[&Body], tol: f64, at: Reach, 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), + 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 } => a.origins().iter().any(|o| on(o, *p)) && b.origins().iter().any(|o| on(o, *p)), }; for (i, f) in h.faces.iter().enumerate() { assert!(!f.origins.is_empty() || generated(i), "face {i} has no origin"); @@ -116,8 +116,12 @@ pub(crate) fn checked<'a>(r: &'a Body, operands: &[&Body], tol: f64, at: Reach, let pts = &rm.edges[j]; for o in &e.origins { 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(|p| along(o, p)), "edge {j}: {o:?} is not along it"), + // A joined edge runs along all of its origins, each along part of it (one in the + // middle of a straight edge holds none of its points, but its own lie on it). + EdgeOrigin::Edge { operand, edge } if e.origins.len() > 1 => assert!( + pts.iter().any(|p| along(o, p)) || ms[*operand].edges[*edge].iter().any(|q| to_edge(&rm, j, *q) < near.max(at.moved)), + "edge {j}: {o:?} is not along it" + ), _ => assert!(pts.iter().all(|p| along(o, p)), "edge {j} ({e:?}) does not lie along {o:?}"), } } @@ -160,9 +164,10 @@ fn from(h: &History, o: FaceOrigin) -> Vec { (0..h.faces.len()).filter(|i| h.faces[*i].origins.contains(&o)).collect() } -/// The meetings among the edges' origins, as face pairs. +/// The meetings among the edges' origins, as face pairs (each side one face here). fn meets(h: &History) -> BTreeSet<(FaceOrigin, FaceOrigin)> { - h.edges.iter().flat_map(|e| &e.origins).filter_map(|o| if let EdgeOrigin::Meet { a, b } = o { Some((*a, *b)) } else { None }).collect() + let one = |s: &Side| s.single().expect("a side of one face"); + h.edges.iter().flat_map(|e| &e.origins).filter_map(|o| if let EdgeOrigin::Meet { a, b } = o { Some((one(a), one(b))) } else { None }).collect() } fn boxed(x0: f64, y0: f64, z0: f64, x1: f64, y1: f64, z1: f64) -> Body { @@ -419,3 +424,44 @@ fn faceted_tool() { assert_eq!(from_a.len(), 6); assert_eq!(h.faces.iter().filter(|f| matches!(f.origins.first(), Some(FaceOrigin::Face { operand: 1, .. }))).count(), 3); } + +/// A step cut into a certified box: the boolean leaves the faces it split on the box's planes +/// in pieces, which the planar regularization joins back; the joined body keeps the boolean's +/// history composed through the join. +#[test] +fn regularized_step_cut() { + let (pa, pb) = (boxed(0.0, 0.0, 0.0, 30.0, 35.0, 7.5), boxed(0.0, 30.0, 2.5, 22.5, 50.0, 25.0)); + let (a, b) = (pa.clone().with_box_certificate(1).unwrap(), pb.clone().with_box_certificate(2).unwrap()); + let r = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap(); + let h = verified(&r, &[&a, &b], 0.01); + let plain = boolean(&pa, &pb, BoolOp::Cut).unwrap().unwrap(); + let ph = verified(&plain, &[&pa, &pb], 0.01); + // Uncertified, the box's face 3 stays in two pieces; certified, they are joined into one + // face, the only one from it. + assert_eq!(from(ph, face(0, 3)).len(), 2, "{}", show(ph)); + assert_eq!(r.face_count() + 1, plain.face_count()); + let joined = from(h, face(0, 3)); + assert_eq!(joined.len(), 1, "{}", show(h)); + assert_eq!(faces_of(h)[joined[0]], (Change::Modified, vec![face(0, 3)])); + // Every other face is as the boolean made it, in its order. + let rest = |h: &History| -> Vec<(Change, Vec)> { faces_of(h).into_iter().filter(|(_, o)| *o != [face(0, 3)]).collect() }; + assert_eq!(rest(h), rest(ph)); +} + +/// A ring joined under a floor, its bore on the floor's hole (coincident cylinders): the ring's +/// bore is narrowed, the bodies joined, and the bore cut out again. The ring's bore is a circle +/// built in two arcs, and the recut wall is split where the cutting cylinder's own construction +/// splits it, so which arc each piece of it restores is not known: no history. +#[test] +fn bore_union_of_a_bore_in_two_arcs_has_no_history() { + let hole = crate::cylinder(Vec3::new(50.0, 40.0, -5.0), Vec3::Z, 10.0, 30.0).unwrap(); + let floor = boolean(&boxed(0.0, 0.0, 0.0, 100.0, 80.0, 10.0), &hole, BoolOp::Cut).unwrap().unwrap(); + let centre = solvecraft_geom::Vec2::new(50.0, 40.0); + let annulus = solvecraft_geom::Region2 { + outer: solvecraft_geom::Loop2::circle(centre, 20.0), + holes: vec![solvecraft_geom::Loop2::circle(centre, 10.0).reversed()], + }; + let ring = crate::extrude(&solvecraft_geom::Plane::XY, &[annulus], -5.0, 2.0).unwrap().remove(0); + let r = crate::coplanar::bore_union(&floor, &ring, &|_| true).expect("the bores are recut"); + assert!(r.history().is_none()); +} diff --git a/crates/kernel/src/boolean_record.rs b/crates/kernel/src/boolean_record.rs index 6d78375f..272b6f28 100644 --- a/crates/kernel/src/boolean_record.rs +++ b/crates/kernel/src/boolean_record.rs @@ -112,7 +112,7 @@ pub(crate) fn shapeops(h: &truck_shapeops::History, tool: &Solid, tool_edges: Op tool_edges.and_then(|t| t.get(passed.get(edge)?)).cloned() } truck_shapeops::EdgeOrigin::Edge { operand, edge } => Some(vec![EdgeOrigin::Edge { operand, edge }]), - truck_shapeops::EdgeOrigin::Meet { a, b } => Some(vec![EdgeOrigin::Meet { a: face(a), b: face(b) }]), + truck_shapeops::EdgeOrigin::Meet { a, b } => Some(vec![EdgeOrigin::meet(face(a), face(b))]), }) .collect::>>()?; Some((faces, edges)) diff --git a/crates/kernel/src/coplanar.rs b/crates/kernel/src/coplanar.rs index e91feaa8..fdd6d12e 100644 --- a/crates/kernel/src/coplanar.rs +++ b/crates/kernel/src/coplanar.rs @@ -357,14 +357,15 @@ fn trimmed(x: &Body, h: History, slab: &Body, operands: [&Body; 2], n: Vec3, d: f.origins = origins.into_iter().collect(); } } + let cap = crate::history::Side::from(cap); for e in &mut cut.edges { for o in &mut e.origins { - let side = match *o { - EdgeOrigin::Meet { a, b } if a == cap => b, - EdgeOrigin::Meet { a, b } if b == cap => a, + let side = match o { + EdgeOrigin::Meet { a, b } if *a == cap => b, + EdgeOrigin::Meet { a, b } if *b == cap => a, _ => continue, }; - let FaceOrigin::Face { operand, face } = side else { return None }; + let Some(FaceOrigin::Face { operand, face }) = side.single() else { return None }; let (plane_faces, bounds) = (in_plane.get(operand)?, bounds.get(operand)?); let mut along = (0..bounds.len()) .filter(|k| bounds.get(*k).is_some_and(|fs| fs.len() == 2 && fs.contains(&face) && fs.iter().any(|f| plane_faces.contains(f)))); @@ -541,8 +542,12 @@ fn same_cylinder(x: &CylFace, y: &CylFace, tol: f64) -> bool { /// 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. +/// wall may be left. Only a bore of one face: the cylinder's wall faces are split where its own +/// construction splits them, so which of several bore faces a piece of it lies over is not known. fn recut(r: &Body, joined: &Body, narrowed: &Body, cut: &Body, bore: &[usize], tool_operand: usize) -> Option { + if bore.len() != 1 { + return None; + } // 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); diff --git a/crates/kernel/src/heal.rs b/crates/kernel/src/heal.rs index 45366e20..52fefac9 100644 --- a/crates/kernel/src/heal.rs +++ b/crates/kernel/src/heal.rs @@ -446,7 +446,7 @@ impl HealMap { .map(|(id, under)| { let mut origins = BTreeSet::new(); for i in under { - origins.extend(edges.get(*i)?.iter().copied()); + origins.extend(edges.get(*i)?.iter().cloned()); } Some((*id, origins.into_iter().collect())) }) diff --git a/crates/kernel/src/history.rs b/crates/kernel/src/history.rs index af1e84dd..d3ae9f9c 100644 --- a/crates/kernel/src/history.rs +++ b/crates/kernel/src/history.rs @@ -48,13 +48,64 @@ pub struct FaceProv { pub change: Change, } +/// One side of a meeting: the origins of the face on that side of the edge. One origin, or +/// several where that face was made from several (a merged face) and the edge lies along it +/// as a whole. Never empty; sorted, without repeats, so equal sides compare equal. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Side(Vec); + +impl Side { + /// The side made of `origins` (in any order, repeats allowed); `None` when there are none. + pub fn new(origins: impl IntoIterator) -> Option { + let side = Side::canonical(origins.into_iter().collect()); + (!side.0.is_empty()).then_some(side) + } + + /// Its origins, sorted. + pub fn origins(&self) -> &[FaceOrigin] { + &self.0 + } + + /// The side's one origin, when it has exactly one. + pub fn single(&self) -> Option { + match self.0.as_slice() { + [o] => Some(*o), + _ => None, + } + } + + /// The same side with each origin mapped by `f` (failing where `f` does). + fn try_map(&self, f: impl Fn(FaceOrigin) -> Result) -> Result { + Ok(Side::canonical(self.0.iter().map(|o| f(*o)).collect::>()?)) + } + + /// The same side with each origin's operand renumbered by `f`. + fn map_operand(&self, f: impl Fn(usize) -> usize) -> Side { + Side::canonical(self.0.iter().map(|o| o.map_operand(&f)).collect()) + } + + /// `origins` sorted, without repeats. + fn canonical(mut origins: Vec) -> Side { + origins.sort_unstable(); + origins.dedup(); + Side(origins) + } +} + +impl From for Side { + fn from(origin: FaceOrigin) -> Side { + Side(vec![origin]) + } +} + /// Where a result edge came from. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum EdgeOrigin { /// (Part of) edge `edge` of input `operand`. Edge { operand: usize, edge: usize }, - /// Where two faces meet that met nowhere before (a boolean's intersection curve). - Meet { a: FaceOrigin, b: FaceOrigin }, + /// Where two faces meet that met nowhere before (a boolean's intersection curve): the + /// origins of the face on each side, `a <= b` (made with [`EdgeOrigin::meet`]). + Meet { a: Side, b: Side }, } /// A result edge: the result faces it separates and what it came from (empty when it is new @@ -91,11 +142,18 @@ impl FaceOrigin { } impl EdgeOrigin { + /// Where the faces with origins `a` and `b` (sides, or single origins) meet, in canonical + /// order (the lesser side first). + pub fn meet(a: impl Into, b: impl Into) -> EdgeOrigin { + let (a, b) = (a.into(), b.into()); + if a <= b { EdgeOrigin::Meet { a, b } } else { EdgeOrigin::Meet { a: b, b: a } } + } + /// The same origin with its operands renumbered by `f`. - fn map_operand(self, f: impl Fn(usize) -> usize) -> EdgeOrigin { + fn map_operand(&self, f: impl Fn(usize) -> usize) -> EdgeOrigin { match self { - EdgeOrigin::Edge { operand, edge } => EdgeOrigin::Edge { operand: f(operand), edge }, - EdgeOrigin::Meet { a, b } => EdgeOrigin::Meet { a: a.map_operand(&f), b: b.map_operand(&f) }, + EdgeOrigin::Edge { operand, edge } => EdgeOrigin::Edge { operand: f(*operand), edge: *edge }, + EdgeOrigin::Meet { a, b } => EdgeOrigin::meet(a.map_operand(&f), b.map_operand(&f)), } } } @@ -143,20 +201,24 @@ impl History { } let ok = e.origins.iter().all(|o| match o { EdgeOrigin::Edge { operand, edge } => counts.get(*operand).is_some_and(|c| *edge < c.1), - EdgeOrigin::Meet { a, b } => face_ok(a) && face_ok(b), + EdgeOrigin::Meet { a, b } => a.origins().iter().chain(b.origins()).all(face_ok), }); if !ok { return Err(failed("an edge origin lies outside its operand")); } + if e.origins.iter().any(|o| matches!(o, EdgeOrigin::Meet { a, b } if a > b)) { + return Err(failed("a meeting's sides are out of order")); + } } Ok(()) } /// Compose with the history of the operation that made operand `k`: origins on operand `k` /// become that operation's origins, its operands (none, for a body made from a sketch) - /// taking `k`'s place in the operand list. Fails, without changing `self`, where a lineage - /// cannot be expressed (an offset of a face that operation generated, a blend of an edge - /// it generated, an edge between merged faces). + /// taking `k`'s place in the operand list; a meeting's side becomes the origins of the faces + /// it names (several where that operation merged faces). Fails, without changing `self`, + /// where a lineage cannot be expressed (an offset of a face that operation generated or + /// merged, a blend of an edge it generated, a meeting with a face it made from nothing). pub fn substitute(&mut self, k: usize, inner: &History) -> Result<()> { if k >= self.operands { return Err(failed("no such operand")); @@ -209,26 +271,30 @@ impl History { }; faces.push(FaceProv { origins, change }); } - // One side of a meeting: a single face of the composed operation. - let side = |o: FaceOrigin| -> Result { - match map_face(o)?.0.as_slice() { - [x] => Ok(*x), - _ => Err(failed("an edge meets a face the inner operation merged")), + // Split where an origin lies under several composed faces (pieces the inner operation + // split and this one joined back, or dropped all but one of, are not). + crate::record::mark_shared_split(&mut faces); + // One side of a meeting: the origins of what its faces were made from. + let side = |s: &Side| -> Result { + let mut origins = Vec::new(); + for o in s.origins() { + origins.extend(map_face(*o)?.0); } + Side::new(origins).ok_or_else(|| failed("an edge meets a face the inner operation made from nothing")) }; let mut edges = Vec::with_capacity(self.edges.len()); for e in &self.edges { let mut origins = BTreeSet::new(); for o in &e.origins { - match *o { - EdgeOrigin::Edge { operand, edge } if operand == k => { - origins.extend(inner_edge(edge)?.origins.iter().map(|x| x.map_operand(lifted))) + match o { + EdgeOrigin::Edge { operand, edge } if *operand == k => { + origins.extend(inner_edge(*edge)?.origins.iter().map(|x| x.map_operand(lifted))) } EdgeOrigin::Edge { .. } => { origins.insert(o.map_operand(outer)); } EdgeOrigin::Meet { a, b } => { - origins.insert(EdgeOrigin::Meet { a: side(a)?, b: side(b)? }); + origins.insert(EdgeOrigin::meet(side(a)?, side(b)?)); } } } @@ -268,9 +334,9 @@ impl History { let origins: BTreeSet = e .origins .iter() - .map(|o| match *o { - EdgeOrigin::Edge { operand, edge } => Ok(EdgeOrigin::Edge { operand: to(operand)?, edge }), - EdgeOrigin::Meet { a, b } => Ok(EdgeOrigin::Meet { a: face(a)?, b: face(b)? }), + .map(|o| match o { + EdgeOrigin::Edge { operand, edge } => Ok(EdgeOrigin::Edge { operand: to(*operand)?, edge: *edge }), + EdgeOrigin::Meet { a, b } => Ok(EdgeOrigin::meet(a.try_map(face)?, b.try_map(face)?)), }) .collect::>()?; edges.push(EdgeProv { faces: e.faces.clone(), origins: origins.into_iter().collect() }); diff --git a/crates/kernel/src/history_tests.rs b/crates/kernel/src/history_tests.rs index 637715c1..ea7ef05b 100644 --- a/crates/kernel/src/history_tests.rs +++ b/crates/kernel/src/history_tests.rs @@ -1,6 +1,6 @@ use solvecraft_geom::Vec3; -use super::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, History}; +use super::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, History, Side}; use crate::{Body, box_solid, transform}; fn face(operand: usize, face: usize) -> FaceOrigin { @@ -53,13 +53,13 @@ fn substitute_a_body_made_from_a_sketch() { let mut join = History { operands: 2, faces: vec![prov(vec![face(0, 2)], Change::Modified), prov(vec![face(1, 1)], Change::Kept)], - edges: vec![EdgeProv { faces: vec![0, 1], origins: vec![EdgeOrigin::Meet { a: face(0, 2), b: face(1, 1) }] }], + edges: vec![EdgeProv { faces: vec![0, 1], origins: vec![EdgeOrigin::meet(face(0, 2), face(1, 1))] }], }; join.substitute(1, &extrude).unwrap(); assert_eq!(join.operands, 1); assert_eq!(join.faces[0], prov(vec![face(0, 2)], Change::Modified)); assert_eq!(join.faces[1], prov(vec![FaceOrigin::Profile { segment: 3 }], Change::Generated)); - assert_eq!(join.edges[0].origins, vec![EdgeOrigin::Meet { a: face(0, 2), b: FaceOrigin::Profile { segment: 3 } }]); + assert_eq!(join.edges[0].origins, vec![EdgeOrigin::meet(face(0, 2), FaceOrigin::Profile { segment: 3 })]); } #[test] @@ -78,6 +78,13 @@ fn check_refuses_a_history_that_does_not_describe_the_body() { let mut short = h.clone(); short.faces.pop(); assert!(short.check(&a, &[&a]).is_err(), "face count"); + let mut reversed = h.clone(); + reversed.edges[0].origins = vec![EdgeOrigin::Meet { a: face(0, 2).into(), b: face(0, 1).into() }]; + assert!(reversed.check(&a, &[&a]).is_err(), "meeting sides out of order"); + reversed.edges[0].origins = vec![EdgeOrigin::meet(face(0, 2), Side::new([face(0, 1), face(0, 6)]).unwrap())]; + assert!(reversed.check(&a, &[&a]).is_err(), "a side's origin beyond the operand's faces"); + reversed.edges[0].origins = vec![EdgeOrigin::meet(face(0, 2), Side::new([face(0, 1), face(0, 5)]).unwrap())]; + reversed.check(&a, &[&a]).unwrap(); h.faces[0].origins = vec![face(0, 6)]; assert!(h.check(&a, &[&a]).is_err(), "origin beyond the operand's faces"); assert!(a.clone().with_history(h, &[&a]).is_err()); @@ -98,7 +105,7 @@ fn substitute_composes_kept_split_merged_and_generated_faces() { ], edges: vec![ EdgeProv { faces: vec![0, 1], origins: vec![EdgeOrigin::Edge { operand: 0, edge: 7 }] }, - EdgeProv { faces: vec![3, 4], origins: vec![EdgeOrigin::Meet { a: face(1, 4), b: FaceOrigin::Profile { segment: 2 } }] }, + EdgeProv { faces: vec![3, 4], origins: vec![EdgeOrigin::meet(face(1, 4), FaceOrigin::Profile { segment: 2 })] }, ], }; let mut op2 = History { @@ -113,7 +120,7 @@ fn substitute_composes_kept_split_merged_and_generated_faces() { ], edges: vec![ EdgeProv { faces: vec![0, 1], origins: vec![EdgeOrigin::Edge { operand: 1, edge: 1 }] }, - EdgeProv { faces: vec![2, 4], origins: vec![EdgeOrigin::Meet { a: face(1, 3), b: face(2, 1) }] }, + EdgeProv { faces: vec![2, 4], origins: vec![EdgeOrigin::meet(face(1, 3), face(2, 1))] }, EdgeProv { faces: vec![4, 5], origins: vec![EdgeOrigin::Edge { operand: 2, edge: 3 }] }, ], }; @@ -128,8 +135,8 @@ fn substitute_composes_kept_split_merged_and_generated_faces() { assert_eq!(f[4], prov(vec![face(0, 5), face(3, 1)], Change::Merged)); assert_eq!(f[5], prov(vec![FaceOrigin::Edge { operand: 1, edge: 7 }], Change::Generated)); let e = &op2.edges; - assert_eq!(e[0].origins, vec![EdgeOrigin::Meet { a: face(2, 4), b: FaceOrigin::Profile { segment: 2 } }]); - assert_eq!(e[1].origins, vec![EdgeOrigin::Meet { a: face(2, 4), b: face(3, 1) }]); + assert_eq!(e[0].origins, vec![EdgeOrigin::meet(face(2, 4), FaceOrigin::Profile { segment: 2 })]); + assert_eq!(e[1].origins, vec![EdgeOrigin::meet(face(2, 4), face(3, 1))]); assert_eq!(e[2].origins, vec![EdgeOrigin::Edge { operand: 3, edge: 3 }]); } @@ -148,12 +155,75 @@ fn substitute_refuses_lineage_it_cannot_express_and_leaves_history_unchanged() { // A blend of an edge the inner operation generated. let mut blend = History { operands: 1, faces: vec![prov(vec![FaceOrigin::Edge { operand: 0, edge: 0 }], Change::Generated)], edges: vec![] }; assert!(blend.substitute(0, &inner).is_err()); + // A meeting with a face the inner operation made from nothing. + let bare = History { operands: 0, faces: vec![prov(vec![], Change::Generated)], edges: vec![] }; + let mut meet = History { + operands: 1, + faces: vec![prov(vec![face(0, 0)], Change::Modified), prov(vec![FaceOrigin::Cap { end: true }], Change::Generated)], + edges: vec![EdgeProv { faces: vec![0, 1], origins: vec![EdgeOrigin::meet(face(0, 0), FaceOrigin::Cap { end: true })] }], + }; + let before = meet.clone(); + assert!(meet.substitute(0, &bare).is_err()); + assert_eq!(meet, before); // Out-of-range references and operands. let mut bad = History { operands: 1, faces: vec![prov(vec![face(0, 9)], Change::Kept)], edges: vec![] }; assert!(bad.substitute(0, &inner).is_err()); assert!(bad.substitute(3, &inner).is_err()); } +/// A side is a set: sorted, without repeats, never empty; a meeting puts the lesser side first. +#[test] +fn sides_and_meetings_are_canonical() { + let side = |o: &[FaceOrigin]| Side::new(o.iter().copied()).unwrap(); + assert_eq!(side(&[face(1, 2), face(0, 5), face(1, 2)]).origins(), [face(0, 5), face(1, 2)]); + assert!(Side::new([]).is_none()); + assert_eq!(Side::from(face(0, 1)).single(), Some(face(0, 1))); + assert_eq!(side(&[face(0, 1), face(0, 2)]).single(), None); + let (x, y) = (side(&[face(1, 0)]), side(&[face(0, 3), face(0, 4)])); + assert_eq!(EdgeOrigin::meet(x.clone(), y.clone()), EdgeOrigin::meet(y.clone(), x.clone())); + assert_eq!(EdgeOrigin::meet(x.clone(), y.clone()), EdgeOrigin::Meet { a: y, b: x }); + assert_eq!(EdgeOrigin::meet(face(2, 0), face(1, 0)), EdgeOrigin::meet(face(1, 0), face(2, 0))); +} + +/// An edge meeting a face the inner operation merged: that side becomes the merged face's +/// origins, and the other side stays one face. +#[test] +fn substitute_composes_a_meeting_with_a_merged_face() { + // Heal-like: the inner operation joined faces 1 and 2 of its operand into its face 0. + let inner = History { + operands: 1, + faces: vec![prov(vec![face(0, 1), face(0, 2)], Change::Merged), prov(vec![face(0, 0)], Change::Kept)], + edges: vec![EdgeProv { faces: vec![0, 1], origins: vec![EdgeOrigin::Edge { operand: 0, edge: 4 }] }], + }; + // Blend-like: a face in place of the inner edge 0, meeting both inner faces. + let mut outer = History { + operands: 1, + faces: vec![ + prov(vec![face(0, 0)], Change::Modified), + prov(vec![face(0, 1)], Change::Modified), + prov(vec![FaceOrigin::Edge { operand: 0, edge: 0 }], Change::Generated), + ], + edges: vec![ + EdgeProv { faces: vec![0, 2], origins: vec![EdgeOrigin::meet(face(0, 0), FaceOrigin::Edge { operand: 0, edge: 0 })] }, + EdgeProv { faces: vec![1, 2], origins: vec![EdgeOrigin::meet(face(0, 1), FaceOrigin::Edge { operand: 0, edge: 0 })] }, + ], + }; + outer.substitute(0, &inner).unwrap(); + let blend = Side::from(FaceOrigin::Edge { operand: 0, edge: 4 }); + let merged = Side::new([face(0, 2), face(0, 1)]).unwrap(); + assert_eq!(outer.faces[0], prov(vec![face(0, 1), face(0, 2)], Change::Merged)); + assert_eq!(outer.edges[0].origins, [EdgeOrigin::meet(blend.clone(), merged)]); + assert_eq!(outer.edges[1].origins, [EdgeOrigin::meet(blend, face(0, 0))]); + // Renumbering joins operands within a side, and keeps the meeting canonical. + let mut two = History { + operands: 2, + faces: vec![], + edges: vec![EdgeProv { faces: vec![], origins: vec![EdgeOrigin::meet(Side::new([face(0, 3), face(1, 3)]).unwrap(), face(1, 0))] }], + }; + two.renumber(&[Some(1), Some(1)], 2).unwrap(); + assert_eq!(two.edges[0].origins, [EdgeOrigin::meet(face(1, 0), face(1, 3))]); +} + #[test] fn face_sources_follow_kept_split_and_agreeing_merged_faces() { let a = box_solid(Vec3::ZERO, Vec3::new(4.0, 3.0, 1.0)).unwrap().with_box_certificate(7).unwrap(); diff --git a/crates/kernel/src/modify_history_tests.rs b/crates/kernel/src/modify_history_tests.rs index 419232c7..4d9f84c7 100644 --- a/crates/kernel/src/modify_history_tests.rs +++ b/crates/kernel/src/modify_history_tests.rs @@ -193,7 +193,7 @@ fn chamfer_cutters_mitre_at_a_corner() { } // 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 }])); + assert!(h.edges.iter().any(|e| e.origins == [EdgeOrigin::meet(a, c)])); } #[test] @@ -218,7 +218,7 @@ fn delete_a_fillet() { // 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) }])); + assert!(h.edges.iter().any(|e| e.origins == [EdgeOrigin::meet(x, y)])); } #[test] @@ -376,3 +376,60 @@ fn mirrored_copy_keeps_every_face_and_edge() { } } } + +/// The block with box cuts of each common shape: a notch through the back, a pocket, a step +/// along one end, a slot across the top, a hole through, a corner bitten out. +fn box_cuts() -> Vec<(&'static str, Body, Body)> { + let b = cube(); + [ + ("notch", Vec3::new(15.0, 25.0, -5.0), Vec3::new(25.0, 35.0, 25.0)), + ("pocket", Vec3::new(10.0, 10.0, 10.0), Vec3::new(30.0, 20.0, 25.0)), + ("step", Vec3::new(30.0, -5.0, 10.0), Vec3::new(45.0, 35.0, 25.0)), + ("slot", Vec3::new(15.0, -5.0, 10.0), Vec3::new(25.0, 35.0, 25.0)), + ("hole", Vec3::new(15.0, 10.0, -5.0), Vec3::new(25.0, 20.0, 25.0)), + ("bite", Vec3::new(30.0, 20.0, 10.0), Vec3::new(45.0, 35.0, 25.0)), + ] + .into_iter() + .map(|(name, lo, hi)| { + let tool = box_solid(lo, hi).unwrap(); + let cut = boolean(&b, &tool, BoolOp::Cut).unwrap().unwrap(); + (name, cut, tool) + }) + .collect() +} + +/// Meetings with a side of several origins (a face the modifier's heal merged). +fn merged_meetings(h: &History) -> usize { + h.edges.iter().flat_map(|e| &e.origins).filter(|o| matches!(o, EdgeOrigin::Meet { a, b } if a.single().is_none() || b.single().is_none())).count() +} + +/// A fillet right after a box cut. Where the cut leaves coplanar pieces (the step, slot and +/// bite leave the front face in pieces), the fillet's heal merges them, and the edge where the +/// round runs into that face meets all of it. Composed with the cut, over the block and the tool. +#[test] +fn fillet_after_box_cuts() { + let b = cube(); + let at = Vec3::new(5.0, 0.0, 20.0); + for (name, cut, tool) in box_cuts() { + let r = crate::fillet(&cut, &[at], 2.0).unwrap(); + let h = verified(&r, &cut, reach(2.0 * 1.5)); + assert_eq!(blends(h), [vec![edge(edge_at(&cut, at))]], "{name}"); + let merged = matches!(name, "step" | "slot" | "bite"); + assert_eq!(merged_meetings(h) > 0, merged, "{name}: {h:?}"); + let mut through = h.clone(); + through.substitute(0, cut.history().unwrap()).unwrap(); + through.check(&r, &[&b, &tool]).unwrap(); + } +} + +/// A chamfer right after a box cut whose cutter's cut goes the prism way: the cutter's walls meet +/// the cut body's walls where a segment was split off. +#[test] +fn chamfer_after_box_cuts() { + let at = Vec3::new(5.0, 0.0, 20.0); + for (name, cut, _) in box_cuts().into_iter().filter(|(name, ..)| matches!(*name, "notch" | "hole")) { + let r = crate::chamfer(&cut, &[at], 2.0).unwrap(); + let h = verified(&r, &cut, reach(2.0 * 1.5)); + assert_eq!(with(h, &[edge(edge_at(&cut, at))]).len(), 1, "{name}: {h:?}"); + } +} diff --git a/crates/kernel/src/polybool.rs b/crates/kernel/src/polybool.rs index 980438ba..8b0016e2 100644 --- a/crates/kernel/src/polybool.rs +++ b/crates/kernel/src/polybool.rs @@ -587,7 +587,7 @@ impl Sources { fn edge(&self, p: FaceOrigin, q: FaceOrigin, ends: (Vec3, Vec3), tol: f64) -> Option> { let (FaceOrigin::Face { operand: x, face: f }, FaceOrigin::Face { operand: y, face: g }) = (p, q) else { return None }; if x != y { - return Some(vec![EdgeOrigin::Meet { a: p.min(q), b: p.max(q) }]); + return Some(vec![EdgeOrigin::meet(p, q)]); } let Some(edges) = self.edges.get(x)?.as_ref() else { return Some(Vec::new()) }; if f == g { diff --git a/crates/kernel/src/prism_lineage.rs b/crates/kernel/src/prism_lineage.rs index 251ef2b3..5da871e0 100644 --- a/crates/kernel/src/prism_lineage.rs +++ b/crates/kernel/src/prism_lineage.rs @@ -13,7 +13,7 @@ use truck_modeling as mt; use super::{Index, Section, Tag, dist_to, inside}; use crate::BoolOp; use crate::body::{Body, edge_order, from_p3}; -use crate::history::{EdgeOrigin, FaceOrigin, FaceOrigin::Face, FaceProv}; +use crate::history::{EdgeOrigin, FaceOrigin, FaceOrigin::Face, FaceProv, Side}; /// A cap of an operand: a planar face square to the prism direction. struct Cap { @@ -188,7 +188,8 @@ impl Context<'_> { /// The origins of the edge between the wall over a segment tagged `t` and a cap lying on the /// operand faces `cap`: the operand's edge between the wall and its own cap there (the very - /// edge the segment was read off, when that is one), else where the wall meets the cap. + /// edge the segment was read off, when that is one), else where the wall meets the cap (all of + /// the faces it lies on). fn cap_edge(&self, t: Tag, cap: &BTreeSet) -> Option> { let (x, w) = (t.operand, t.wall?); let own: Vec = @@ -199,33 +200,32 @@ impl Context<'_> { return Some(vec![EdgeOrigin::Edge { operand: x, edge: e }]); } let along: Vec = own.iter().flat_map(|c| self.edges_between(x, [w, *c], None)).collect(); - match (along.as_slice(), cap.first()) { - ([e], _) => Some(vec![EdgeOrigin::Edge { operand: x, edge: *e }]), - ([], Some(o)) if cap.len() == 1 => { - let wall = Self::wall(t)?; - Some(vec![EdgeOrigin::Meet { a: wall.min(*o), b: wall.max(*o) }]) - } + match along.as_slice() { + [e] => Some(vec![EdgeOrigin::Edge { operand: x, edge: *e }]), + [] => Some(vec![EdgeOrigin::meet(Self::wall(t)?, Side::new(cap.iter().copied())?)]), _ => None, } } /// The origins of the edge between the walls over two segments where the first (tagged /// `t1`) runs into the second (`t2`): the operand's edge there between its walls (at the - /// operand vertex the segments meet at), where two operands' walls meet, or none within one - /// wall (a segment split there). + /// operand vertex the segments meet at, else the one edge between them), where two + /// operands' walls meet, or none within one wall (a segment split there). fn junction(&self, t1: Tag, t2: Tag) -> Option> { let (p, q) = (Self::wall(t1)?, Self::wall(t2)?); if t1.operand != t2.operand { - return Some(vec![EdgeOrigin::Meet { a: p.min(q), b: p.max(q) }]); + return Some(vec![EdgeOrigin::meet(p, q)]); } let (x, w1, w2) = (t1.operand, t1.wall?, t2.wall?); match (t1.end, t2.start) { - (Some(v), Some(u)) if v == u => match self.edges_between(x, [w1, w2], Some(v)).as_slice() { - [e] => Some(vec![EdgeOrigin::Edge { operand: x, edge: *e }]), - _ => None, - }, + // At that vertex: the operand's edge there (a seam, within one wall), else two edges + // of one wall's cap outline running on through it. + (Some(v), Some(u)) if v == u => self.one_edge(x, [w1, w2], Some(v)).or_else(|| (w1 == w2).then(Vec::new)), _ if w1 == w2 => Some(Vec::new()), - _ => None, + // Elsewhere (a segment split off there, or read off another cap): only the operand's + // walls are around the edge, so the operand has its edge between them there, and + // that is the one edge between them where there is one. + _ => self.one_edge(x, [w1, w2], None), } } @@ -238,10 +238,16 @@ impl Context<'_> { return Some(Vec::new()); } if t1.operand != t2.operand { - return Some(vec![EdgeOrigin::Meet { a: p.min(q), b: p.max(q) }]); + return Some(vec![EdgeOrigin::meet(p, q)]); } - match self.edges_between(t1.operand, [t1.wall?, t2.wall?], None).as_slice() { - [e] => Some(vec![EdgeOrigin::Edge { operand: t1.operand, edge: *e }]), + self.one_edge(t1.operand, [t1.wall?, t2.wall?], None) + } + + /// The operand's edge between the faces `faces` (touching vertex `at` when given), when it + /// has exactly one. + fn one_edge(&self, operand: usize, faces: [usize; 2], at: Option) -> Option> { + match self.edges_between(operand, faces, at).as_slice() { + [edge] => Some(vec![EdgeOrigin::Edge { operand, edge: *edge }]), _ => None, } } diff --git a/crates/kernel/src/record.rs b/crates/kernel/src/record.rs index 378e7639..95e6090b 100644 --- a/crates/kernel/src/record.rs +++ b/crates/kernel/src/record.rs @@ -166,7 +166,7 @@ pub(crate) fn attach(body: Body, operands: &[Operand], faces: Option = operands.iter().map(|o| (o.body.face_count(), o.edges.len())).collect(); body.with_history_counts(history, &counts) @@ -323,15 +323,16 @@ impl Rebuild { } /// 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). +/// `faces`): the meeting of the two faces (each side all of its face's origins, one or +/// several), when each comes from something and no origin is under both; else none (a seam, +/// an edge between pieces of one face, a face new to the operation). 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) }], + let side = |f: &usize| faces.get(*f).and_then(|p| crate::history::Side::new(p.origins.iter().copied())); + match between { + [f, g] => match (side(f), side(g)) { + (Some(a), Some(b)) if a.origins().iter().all(|o| !b.origins().contains(o)) => vec![EdgeOrigin::meet(a, b)], + _ => Vec::new(), + }, _ => Vec::new(), } } diff --git a/crates/kernel/src/record_tests.rs b/crates/kernel/src/record_tests.rs index 35814593..ec917fea 100644 --- a/crates/kernel/src/record_tests.rs +++ b/crates/kernel/src/record_tests.rs @@ -7,7 +7,7 @@ use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; use crate::body::Body; use crate::build::PathSeg; -use crate::history::{Change, FaceOrigin, History}; +use crate::history::{Change, EdgeOrigin, FaceOrigin, FaceProv, History, Side}; const P: fn(usize) -> FaceOrigin = |segment| FaceOrigin::Profile { segment }; const START: FaceOrigin = FaceOrigin::Cap { end: false }; @@ -550,3 +550,23 @@ fn emboss_reports_kept_trimmed_and_profile_faces() { } } } + +/// A new edge's meeting: each side all of its face's origins (a merged face's several); none +/// where an origin lies on both sides (pieces of one face) or a face comes from nothing. +#[test] +fn a_new_edge_meets_whole_faces() { + let f = |face: usize| FaceOrigin::Face { operand: 0, face }; + let blend = FaceOrigin::Edge { operand: 0, edge: 3 }; + let faces = vec![ + FaceProv { origins: vec![f(0), f(1)], change: Change::Merged }, + FaceProv { origins: vec![blend], change: Change::Generated }, + FaceProv { origins: vec![f(1)], change: Change::Split }, + FaceProv { origins: vec![], change: Change::Generated }, + ]; + let merged = Side::new([f(0), f(1)]).unwrap(); + assert_eq!(super::meeting(&[1, 0], &faces), [EdgeOrigin::meet(merged, blend)]); + assert_eq!(super::meeting(&[2, 1], &faces), [EdgeOrigin::meet(f(1), blend)]); + assert!(super::meeting(&[0, 2], &faces).is_empty()); + assert!(super::meeting(&[1, 3], &faces).is_empty()); + assert!(super::meeting(&[1], &faces).is_empty()); +}