diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 8eae9eb7..2016891e 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -458,6 +458,13 @@ impl Body { Ok(self) } + /// [`Body::with_history`], given the operands' face and edge counts. + pub(crate) fn with_history_counts(mut self, history: crate::history::History, operands: &[(usize, usize)]) -> Result { + history.check_counts((self.face_count(), self.edge_count()), operands)?; + self.history = Some(std::sync::Arc::new(history)); + Ok(self) + } + pub fn face_sources(&self) -> Option<&[Option]> { self.face_sources.as_deref().map(Vec::as_slice) } diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 7619f47b..fa3f4329 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -5,6 +5,9 @@ use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; use truck_modeling as mt; use crate::body::{Body, Solid, from_p3, p3, v3}; +use crate::history::FaceProv; +use crate::numbered::{self, NumberedLoop, NumberedRegion}; +use crate::record::{self, Side}; use crate::{KernelError, Result, guard}; const MAX_SEGS: usize = 20_000; @@ -60,34 +63,61 @@ fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, S Ok((edges.into(), sources)) } -/// A region's wires, its loops oriented as the builders build them (outer counter-clockwise, -/// holes clockwise); each edge with its segment; and each edge with the profile segments under -/// it (`origins[k][i]`: loop `k`, segment `i` as oriented; see [`crate::record::loop_origins`]). -#[allow(clippy::type_complexity)] -fn region_wires( - plane: &Plane, - r: &Region2, - origins: &[Vec>], -) -> Result<(Vec, Vec<(mt::Edge, Seg2)>, Vec<(mt::EdgeID, Vec)>)> { - let mut wires = Vec::with_capacity(1 + r.holes.len()); - let (mut sources, mut seeds) = (Vec::new(), Vec::new()); - for (k, lp) in std::iter::once(r.outer.ccw()).chain(r.holes.iter().map(|h| h.ccw().reversed())).enumerate() { - let (w, s) = wire_sources(plane, &lp)?; - for (edge, segment, i) in s { - if let Some(o) = origins.get(k).and_then(|l| l.get(i)) { - seeds.push((edge.id(), o.clone())); - } - sources.push((edge, segment)); - } - wires.push(w); +/// An edge of a profile wire: the segment it was built from and the profile segments under it. +pub(crate) struct ProfileEdge { + pub edge: mt::Edge, + pub segment: Seg2, + pub numbers: Vec, +} + +/// A wire built from a profile loop, with its edges in wire order. +pub(crate) struct ProfileWire { + pub wire: mt::Wire, + pub edges: Vec, +} + +impl ProfileWire { + /// The profile segments under each edge, in wire order. + fn numbers(&self) -> Vec> { + self.edges.iter().map(|e| e.numbers.clone()).collect() } - Ok((wires, sources, seeds)) +} + +/// The wires of a region oriented as the builders build it ([`NumberedRegion::oriented`]), each +/// with what its edges were built from. +fn region_wires(plane: &Plane, r: &NumberedRegion) -> Result> { + r.loops() + .map(|lp| { + let (wire, s) = wire_sources(plane, &lp.lp)?; + let edges = s.into_iter().map(|(edge, segment, i)| ProfileEdge { edge, segment, numbers: lp.numbers(i).to_vec() }).collect(); + Ok(ProfileWire { wire, edges }) + }) + .collect() +} + +/// The wires alone. +fn wires_of(profile: &[ProfileWire]) -> Vec { + profile.iter().map(|p| p.wire.clone()).collect() +} + +/// The profile edges as sweep seeds: each edge with the profile segments under it. +fn seeds(profile: &[ProfileWire]) -> Vec<(mt::EdgeID, Vec)> { + profile.iter().flat_map(|p| &p.edges).map(|e| (e.edge.id(), e.numbers.clone())).collect() +} + +/// A region numbered on its own, as given. +fn numbered_region(r: &Region2) -> Result { + numbered::number_regions(std::slice::from_ref(r)).pop().ok_or_else(|| KernelError::Invalid("profile".into())) +} + +/// A region numbered on its own and oriented as the builders build it. +fn oriented(r: &Region2) -> Result { + numbered_region(r).map(|n| n.oriented()) } /// A planar face from a region (outer loop with holes). pub(crate) fn face(plane: &Plane, r: &Region2) -> Result { - let (wires, ..) = region_wires(plane, r, &[])?; - attach_profile(&wires) + attach_profile(&wires_of(®ion_wires(plane, &oriented(r)?)?)) } fn attach_profile(wires: &[mt::Wire]) -> Result { @@ -139,78 +169,81 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu for r in regions { region_ok(r)?; let base = plane.offset(lo); - let (wires, sources, seeds) = region_wires(&base, r, &crate::record::loop_origins(std::slice::from_ref(r)))?; + let profile = region_wires(&base, &oriented(r)?)?; + let wires = wires_of(&profile); let f = guard("extrude profile", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))?; let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?; - let history = guard("extrude history", || Ok(crate::record::swept_face(&solid, &f, &seeds, false)))?; - let upper = guard("extrude cap identities", || { - let inputs: std::collections::HashSet<_> = sources.iter().map(|(edge, _)| edge.id()).collect(); + let body = Body::new(solid)?; + // tsweep retains each actual input boundary edge on its generated side: one pass over + // the faces matches topology identity (never face order, surface samples or + // centroids) for both the face sources and the history. The profile face is the start + // cap, the one face holding no profile edge the end cap. + let (sources, history) = guard("extrude source identities", || { + let by_edge: std::collections::HashMap = + profile.iter().flat_map(|p| &p.edges).map(|e| (e.edge.id(), e)).collect(); + let mut faces: Vec<(mt::FaceID, Vec<&ProfileEdge>)> = Vec::new(); let mut visits = 0usize; - let mut candidate = None; - for (index, generated) in solid.face_iter().enumerate() { - if index >= 50_000 { - return Ok(None); - } - let mut side = false; + for generated in body.solid.face_iter() { + let mut matching = Vec::new(); for edge in generated.absolute_boundaries().iter().flatten() { visits += 1; - if visits > 200_000 { - return Ok(None); + if let Some(e) = by_edge.get(&edge.id()) { + matching.push(*e); } - side |= inputs.contains(&edge.id()); - } - if generated.id() != f.id() && !side { - if candidate.is_some() { - return Ok(None); - } - candidate = Some(generated.id()); } + faces.push((generated.id(), matching)); } - Ok(candidate) - })?; - let body = Body::new(solid)?; - // tsweep retains each actual input boundary edge on its generated side. - // Match topology identity, never face order, surface samples or centroids. - let provenance = guard("extrude source identities", || { - let source_edges: std::collections::HashMap<_, _> = sources.iter().map(|(edge, segment)| (edge.id(), *segment)).collect(); - let mut result = Vec::new(); - for generated in body.solid.face_iter() { - let matching: Vec<_> = generated.absolute_boundaries().iter().flatten().filter_map(|edge| source_edges.get(&edge.id())).collect(); - let source = - if generated.id() == f.id() { - upper.and_then(|_| { - (r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::() <= 4096).then(|| { - crate::FaceSource::ExtrudeCap { - high: false, - region: r.clone(), - plane: Plane { origin: base.origin, x: plane.x, y: -plane.y }, - } - }) + let mut ends = faces.iter().filter(|(id, matching)| *id != f.id() && matching.is_empty()).map(|(id, _)| *id); + let end = match (ends.next(), ends.next()) { + (Some(id), None) => Some(id), + _ => None, + }; + let upper = end.filter(|_| faces.len() <= 50_000 && visits <= 200_000); + let small = r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::() <= 4096; + let mut sources = Vec::with_capacity(faces.len()); + let mut history = Some(Vec::with_capacity(faces.len())); + for (id, matching) in &faces { + let (source, prov) = if *id == f.id() { + let source = upper.and_then(|_| { + small.then(|| crate::FaceSource::ExtrudeCap { + high: false, + region: r.clone(), + plane: Plane { origin: base.origin, x: plane.x, y: -plane.y }, }) - } else if Some(generated.id()) == upper { - (r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::() <= 4096) - .then(|| crate::FaceSource::ExtrudeCap { high: true, region: r.clone(), plane: plane.offset(hi) }) - } else if let [segment] = matching.as_slice() { - let basis = match **segment { - Seg2::Line { a, b } => { - let d = b - a; - d.normalized().and_then(|direction| { - let foot = a - direction * a.dot(direction); - Plane::new(plane.to_world(foot), plane.dir_to_world(direction), n) - }) - } - _ => None, - }; - Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis, coalesced: false }) - } else { - None + }); + (source, Some(record::cap(false))) + } else if Some(*id) == upper { + (small.then(|| crate::FaceSource::ExtrudeCap { high: true, region: r.clone(), plane: plane.offset(hi) }), Some(record::cap(true))) + } else if let [side] = matching.as_slice() { + let basis = match side.segment { + Seg2::Line { a, b } => { + let d = b - a; + d.normalized().and_then(|direction| { + let foot = a - direction * a.dot(direction); + Plane::new(plane.to_world(foot), plane.dir_to_world(direction), n) + }) + } + _ => None, }; - result.push(source); + ( + Some(crate::FaceSource::ExtrudeSide { segment: side.segment, plane: basis, coalesced: false }), + Some(record::profile(&side.numbers)), + ) + } else if Some(*id) == end { + (None, Some(record::cap(true))) + } else { + (None, None) + }; + sources.push(source); + history = history.zip(prov).map(|(mut h, p)| { + h.push(p); + h + }); } - Ok(result) + Ok((sources, history)) })?; - let body = body.with_face_sources(Some(std::sync::Arc::new(provenance))); - out.push(crate::record::attach_if(body, &[], history)?); + let body = body.with_face_sources(Some(std::sync::Arc::new(sources))); + out.push(record::attach(body, &[], history, Default::default())?); } Ok(out) } @@ -265,20 +298,17 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: let q = axis_origin + d * along; if p.dist(q) < 1e-7 { q } else { p } }; - let snap_loop = |l: &Loop2| Loop2 { - segs: l - .segs - .iter() - .map(|s| match *s { - Seg2::Line { a, b } => Seg2::Line { a: snap(a), b: snap(b) }, - arc => arc, - }) - .collect(), + let snap_loop = |l: &NumberedLoop| { + l.map_segs(|s| match *s { + Seg2::Line { a, b } => Seg2::Line { a: snap(a), b: snap(b) }, + arc => arc, + }) }; // Full circles start at an odd angle: their seam points then don't lie in the planes - // through the profile's centre that other bodies tend to cut along. (Whether the loop - // was re-split comes with it.) - let reseam = |l: Loop2| -> (Loop2, bool) { + // through the profile's centre that other bodies tend to cut along. Each new arc comes + // from every given arc it overlaps. + let reseam = |nl: NumberedLoop| -> NumberedLoop { + let l = &nl.lp; let c = match l.segs.first() { Some(Seg2::Arc { center, radius, .. }) => Some((*center, *radius)), _ => None, @@ -306,45 +336,27 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: { let ccw = l.signed_area() > 0.0; let n = Loop2::circle_from(center, radius, 0.4142); - (if ccw { n } else { n.reversed() }, true) + nl.resplit_circle(if ccw { n } else { n.reversed() }) } - _ => (l, false), + _ => nl, } }; - // Each loop's profile segments as oriented; a circle the revolve re-split comes from - // every input arc it overlaps. - let mut origins = crate::record::loop_origins(std::slice::from_ref(r)); - let (outer, resplit) = reseam(snap_loop(&r.outer)); - let mut resplit = vec![resplit]; - let mut holes = Vec::with_capacity(r.holes.len()); - for h in &r.holes { - let (l, again) = reseam(snap_loop(h)); - holes.push(l); - resplit.push(again); - } - let given: Vec<&Loop2> = std::iter::once(&r.outer).chain(&r.holes).collect(); - let r = &Region2 { outer, holes }; - for (k, b) in std::iter::once(&r.outer).chain(&r.holes).enumerate() { - if resplit.get(k) == Some(&true) - && let Some(g) = given.get(k) - { - let base: usize = origins.iter().take(k).map(Vec::len).sum(); - let oriented = if k == 0 { b.ccw() } else { b.ccw().reversed() }; - if let Some(slot) = origins.get_mut(k) { - *slot = crate::record::arc_overlaps(&oriented.segs, &g.segs) - .into_iter() - .map(|js| js.into_iter().map(|j| base + j).collect()) - .collect(); - } - } - } + let given = numbered_region(r)?.map_loops(|l| reseam(snap_loop(l))); + let r = &given.region(); // A half disc on the axis revolved fully is a sphere: build it without poles. Every // patch is swept from the half disc's arcs. if angle.abs() >= std::f64::consts::TAU - 1e-9 && r.holes.is_empty() && let Some((c, rad)) = half_disc(&r.outer, axis_origin, d) { - let arcs: Vec = r.outer.segs.iter().enumerate().filter(|(_, s)| matches!(s, Seg2::Arc { .. })).map(|(i, _)| i).collect(); + let arcs: Vec = r + .outer + .segs + .iter() + .enumerate() + .filter(|(_, s)| matches!(s, Seg2::Arc { .. })) + .flat_map(|(i, _)| given.outer.numbers(i).to_vec()) + .collect(); out.push(sphere_solid(plane.to_world(c), rad, [Vec3::X, Vec3::Y, Vec3::Z], &arcs)?); continue; } @@ -352,13 +364,13 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: // as a cone-like shell (no zero-area faces, which booleans can't handle). if angle.abs() >= std::f64::consts::TAU - 1e-9 && r.holes.is_empty() - && let Some(b) = revolve_touching_axis(plane, &r.outer, axis_origin, d)? + && let Some(b) = revolve_touching_axis(plane, &given.outer, axis_origin, d)? { out.push(b); continue; } - let (wires, _, seeds) = region_wires(plane, r, &origins)?; - let f = attach_profile(&wires)?; + let profile = region_wires(plane, &given.oriented())?; + let f = attach_profile(&wires_of(&profile))?; // Partial revolves sweep toward the side the sketch normal points to (as features // extrude toward it). let c = plane.to_world(r.centroid()); @@ -367,15 +379,15 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: let angle = if toward < 0.0 { -angle } else { angle }; // A profile with an edge on the axis: no zero-area face where that edge turns on itself. if r.holes.is_empty() - && let Some(b) = revolve_partial_on_axis(plane, &r.outer, axis_origin, d, angle)? + && let Some(b) = revolve_partial_on_axis(plane, &given.outer, axis_origin, d, angle)? { out.push(b); continue; } let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?; let closed = angle.abs() >= std::f64::consts::TAU; - let history = guard("revolve history", || Ok(crate::record::swept_face(&solid, &f, &seeds, closed)))?; - out.push(crate::record::attach_if(Body::new(solid)?, &[], history)?); + let history = guard("revolve history", || Ok(record::swept_face(&solid, &f, &seeds(&profile), closed)))?; + out.push(record::attach(Body::new(solid)?, &[], history, Default::default())?); } Ok(out) } @@ -522,19 +534,22 @@ pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f region_ok(region)?; let d = length * taper.tan(); let top_plane = plane.offset(length * dir_sign.signum()); - let loops0: Vec = std::iter::once(region.outer.ccw()).chain(region.holes.iter().map(|h| h.ccw().reversed())).collect(); - let loops1: Vec = loops0.iter().map(|l| offset_loop(l, d)).collect::>()?; - let origins = crate::record::loop_origins(std::slice::from_ref(region)); + let loops0 = oriented(region)?; + let loops1: Vec = loops0.loops().map(|l| offset_loop(&l.lp, d)).collect::>()?; guard("tapered extrude", || { - let (w0, _, seeds) = region_wires(plane, region, &origins)?; + let p0 = region_wires(plane, &loops0)?; + let w0 = wires_of(&p0); let w1: Vec = loops1.iter().map(|l| wire(&top_plane, l)).collect::>()?; - let mut faces: Vec = Vec::new(); - for (a, b) in w0.iter().zip(&w1) { - let sides = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("taper sides: {e}")))?; - faces.extend(sides.face_iter().cloned()); + let mut sides: Vec = Vec::new(); + for (a, b) in p0.iter().zip(&w1) { + let shell = builder::try_wire_homotopy(&a.wire, b).map_err(|e| KernelError::Failed(format!("taper sides: {e}")))?; + // The offset loop keeps the loop's segments, edge for edge. + let numbers = a.numbers(); + sides.extend(record::ruled(&shell, &a.wire, b, &numbers, &numbers)); } + let (faces, prov): (Vec, Vec>) = sides.into_iter().unzip(); // The sides, then the bottom and top as pushed below. - let history = crate::record::capped_sides(&faces, &seeds, crate::record::Column::Steps(1)); + let history: Option> = prov.into_iter().chain([Some(record::cap(false)), Some(record::cap(true))]).collect(); let bottom = builder::try_attach_plane(&w0.iter().map(|w| w.inverse()).collect::>()) .map_err(|e| KernelError::Failed(format!("taper bottom: {e}")))?; let top = builder::try_attach_plane(&w1).map_err(|e| KernelError::Failed(format!("taper top: {e}")))?; @@ -547,7 +562,7 @@ pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f all.push(t); let shell: mt::Shell = all.into(); match Solid::try_new(vec![shell]) { - Ok(s) => return crate::record::attach_if(Body::new(s)?, &[], history), + Ok(s) => return record::attach(Body::new(s)?, &[], history, Default::default()), Err(e) => last = e.to_string(), } } @@ -576,8 +591,9 @@ fn half_disc(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Option<(Vec2, f64)> { /// A partial revolve of a loop with one edge on the axis: the other edges sweep their faces, /// the two caps (the profile at the start and at the end) share the axis edge. `None` when the /// loop isn't like that. -fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2, angle: f64) -> Result> { +fn revolve_partial_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2, angle: f64) -> Result> { use mt::{BoundedCurve, Invertible, ParametricCurve, ParametricSurface3D, SearchNearestParameter, Transformed}; + let lp = &numbered.lp; let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9; let n = lp.segs.len(); let axis_segs: Vec = @@ -632,8 +648,9 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2 *slot = Some(builder::circle_arc(a, b, p3(turn(&half, *p)))); } } - // Side faces: each edge's swept surface, oriented to agree with its boundary. - let mut faces: Vec = Vec::new(); + // Side faces: each edge's swept surface, oriented to agree with its boundary, from the + // profile segment the edge was built from. + let mut faces: Vec<(mt::Face, FaceProv)> = Vec::new(); for (i, sg) in chain.iter().enumerate() { let (Some(ei), Some(eri)) = (e.get(i), er.get(i)) else { return Err(fail("edges")) }; let mut ws: Vec = vec![ei.clone()]; @@ -673,7 +690,7 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2 } mt::Face::try_new(vec![wire], surface).map_err(|er| fail(&er.to_string()))? }; - faces.push(f); + faces.push((f, record::profile(numbered.numbers((k + 1 + i) % n)))); } // Caps: the profile at the start and at the end, sharing the axis edge. let (Some(a0), Some(an)) = (pv.first(), pv.get(last)) else { return Err(fail("ends")) }; @@ -682,6 +699,8 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2 let end: mt::Wire = er.iter().cloned().chain(std::iter::once(axis_edge.clone())).collect::>().into(); let cs = builder::try_attach_plane(&[start]).map_err(|er| fail(&er.to_string()))?; let ce = builder::try_attach_plane(&[end]).map_err(|er| fail(&er.to_string()))?; + let (faces, mut history): (Vec, Vec) = faces.into_iter().unzip(); + history.extend([record::cap(false), record::cap(true)]); let mut last_err = String::new(); for flip in 0..4 { let mut all = faces.clone(); @@ -690,9 +709,7 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2 match Solid::try_new(vec![all.into()]) { Ok(solid) => { let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid }; - // The faces as built: one per chain segment, then the start and end caps. - let history = crate::record::walls_then_caps((0..chain.len()).map(|i| vec![(k + 1 + i) % n])); - return crate::record::attach(Body::new(solid)?, &[], history).map(Some); + return record::attach(Body::new(solid)?, &[], Some(history), Default::default()).map(Some); } Err(er) => last_err = er.to_string(), } @@ -701,15 +718,17 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2 }) } -fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Result> { +fn revolve_touching_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2) -> Result> { + let lp = &numbered.lp; let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9; let n = lp.segs.len(); let axis_segs: Vec = (0..n).filter(|i| lp.segs.get(*i).is_some_and(|s| matches!(s, Seg2::Line { a, b } if on_axis(*a) && on_axis(*b)))).collect(); let [k] = axis_segs[..] else { return Ok(None) }; - // The open chain after the axis segment, back around to it, with each piece's segment. + // The open chain after the axis segment, back around to it, with the profile segments + // under each piece. let mut chain: Vec = (1..n).filter_map(|j| lp.segs.get((k + j) % n).copied()).collect(); - let mut chain_src: Vec = (1..n).map(|j| (k + j) % n).collect(); + let mut chain_src: Vec<&[usize]> = (1..n).map(|j| numbered.numbers((k + j) % n)).collect(); // A single arc (a sphere) is split off-centre: a seam on the equator would lie in the very // planes other bodies are often cut with. if let [Seg2::Arc { center, radius, start, sweep }] = chain[..] { @@ -718,7 +737,7 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) Seg2::Arc { center, radius, start, sweep: sweep * f }, Seg2::Arc { center, radius, start: start + sweep * f, sweep: sweep * (1.0 - f) }, ]; - chain_src = vec![(k + 1) % n; 2]; + chain_src = vec![numbered.numbers((k + 1) % n); 2]; } if chain.is_empty() || chain.iter().skip(1).take(chain.len().saturating_sub(2)).any(|s| on_axis(s.start()) && on_axis(s.end())) { return Ok(None); @@ -731,7 +750,7 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) // Radial lines at the ends become planar caps (no degenerate disc centres). let radial = |sg: &Seg2| matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9)); let mut mid: &[Seg2] = &chain; - let mut mid_src: &[usize] = &chain_src; + let mut mid_src: &[&[usize]] = &chain_src; // The segments of the radial lines that become caps. let (mut start_cap, mut end_cap) = (None, None); if mid.first().is_some_and(|f| radial(f) && on_axis(f.start())) { @@ -767,6 +786,8 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) }, }); } + // Each edge with the profile segments under it, before the wire may be turned round. + let seeds: Vec<(mt::EdgeID, Vec)> = edges.iter().map(|e| e.id()).zip(mid_src.iter().map(|s| s.to_vec())).collect(); let mut w: mt::Wire = edges.into(); let axis = plane.dir_to_world(d); let mut shell = if apex_start || apex_end { @@ -779,25 +800,15 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) builder::rsweep(&w, p3(plane.to_world(axis_origin)), v3(axis), mt::Rad(std::f64::consts::TAU)) }; let sides = shell.len(); - // History. truck's full turn makes its faces division by division (three), each - // division one face per wire edge in wire order; the wire runs backwards when it was - // turned for the cone. Each cap is the disc a trimmed radial line sweeps, found by the - // profile vertex on its rim. - let per = sides / 3; - let side_src = |i: usize| -> Option { - let j = i % per.max(1); - let j = if mid.len() == 1 { - 0 - } else if apex_end && !apex_start { - mid.len().checked_sub(j + 1)? - } else { - j - }; - mid_src.get(j).copied() - }; - let mut history: Option> = (sides % 3 == 0 && (per == mid.len() || (mid.len() == 1 && per == 2))) - .then(|| (0..sides).map(|i| side_src(i).map(|seg| crate::record::profile(&[seg]))).collect::>>()) - .flatten(); + // History. truck sweeps each wire edge round into a column of faces (triangles at an + // apex, which stays put); each cap is the disc a trimmed radial line sweeps, found by + // the profile vertex on its rim. + let fixed: Vec = + [(apex_start, verts.first()), (apex_end, verts.last())].into_iter().filter_map(|(apex, v)| v.filter(|_| apex).map(|v| v.id())).collect(); + let mut history: Option> = (seeds.len() == mid.len()) + .then(|| record::columns(shell.face_iter(), &seeds, None, record::Column::ToEnd, &fixed)) + .flatten() + .and_then(|sides| sides.into_iter().collect()); let (first_vertex, last_vertex) = (verts.first().map(|v| v.id()), verts.last().map(|v| v.id())); for b in shell.extract_boundaries() { let at = |v: Option| v.is_some_and(|v| b.vertex_iter().any(|x| x.id() == v)); @@ -808,8 +819,8 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) } else { None }; - history = history.zip(src).map(|(mut h, seg)| { - h.push(crate::record::profile(&[seg])); + history = history.zip(src).map(|(mut h, numbers)| { + h.push(record::profile(numbers)); h }); let cap = builder::try_attach_plane(&[b]).map_err(|e| KernelError::Failed(format!("revolve cap: {e}")))?; @@ -825,12 +836,16 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) }); let solid = solid.map_err(|e| KernelError::Failed(format!("revolve: {e}")))?; let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid }; - crate::record::attach_if(Body::new(solid)?, &[], history).map(Some) + record::attach(Body::new(solid)?, &[], history, Default::default()).map(Some) }) } -/// Close a ruled shell (side faces between matching wires) with planar caps into a body. -pub(crate) fn cap_and_close(mut faces: Vec, w0: &[mt::Wire], w1: &[mt::Wire]) -> Result { +/// Close a ruled shell (side faces between matching wires, with their provenance as built) +/// with planar caps into a body, whose history is the sides' and the caps': the profile at +/// `w0` is the start cap and at `w1` the end cap (the other way round when `from_end`). +pub(crate) fn cap_and_close(sides: Vec, w0: &[mt::Wire], w1: &[mt::Wire], from_end: bool) -> Result { + let (mut faces, prov): (Vec, Vec>) = sides.into_iter().unzip(); + let history: Option> = prov.into_iter().chain([Some(record::cap(from_end)), Some(record::cap(!from_end))]).collect(); let bottom = builder::try_attach_plane(&w0.iter().map(|w| w.inverse()).collect::>()).map_err(|e| KernelError::Failed(format!("cap: {e}")))?; let top = builder::try_attach_plane(w1).map_err(|e| KernelError::Failed(format!("cap: {e}")))?; @@ -842,7 +857,7 @@ pub(crate) fn cap_and_close(mut faces: Vec, w0: &[mt::Wire], w1: &[mt: all.push(t); let shell: mt::Shell = all.into(); match Solid::try_new(vec![shell]) { - Ok(s) => return Body::new(s), + Ok(s) => return record::attach(Body::new(s)?, &[], history, Default::default()), Err(e) => last = e.to_string(), } faces = keep; @@ -850,48 +865,6 @@ pub(crate) fn cap_and_close(mut faces: Vec, w0: &[mt::Wire], w1: &[mt: Err(KernelError::Failed(format!("closing the shell: {last}"))) } -/// Re-split a loop into exactly `n` segments: a full circle gets breaks at `angles` (or evenly), -/// other loops split their longest segments in half. With each new segment, the segments of -/// `lp` it runs over. -fn resample(lp: &Loop2, n: usize, angles: &[f64]) -> (Loop2, Vec>) { - let circle = lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. })) - && lp.segs.windows(2).all(|w| match (w[0], w[1]) { - (Seg2::Arc { center: c1, radius: r1, .. }, Seg2::Arc { center: c2, radius: r2, .. }) => c1.dist(c2) < 1e-9 && (r1 - r2).abs() < 1e-9, - _ => false, - }); - if circle && let Some(Seg2::Arc { center, radius, .. }) = lp.segs.first().copied() { - let mut a: Vec = if angles.len() == n { angles.to_vec() } else { (0..n).map(|i| std::f64::consts::TAU * i as f64 / n as f64).collect() }; - // Keep the given order but make them increase. - for i in 1..a.len() { - while a[i] <= a[i - 1] { - a[i] += std::f64::consts::TAU; - } - } - let segs: Vec = (0..n) - .map(|i| { - let s = a[i]; - let e = if i + 1 < n { a[i + 1] } else { a[0] + std::f64::consts::TAU }; - Seg2::Arc { center, radius, start: s, sweep: e - s } - }) - .collect(); - let src = crate::record::arc_overlaps(&segs, &lp.segs); - return (Loop2 { segs }, src); - } - let mut segs = lp.segs.clone(); - let mut src: Vec> = (0..segs.len()).map(|i| vec![i]).collect(); - while segs.len() < n { - let Some((k, _)) = segs.iter().enumerate().max_by(|a, b| a.1.length().total_cmp(&b.1.length())) else { break }; - let s = segs.remove(k); - let (p, q) = s.split_half(); - segs.insert(k, q); - segs.insert(k, p); - if let Some(from) = src.get(k).cloned() { - src.insert(k, from); - } - } - (Loop2 { segs }, src) -} - /// Ruled loft through two or more planar sections (outer loops only). The sections and the /// same sections in reverse order give the same body. pub fn loft(sections: &[(Plane, Loop2)]) -> Result { @@ -912,90 +885,66 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result { let step = centre(last) - centre(first); let big = [step.x, step.y, step.z].into_iter().max_by(|a, b| a.abs().total_cmp(&b.abs())).unwrap_or(0.0); let reversed = big < 0.0; - // (Each section with the profile number of its first segment: the sections' segments are - // numbered in the order given.) - let mut ordered: Vec<(usize, &(Plane, Loop2))> = - sections.iter().scan(0, |acc, s| Some((std::mem::replace(acc, *acc + s.1.segs.len()), s))).collect(); + // The sections' segments are numbered in the order given. + let mut ordered: Vec<(Plane, NumberedLoop)> = + sections.iter().map(|(p, _)| *p).zip(numbered::number_loops(sections.iter().map(|(_, l)| l))).collect(); if reversed { ordered.reverse(); } // Orient every section counter-clockwise about the first section's normal (`Body::new` // turns the solid outward), then match segment counts and starting points to the section // with the most segments. - let n0 = ordered.first().map(|(_, (p, _))| p.normal()).unwrap_or(Vec3::Z); - // (With each section, the profile segment number of each of its segments as oriented.) - let oriented: Vec<(Plane, Loop2, Vec)> = ordered + let n0 = ordered.first().map(|(p, _)| p.normal()).unwrap_or(Vec3::Z); + let oriented: Vec<(Plane, NumberedLoop)> = ordered .iter() - .map(|(base, (p, l))| { + .map(|(p, l)| { let ccw = p.normal().dot(n0) >= 0.0; - let numbers = crate::record::ccw_numbers(l, !ccw, *base); - let l = if ccw { l.ccw() } else { l.ccw().reversed() }; - (*p, l, numbers) + (*p, l.oriented(!ccw)) }) .collect(); - let lead = oriented.iter().max_by_key(|(_, l, _)| l.segs.len()).cloned().ok_or_else(|| KernelError::Invalid("no sections".into()))?; - let lead_c = lead.0.to_world(lead.1.centroid()); - let lead_dirs: Vec = lead.1.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect(); + let lead = oriented.iter().max_by_key(|(_, l)| l.lp.segs.len()).ok_or_else(|| KernelError::Invalid("no sections".into()))?; + let lead_c = lead.0.to_world(lead.1.lp.centroid()); + let lead_dirs: Vec = lead.1.lp.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect(); let mut wires: Vec = Vec::new(); // Per section, the profile segments under each of its wire's edges. let mut edge_sources: Vec>> = Vec::new(); - for (p, l, numbers) in &oriented { - let c = l.centroid(); + for (p, l) in &oriented { + let c = l.lp.centroid(); // Break angles: the lead section's vertex directions seen in this plane. let angles: Vec = lead_dirs.iter().map(|d| Vec2::new(d.dot(p.x), d.dot(p.y)).angle()).collect(); - let (mut r, mut src) = resample(l, n, &angles); + let mut r = l.resample(n, &angles); // Start at the vertex closest in direction to the lead's first vertex. if let Some(d0) = lead_dirs.first() { let target = Vec2::new(d0.dot(p.x), d0.dot(p.y)).angle(); - let best = (0..r.segs.len()).min_by(|a, b| { - let ang = |k: usize| { - let v = r.segs[k].start() - c; - let d = (v.angle() - target).rem_euclid(std::f64::consts::TAU); - d.min(std::f64::consts::TAU - d) - }; - ang(*a).total_cmp(&ang(*b)) - }); + let ang = |s: &Seg2| { + let d = ((s.start() - c).angle() - target).rem_euclid(std::f64::consts::TAU); + d.min(std::f64::consts::TAU - d) + }; + let best = r.lp.segs.iter().enumerate().min_by(|a, b| ang(a.1).total_cmp(&ang(b.1))).map(|(k, _)| k); if let Some(k) = best { - r.segs.rotate_left(k); - src.rotate_left(k); + r.rotate_left(k); } } - edge_sources.push(src.iter().map(|js| js.iter().filter_map(|j| numbers.get(*j).copied()).collect()).collect()); - wires.push(uniform_wire(p, &r)?); + wires.push(uniform_wire(p, &r.lp)?); + edge_sources.push(r.from); } - // The caps: the wires run from the first section given, or (turned round) the last. - let caps = [crate::record::cap(reversed), crate::record::cap(!reversed)]; + // The wires run from the first section given, or (turned round) the last. // Three sections or more: one smooth surface through them all for each segment. if wires.len() >= 3 - && let Ok(b) = guard("loft", || smooth_loft(&wires)) + && let Ok(b) = guard("loft", || smooth_loft(&wires, &edge_sources, reversed)) { - // Its faces as built: one per edge position k, through the k-th edge of every - // section, then the caps. - let faces = (0..n) - .map(|k| crate::record::profile_union(edge_sources.iter().filter_map(|s| s.get(k)).flatten().copied())) - .chain(caps.clone()) - .collect(); - return crate::record::attach(b, &[], faces); + return Ok(b); } guard("loft", || { - let mut faces: Vec = Vec::new(); - for w in wires.windows(2) { - let sides = builder::try_wire_homotopy(&w[0], &w[1]).map_err(|e| KernelError::Failed(format!("loft: {e}")))?; - faces.extend(sides.face_iter().cloned()); + // Each ruled face lies between two sections: it comes from the section edges it spans. + let mut sides: Vec = Vec::new(); + for (w, src) in wires.windows(2).zip(edge_sources.windows(2)) { + let (Some(a), Some(b), Some(na), Some(nb)) = (w.first(), w.get(1), src.first(), src.get(1)) else { continue }; + let shell = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("loft: {e}")))?; + sides.extend(record::ruled(&shell, a, b, na, nb)); } - // Each ruled face lies between two sections: it comes from the section edges it holds. - let under: std::collections::HashMap> = - wires.iter().zip(&edge_sources).flat_map(|(w, src)| w.edge_iter().map(|e| e.id()).zip(src)).collect(); - let history: Option> = faces - .iter() - .map(|f| { - let held: Vec<&Vec> = f.absolute_boundaries().iter().flatten().filter_map(|e| under.get(&e.id()).copied()).collect(); - (held.len() == 2).then(|| crate::record::profile_union(held.iter().flat_map(|s| s.iter().copied()))) - }) - .chain(caps.iter().cloned().map(Some)) - .collect(); let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("loft".into())) }; - crate::record::attach_if(cap_and_close(faces, std::slice::from_ref(first), std::slice::from_ref(last))?, &[], history) + cap_and_close(sides, std::slice::from_ref(first), std::slice::from_ref(last), reversed) }) } @@ -1013,8 +962,10 @@ fn across(pts: &[Vec3]) -> Option> { /// A loft whose side faces are B-spline surfaces through every section (smooth across the /// middle sections, which leave no edges). Every section wire comes from `uniform_wire`, so the -/// k-th edges of all sections share one knot vector and their control points pair up. -fn smooth_loft(wires: &[mt::Wire]) -> Result { +/// k-th edges of all sections share one knot vector and their control points pair up. Face k +/// comes from the profile segments under the k-th edge of every section (`numbers`: by +/// section, by edge); the caps as [`cap_and_close`] takes them (`from_end`). +fn smooth_loft(wires: &[mt::Wire], numbers: &[Vec>], from_end: bool) -> Result { let fail = |m: &str| KernelError::Failed(format!("smooth loft: {m}")); let edges: Vec> = wires.iter().map(|w| w.edge_iter().cloned().collect()).collect(); let n = edges.first().map(Vec::len).ok_or_else(|| fail("no sections"))?; @@ -1061,10 +1012,12 @@ fn smooth_loft(wires: &[mt::Wire]) -> Result { }; // u along the section edge, v across the sections: counter-clockwise in (u, v). let w: mt::Wire = vec![e0.clone(), s1.clone(), e1.inverse(), s0.inverse()].into(); - faces.push(mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))?); + let face = mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))?; + let under: Vec<&usize> = numbers.iter().filter_map(|s| s.get(k)).flatten().collect(); + faces.push((face, (under.len() >= numbers.len()).then(|| record::profile(under)))); } let (Some(w0), Some(w1)) = (wires.first(), wires.last()) else { return Err(fail("sections")) }; - cap_and_close(faces, std::slice::from_ref(w0), std::slice::from_ref(w1)) + cap_and_close(faces, std::slice::from_ref(w0), std::slice::from_ref(w1), from_end) } /// A 3D path segment for sweeps. @@ -1146,7 +1099,8 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result return crate::sweep_path::sweep_path(plane, region, &pts); } // The outer loop and each hole, swept along the path; the ends are capped with holes. - let (starts, _, seeds) = region_wires(plane, region, &crate::record::loop_origins(std::slice::from_ref(region)))?; + let profile = region_wires(plane, &oriented(region)?)?; + let starts = wires_of(&profile); guard("sweep", || { let mut shell = mt::Shell::new(); let mut ends = Vec::new(); @@ -1170,10 +1124,13 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result } ends.push(cur); } - let faces: Vec = shell.face_iter().cloned().collect(); - // Each profile edge's faces run along the whole path, path segment after segment. - let history = crate::record::capped_sides(&faces, &seeds, crate::record::Column::ToEnd); - crate::record::attach_if(cap_and_close(faces, &starts, &ends)?, &[], history) + // truck sweeps the faces: each profile edge's run along the whole path, path segment + // after segment. + let sides: Vec = match record::columns(shell.face_iter(), &seeds(&profile), None, record::Column::ToEnd, &[]) { + Some(prov) => shell.face_iter().cloned().zip(prov).collect(), + None => shell.face_iter().map(|f| (f.clone(), None)).collect(), + }; + cap_and_close(sides, &starts, &ends, false) }) } @@ -1342,6 +1299,6 @@ fn sphere_solid(center: Vec3, radius: f64, rot: [Vec3; 3], arcs: &[usize]) -> Re } let shell: mt::Shell = out.into(); let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sphere: {e}")))?; - crate::record::attach(Body::new(solid)?, &[], vec![crate::record::profile(arcs); 6]) + record::attach(Body::new(solid)?, &[], Some(vec![record::profile(arcs); 6]), Default::default()) }) } diff --git a/crates/kernel/src/emboss.rs b/crates/kernel/src/emboss.rs index 245f7267..fec9639f 100644 --- a/crates/kernel/src/emboss.rs +++ b/crates/kernel/src/emboss.rs @@ -1,7 +1,9 @@ //! Exact radial embossing of orthogonal profiles on complete cylindrical walls. use crate::body::{Body, Solid, from_p3, p3}; use crate::history::Change; +use crate::numbered::NumberedRegion; use crate::offset::{Surf, surf_of}; +use crate::record::Operand; use crate::{KernelError, Result, guard}; use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; use truck_modeling::{self as mt, Transformed, builder}; @@ -255,7 +257,6 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi let s0 = (plane.origin - o).dot(tangent); let map2 = |p: Vec2| Vec2::new(s0 + plane.dir_to_world(p).dot(tangent), z0 + plane.dir_to_world(p).dot(axis)); let mut count = 0usize; - let mut mapped = Vec::new(); // The regions mapped onto the wall as given (their loops' turning may differ from the // sketch's): profile numbering follows them. let mut unoriented = Vec::new(); @@ -294,12 +295,11 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi } loops.push(Loop2 { segs }); } - let outer = loops.first().ok_or_else(|| fail("profile"))?.ccw(); - let holes = loops.iter().skip(1).map(|l| l.ccw().reversed()).collect(); - mapped.push(Region2 { outer, holes }); let mut given = loops.into_iter(); unoriented.push(Region2 { outer: given.next().ok_or_else(|| fail("profile"))?, holes: given.collect() }); } + let numbered: Vec = crate::numbered::number_regions(&unoriented).iter().map(NumberedRegion::oriented).collect(); + let mapped: Vec = numbered.iter().map(NumberedRegion::region).collect(); validate_profiles(&mapped, tol)?; if qualify { let segment = mapped.first().and_then(|region| region.outer.segs.first()).ok_or_else(|| fail("empty profile"))?; @@ -343,16 +343,13 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi // History of the faces added (walls from their profile segments, the raised or sunk // region the profile's end cap), in the order added. let mut added_history = Vec::new(); - let mut origins = crate::record::loop_origins(&unoriented).into_iter(); - for reg in &mapped { + for reg in &numbered { let mut cap = Vec::new(); - for (li, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() { + for (li, numbered_loop) in reg.loops().enumerate() { + let lp = &numbered_loop.lp; let (base, top, walls) = wrapped_loop(lp, r, rr, &at)?; - let loop_origins = origins.next().ok_or_else(|| fail("profile numbering"))?; - if loop_origins.len() != walls.len() { - return Err(fail("profile numbering")); - } - added_history.extend(loop_origins.iter().map(|o| crate::record::profile(o))); + // One wall per segment, in order. + added_history.extend((0..walls.len()).map(|j| crate::record::profile(numbered_loop.numbers(j)))); if li == 0 { footprints.push((lp, base.inverse())); } else { @@ -383,7 +380,7 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi // gathered: the other faces kept, the wall's pieces left around the footprints (split // when the profile's holes leave more than one), then the faces added. let pieces = if base_patches.len() > 1 { Change::Split } else { Change::Modified }; - let mut history: Vec<_> = (0..faces.len()).filter(|i| *i != fi).map(|i| crate::record::from_face(0, i, Change::Kept)).collect(); + let mut history: Vec<_> = crate::record::kept_except(faces.len(), fi).collect(); history.extend(std::iter::repeat_n(crate::record::from_face(0, fi, pieces), base_patches.len())); history.extend(added_history); for patch in base_patches { @@ -395,7 +392,7 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi if !result.validity().is_empty() { return Err(fail("rebuilt cylinder is not a closed manifold")); } - Ok(EmbossOutcome::Body(crate::record::attach(result, &[body], history)?)) + Ok(EmbossOutcome::Body(crate::record::attach(result, &[Operand::new(body)], Some(history), Default::default())?)) }) } diff --git a/crates/kernel/src/helix.rs b/crates/kernel/src/helix.rs index 8aef6877..12780859 100644 --- a/crates/kernel/src/helix.rs +++ b/crates/kernel/src/helix.rs @@ -6,6 +6,8 @@ use truck_modeling as mt; use crate::body::{Body, Solid, p3}; use crate::build::{cap_and_close, uniform_wire}; +use crate::numbered::NumberedLoop; +use crate::record::{self, Side}; use crate::{KernelError, Result, guard}; /// Rotate `v` about the unit axis `k` by `ang` (Rodrigues). @@ -26,20 +28,19 @@ pub fn sweep_helix(plane: &Plane, region: &Region2, axis_origin: Vec3, axis: Vec if region.area().abs() < 1e-9 { return Err(KernelError::Invalid("profile has no area".into())); } - // The profile must clear itself after one turn. - let lp = region.outer.ccw(); + // The profile must clear itself after one turn. (Only the outer loop is swept.) + let numbered = crate::numbered::number_regions(std::slice::from_ref(region)).pop().ok_or_else(|| KernelError::Invalid("profile".into()))?; + let profile = numbered.outer.oriented(false); + let lp = &profile.lp; let along: Vec = lp.segs.iter().flat_map(|s| (0..8).map(move |i| s.point_at(i as f64 / 8.0))).map(|q| (plane.to_world(q) - axis_origin).dot(k)).collect(); let extent = along.iter().cloned().fold(f64::NEG_INFINITY, f64::max) - along.iter().cloned().fold(f64::INFINITY, f64::min); if turns > 1.0 && pitch.abs() <= extent { return Err(KernelError::Invalid(format!("the coil runs into itself: the pitch must exceed the section ({extent:.3} mm)"))); } - // Profile numbering (only the outer loop is swept). - let origins = crate::record::loop_origins(std::slice::from_ref(region)).into_iter().next().unwrap_or_default(); // One smooth B-spline wall per profile segment; the ruled walls below are the fallback. - if let Ok(b) = smooth_helix(plane, &lp, axis_origin, k, pitch, turns) { - // Its faces as built: one wall per segment, then the caps. - return crate::record::attach(b, &[], crate::record::walls_then_caps(origins)); + if let Ok(b) = smooth_helix(plane, &profile, axis_origin, k, pitch, turns) { + return Ok(b); } let n = ((turns * 120.0).ceil() as usize).clamp(4, 24_000); let total = std::f64::consts::TAU * turns; @@ -49,18 +50,18 @@ pub fn sweep_helix(plane: &Plane, region: &Region2, axis_origin: Vec3, axis: Vec let lift = k * (pitch * a / std::f64::consts::TAU); let o = axis_origin + rot(plane.origin - axis_origin, k, a) + lift; let pl = Plane { origin: o, x: rot(plane.x, k, a), y: rot(plane.y, k, a) }; - wires.push(uniform_wire(&pl, &lp)?); + wires.push(uniform_wire(&pl, lp)?); } guard("helix sweep", || { - let mut faces: Vec = Vec::new(); + // Every station's wire has the profile's segments, edge for edge. + let mut faces: Vec = Vec::new(); for w in wires.windows(2) { - let sides = builder::try_wire_homotopy(&w[0], &w[1]).map_err(|e| KernelError::Failed(format!("helix: {e}")))?; - faces.extend(sides.face_iter().cloned()); + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + let sides = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("helix: {e}")))?; + faces.extend(record::ruled(&sides, a, b, &profile.from, &profile.from)); } let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("helix".into())) }; - let seeds: Vec<(mt::EdgeID, Vec)> = first.edge_iter().map(|e| e.id()).zip(origins).collect(); - let history = crate::record::capped_sides(&faces, &seeds, crate::record::Column::ToEnd); - crate::record::attach_if(cap_and_close(faces, std::slice::from_ref(first), std::slice::from_ref(last))?, &[], history) + cap_and_close(faces, std::slice::from_ref(first), std::slice::from_ref(last), false) }) } @@ -109,7 +110,8 @@ const STATIONS_PER_TURN: f64 = 12.0; /// Helical walls as B-spline surfaces: each profile segment, sampled at every station of the /// screw motion, interpolated by cubics across the profile and then along the helix. -fn smooth_helix(plane: &Plane, lp: &Loop2, o: Vec3, k: Vec3, pitch: f64, turns: f64) -> Result { +fn smooth_helix(plane: &Plane, profile: &NumberedLoop, o: Vec3, k: Vec3, pitch: f64, turns: f64) -> Result { + let lp = &profile.lp; let nseg = lp.segs.len(); if nseg == 0 || nseg > 64 { return Err(KernelError::Invalid("helix profile".into())); @@ -171,7 +173,8 @@ fn smooth_helix(plane: &Plane, lp: &Loop2, o: Vec3, k: Vec3, pitch: f64, turns: }; // Counter-clockwise in the surface's (u along the helix, v along the profile) space. let w: mt::Wire = vec![sa.clone(), b.clone(), sb.inverse(), a.inverse()].into(); - walls.push(mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surf.clone())).map_err(|e| fail(&e.to_string()))?); + let wall = mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surf.clone())).map_err(|e| fail(&e.to_string()))?; + walls.push((wall, Some(record::profile(profile.numbers(j))))); } let w0: mt::Wire = e0.iter().cloned().collect(); let w1: mt::Wire = e1.iter().cloned().collect(); @@ -179,8 +182,8 @@ fn smooth_helix(plane: &Plane, lp: &Loop2, o: Vec3, k: Vec3, pitch: f64, turns: let expected = lp_area_path(plane, lp, o, k, turns); // A coarse mesh (within ~10%) is enough to tell a proper solid from an empty or inside-out one. let section_tol = (lp.signed_area().abs().sqrt() * 0.05).max(1e-3); - for ws in [walls.clone(), walls.iter().map(|f| f.inverse()).collect()] { - if let Ok(b) = cap_and_close(ws, std::slice::from_ref(&w0), std::slice::from_ref(&w1)) + for ws in [walls.clone(), walls.iter().map(|(f, prov)| (f.inverse(), prov.clone())).collect()] { + if let Ok(b) = cap_and_close(ws, std::slice::from_ref(&w0), std::slice::from_ref(&w1), false) && b.tessellate(section_tol).is_ok_and(|m| (m.measure().volume - expected).abs() <= 0.15 * expected.abs()) { return Ok(b); @@ -211,30 +214,27 @@ pub fn loft_to_point(plane: &Plane, lp: &Loop2, apex: Vec3) -> Result { } // Wind the base so its normal points away from the apex. let above = (apex - plane.origin).dot(plane.normal()) > 0.0; - let numbers = crate::record::ccw_numbers(lp, above, 0); - let mut lp = lp.ccw(); - if above { - lp = lp.reversed(); - } + let base = crate::numbered::number_loops([lp]).pop().ok_or_else(|| KernelError::Invalid("loft to a point: bad section".into()))?.oriented(above); + let lp = &base.lp; guard("loft to point", || { let verts: Vec = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect(); let top = builder::vertex(p3(apex)); let m = verts.len(); - let base: Vec = (0..m).filter_map(|i| Some(builder::line(verts.get(i)?, verts.get((i + 1) % m)?))).collect(); + let edges: Vec = (0..m).filter_map(|i| Some(builder::line(verts.get(i)?, verts.get((i + 1) % m)?))).collect(); let sides: Vec = verts.iter().map(|v| builder::line(v, &top)).collect(); let mut faces = Vec::with_capacity(m + 1); // History as the faces are made: the base is the section (the start cap), each // triangle comes from its base segment. let mut history = Vec::with_capacity(m + 1); - let bw: mt::Wire = base.iter().cloned().collect(); + let bw: mt::Wire = edges.iter().cloned().collect(); faces.push(builder::try_attach_plane(&[bw]).map_err(|e| KernelError::Failed(format!("loft base: {e}")))?); - history.push(crate::record::cap(false)); + history.push(record::cap(false)); for i in 0..m { - let (Some(e), Some(up), Some(down), Some(n)) = (base.get(i), sides.get((i + 1) % m), sides.get(i), numbers.get(i)) else { continue }; + let (Some(e), Some(up), Some(down)) = (edges.get(i), sides.get((i + 1) % m), sides.get(i)) else { continue }; // Triangle i: along the base edge, up to the apex, back down — opposite to the base's winding. let w: mt::Wire = vec![e.inverse(), down.clone(), up.inverse()].into(); faces.push(builder::try_attach_plane(&[w]).map_err(|e| KernelError::Failed(format!("loft side: {e}")))?); - history.push(crate::record::profile(&[*n])); + history.push(record::profile(base.numbers(i))); } let shell: mt::Shell = faces.into(); let body = match Solid::try_new(vec![shell.clone()]) { @@ -244,7 +244,7 @@ pub fn loft_to_point(plane: &Plane, lp: &Loop2, apex: Vec3) -> Result { Body::new(Solid::try_new(vec![inv]).map_err(|e| KernelError::Failed(format!("loft to point: {e}")))?)? } }; - crate::record::attach(body, &[], history) + record::attach(body, &[], Some(history), Default::default()) }) } diff --git a/crates/kernel/src/history.rs b/crates/kernel/src/history.rs index 493fd16f..82fedf14 100644 --- a/crates/kernel/src/history.rs +++ b/crates/kernel/src/history.rs @@ -117,13 +117,18 @@ impl History { /// Check that this history describes `result` made from `operands`: one entry per face and /// edge, and every origin within its operand's faces and edges. pub fn check(&self, result: &Body, operands: &[&Body]) -> Result<()> { - if self.operands != operands.len() { + let counts: Vec<(usize, usize)> = operands.iter().map(|b| (b.face_count(), b.edge_count())).collect(); + self.check_counts((result.face_count(), result.edge_count()), &counts) + } + + /// [`History::check`] against the result's and each operand's face and edge counts. + pub(crate) fn check_counts(&self, result: (usize, usize), counts: &[(usize, usize)]) -> Result<()> { + if self.operands != counts.len() { return Err(failed("operand count does not match")); } - if self.faces.len() != result.face_count() || self.edges.len() != result.edge_count() { + if self.faces.len() != result.0 || self.edges.len() != result.1 { return Err(failed("entries do not match the result's faces and edges")); } - let counts: Vec<(usize, usize)> = operands.iter().map(|b| (b.face_count(), b.edge_count())).collect(); let face_ok = |o: &FaceOrigin| match *o { FaceOrigin::Face { operand, face } | FaceOrigin::Offset { operand, face } => counts.get(operand).is_some_and(|c| face < c.0), FaceOrigin::Edge { operand, edge } => counts.get(operand).is_some_and(|c| edge < c.1), diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 2f912546..ad8cbe33 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -39,6 +39,7 @@ mod loopblend; mod measure; mod mesh_convert; mod meshbody; +mod numbered; mod offset; mod ops; mod polybool; diff --git a/crates/kernel/src/lip.rs b/crates/kernel/src/lip.rs index 9418eac3..7a53698a 100644 --- a/crates/kernel/src/lip.rs +++ b/crates/kernel/src/lip.rs @@ -8,6 +8,7 @@ use truck_modeling::{self as mt, builder}; use crate::body::{Body, Solid, from_p3, p3, v3}; use crate::history::{Change, FaceOrigin, FaceProv}; +use crate::record::Operand; use crate::{KernelError, Result, guard}; fn fail(m: &str) -> KernelError { @@ -177,10 +178,10 @@ pub fn raise_band(body: &Body, at: Vec3, width: f64, height: f64, outside: bool) }); } // The loop's edges and the band's edge beside each, by the loop edge's index in the body. - let edge_index = crate::record::edge_index(body); + let operand = Operand::new(body); let mut wall_edge: std::collections::HashMap = std::collections::HashMap::new(); for (e, ce) in along.edge_iter().zip(&cedges) { - if let Some(i) = edge_index.get(&e.id()) { + if let Some(i) = operand.edges.get(&e.id()) { wall_edge.insert(e.id(), *i); wall_edge.insert(ce.id(), *i); } @@ -204,9 +205,8 @@ pub fn raise_band(body: &Body, at: Vec3, width: f64, height: f64, outside: bool) // kept, the face trimmed to what the band leaves, each wall made along the loop // edge it rises from (the band's inner wall along the edge it runs beside), and the // band's top an offset of the face. (Flipping a face keeps its edges.) - let history: Option> = (0..faces.len()) - .filter(|i| *i != fi) - .map(|i| Some(crate::record::from_face(0, i, Change::Kept))) + let history: Option> = crate::record::kept_except(faces.len(), fi) + .map(Some) .chain([Some(crate::record::from_face(0, fi, Change::Modified))]) .chain(wall_along.face_iter().chain(wall_c.face_iter()).map(|w| { let edge = w.absolute_boundaries().iter().flatten().find_map(|e| wall_edge.get(&e.id()))?; @@ -235,7 +235,7 @@ pub fn raise_band(body: &Body, at: Vec3, width: f64, height: f64, outside: bool) Ok(s) => { let b = Body::new(s)?; if b.validity().is_empty() { - return crate::record::attach_if(b, &[body], history.clone()); + return crate::record::attach(b, std::slice::from_ref(&operand), history.clone(), Default::default()); } last = b.validity().join("; "); } diff --git a/crates/kernel/src/numbered.rs b/crates/kernel/src/numbered.rs new file mode 100644 index 00000000..3c895b2d --- /dev/null +++ b/crates/kernel/src/numbered.rs @@ -0,0 +1,195 @@ +//! Profile loops whose segments carry the profile segments they come from, through every way the +//! builders reshape a loop before building (turning it round, re-seaming a circle, re-splitting +//! it to match another section). [`FaceOrigin::Profile`](crate::history::FaceOrigin::Profile) +//! numbers are defined here: the segments of the caller's regions in order, each region its outer +//! loop and then its holes ([`profile_loops`]). + +use std::collections::BTreeSet; + +use solvecraft_geom::{Loop2, Region2, Seg2}; + +/// The loops of `regions` in profile order: each region's outer loop, then its holes. +pub(crate) fn profile_loops(regions: &[Region2]) -> impl Iterator { + regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) +} + +/// A loop with, for each of its segments, the profile segments it runs over (one, until the loop +/// is re-split; a segment split in two leaves both pieces over it). +#[derive(Clone, Debug)] +pub(crate) struct NumberedLoop { + pub lp: Loop2, + pub from: Vec>, +} + +/// A region of numbered loops (outer, then holes). +#[derive(Clone, Debug)] +pub(crate) struct NumberedRegion { + pub outer: NumberedLoop, + pub holes: Vec, +} + +/// `loops` numbered one after the other from 0, in the order given. +pub(crate) fn number_loops<'a>(loops: impl IntoIterator) -> Vec { + let mut base = 0; + loops + .into_iter() + .map(|lp| { + let numbered = NumberedLoop { lp: lp.clone(), from: (base..base + lp.segs.len()).map(|n| vec![n]).collect() }; + base += lp.segs.len(); + numbered + }) + .collect() +} + +/// `regions` numbered in profile order ([`profile_loops`]), as given (not yet oriented). +pub(crate) fn number_regions(regions: &[Region2]) -> Vec { + let mut loops = number_loops(profile_loops(regions)).into_iter(); + regions.iter().filter_map(|r| Some(NumberedRegion { outer: loops.next()?, holes: loops.by_ref().take(r.holes.len()).collect() })).collect() +} + +impl NumberedLoop { + /// The profile segments under segment `i`. + pub(crate) fn numbers(&self, i: usize) -> &[usize] { + self.from.get(i).map(Vec::as_slice).unwrap_or(&[]) + } + + /// Run the other way round. + pub(crate) fn reversed(&self) -> NumberedLoop { + NumberedLoop { lp: self.lp.reversed(), from: self.from.iter().rev().cloned().collect() } + } + + /// Counter-clockwise (as [`Loop2::ccw`]); clockwise when `hole`. Every builder orients its + /// loops this way, through [`NumberedRegion::oriented`] or this. + pub(crate) fn oriented(&self, hole: bool) -> NumberedLoop { + let ccw = if self.lp.is_ccw() { self.clone() } else { self.reversed() }; + if hole { ccw.reversed() } else { ccw } + } + + /// Each segment replaced by `f` of it (the same segment, moved). + pub(crate) fn map_segs(&self, f: impl Fn(&Seg2) -> Seg2) -> NumberedLoop { + NumberedLoop { lp: Loop2 { segs: self.lp.segs.iter().map(f).collect() }, from: self.from.clone() } + } + + /// Start at segment `k`. + pub(crate) fn rotate_left(&mut self, k: usize) { + if k < self.lp.segs.len() && self.from.len() == self.lp.segs.len() { + self.lp.segs.rotate_left(k); + self.from.rotate_left(k); + } + } + + /// This loop (a full circle) re-split as `pieces` (arcs of the same circle): each piece runs + /// over every arc of this loop it overlaps. + pub(crate) fn resplit_circle(&self, pieces: Loop2) -> NumberedLoop { + let from = arc_overlaps(&pieces.segs, &self.lp.segs).into_iter().map(|js| union(js.iter().map(|j| self.numbers(*j)))).collect(); + NumberedLoop { lp: pieces, from } + } + + /// Re-split into exactly `n` segments: a full circle gets breaks at `angles` (or evenly), + /// other loops split their longest segments in half. + pub(crate) fn resample(&self, n: usize, angles: &[f64]) -> NumberedLoop { + let lp = &self.lp; + let circle = lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. })) + && lp.segs.windows(2).all(|w| match (w.first(), w.get(1)) { + (Some(Seg2::Arc { center: c1, radius: r1, .. }), Some(Seg2::Arc { center: c2, radius: r2, .. })) => { + c1.dist(*c2) < 1e-9 && (r1 - r2).abs() < 1e-9 + } + _ => false, + }); + if circle && let Some(Seg2::Arc { center, radius, .. }) = lp.segs.first().copied() { + let mut a: Vec = + if angles.len() == n { angles.to_vec() } else { (0..n).map(|i| std::f64::consts::TAU * i as f64 / n as f64).collect() }; + // Keep the given order but make them increase. + for i in 1..a.len() { + let Some(prev) = a.get(i - 1).copied() else { continue }; + if let Some(x) = a.get_mut(i) { + while *x <= prev { + *x += std::f64::consts::TAU; + } + } + } + let segs: Vec = (0..n) + .filter_map(|i| { + let s = *a.get(i)?; + let e = if i + 1 < n { *a.get(i + 1)? } else { a.first()? + std::f64::consts::TAU }; + Some(Seg2::Arc { center, radius, start: s, sweep: e - s }) + }) + .collect(); + return self.resplit_circle(Loop2 { segs }); + } + let mut out = self.clone(); + while out.lp.segs.len() < n { + let Some((k, _)) = out.lp.segs.iter().enumerate().max_by(|a, b| a.1.length().total_cmp(&b.1.length())) else { break }; + out.split_half(k); + } + out + } + + /// Split segment `k` in half, both halves over what it ran over. + fn split_half(&mut self, k: usize) { + if k >= self.lp.segs.len() || k >= self.from.len() { + return; + } + let s = self.lp.segs.remove(k); + let (p, q) = s.split_half(); + self.lp.segs.insert(k, q); + self.lp.segs.insert(k, p); + let from = self.from.get(k).cloned().unwrap_or_default(); + self.from.insert(k, from); + } +} + +impl NumberedRegion { + /// The region as the builders build it: outer loop counter-clockwise, holes clockwise. + pub(crate) fn oriented(&self) -> NumberedRegion { + NumberedRegion { outer: self.outer.oriented(false), holes: self.holes.iter().map(|h| h.oriented(true)).collect() } + } + + /// Each loop replaced by `f` of it. + pub(crate) fn map_loops(&self, f: impl Fn(&NumberedLoop) -> NumberedLoop) -> NumberedRegion { + NumberedRegion { outer: f(&self.outer), holes: self.holes.iter().map(f).collect() } + } + + /// The outer loop, then the holes. + pub(crate) fn loops(&self) -> impl Iterator { + std::iter::once(&self.outer).chain(&self.holes) + } + + /// The region's geometry. + pub(crate) fn region(&self) -> Region2 { + Region2 { outer: self.outer.lp.clone(), holes: self.holes.iter().map(|h| h.lp.clone()).collect() } + } +} + +/// Every number of `sets`, once each, in order. +fn union<'a>(sets: impl IntoIterator) -> Vec { + sets.into_iter().flatten().copied().collect::>().into_iter().collect() +} + +/// Which arcs of `originals` (arcs of one circle) each arc of `pieces` (arcs of the same +/// circle) runs over: a circle re-split at other angles comes from every original arc it +/// overlaps. +fn arc_overlaps(pieces: &[Seg2], originals: &[Seg2]) -> Vec> { + use std::f64::consts::TAU; + // An arc as its counter-clockwise start angle and (positive) sweep. + let span = |s: &Seg2| match *s { + Seg2::Arc { start, sweep, .. } => Some(if sweep < 0.0 { (start + sweep, -sweep) } else { (start, sweep) }), + _ => None, + }; + let eps = 1e-9; + pieces + .iter() + .map(|p| { + let Some((p0, pw)) = span(p) else { return Vec::new() }; + (0..originals.len()) + .filter(|&j| { + originals.get(j).and_then(span).is_some_and(|(o0, ow)| { + // The original's start, seen from the piece's start going round. + let d = (o0 - p0).rem_euclid(TAU); + d < pw - eps || d + ow > TAU + eps || ow >= TAU - eps + }) + }) + .collect() + }) + .collect() +} diff --git a/crates/kernel/src/provenance.rs b/crates/kernel/src/provenance.rs index b46703fd..62898dad 100644 --- a/crates/kernel/src/provenance.rs +++ b/crates/kernel/src/provenance.rs @@ -178,7 +178,7 @@ pub fn trace(result: &Body, inputs: &[&Body]) -> Result { /// The profile segments of the loops, in [`FaceOrigin::Profile`] order (outer, then holes). fn segments(regions: &[Region2]) -> Vec { - regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(r.holes.iter())).flat_map(|l| l.segs.iter().copied()).collect() + crate::numbered::profile_loops(regions).flat_map(|l| l.segs.iter().copied()).collect() } /// The segment nearest the 2D points (by mean distance). diff --git a/crates/kernel/src/record.rs b/crates/kernel/src/record.rs index 232e2183..957ac056 100644 --- a/crates/kernel/src/record.rs +++ b/crates/kernel/src/record.rs @@ -3,97 +3,39 @@ //! topology identity inside the building call: which face a profile edge was swept into, //! which edges an operand still owns. Nothing is sampled. //! -//! Profile numbering: the segments of the builder's regions in the caller's order (each region -//! its outer loop, then its holes), as [`FaceOrigin::Profile`] counts them. Builders turn loops -//! counter-clockwise (holes clockwise) before building; [`loop_origins`] undoes that. +//! Faces our code builds are paired with their provenance as they are made (a [`Side`]); only +//! where truck makes the faces itself (`tsweep`, `rsweep`, `cone`) are they found afterwards, +//! by following each profile edge's column of swept faces ([`follow`]). Profile segments are +//! numbered by [`crate::numbered`]. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; -use solvecraft_geom::{Loop2, Region2, Seg2}; use truck_modeling as mt; use crate::Result; use crate::body::{Body, edge_order}; use crate::history::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, History}; -/// The profile number of each segment of `lp` as `ccw()` orders it (as `ccw().reversed()` does -/// for a hole), the loop's own first segment being number `base`. -pub(crate) fn ccw_numbers(lp: &Loop2, hole: bool, base: usize) -> Vec { - let n = lp.segs.len(); - let reversed = lp.is_ccw() == hole; - (0..n).map(|j| base + if reversed { n - 1 - j } else { j }).collect() -} +/// A face as built, with its provenance (`None` where the construction could not be followed). +pub(crate) type Side = (mt::Face, Option); -/// For every loop of `regions` (each region's outer loop, then its holes), the profile -/// segments under each of its segments in the order the builders orient the loop (one each, -/// until a builder re-splits the loop). -pub(crate) fn loop_origins(regions: &[Region2]) -> Vec>> { - let mut base = 0; - let mut out = Vec::new(); - for r in regions { - for (k, lp) in std::iter::once(&r.outer).chain(&r.holes).enumerate() { - out.push(ccw_numbers(lp, k > 0, base).into_iter().map(|n| vec![n]).collect()); - base += lp.segs.len(); - } - } - out -} - -/// Which arcs of `originals` (arcs of one circle) each arc of `pieces` (arcs of the same -/// circle) runs over: a circle re-split at other angles comes from every original arc it -/// overlaps. -pub(crate) fn arc_overlaps(pieces: &[Seg2], originals: &[Seg2]) -> Vec> { - use std::f64::consts::TAU; - // An arc as its counter-clockwise start angle and (positive) sweep. - let span = |s: &Seg2| match *s { - Seg2::Arc { start, sweep, .. } => Some(if sweep < 0.0 { (start + sweep, -sweep) } else { (start, sweep) }), - _ => None, - }; - let eps = 1e-9; - pieces - .iter() - .map(|p| { - let Some((p0, pw)) = span(p) else { return Vec::new() }; - (0..originals.len()) - .filter(|&j| { - originals.get(j).and_then(span).is_some_and(|(o0, ow)| { - // The original's start, seen from the piece's start going round. - let d = (o0 - p0).rem_euclid(TAU); - d < pw - eps || d + ow > TAU + eps || ow >= TAU - eps - }) - }) - .collect() - }) - .collect() -} - -/// A face made from profile segments `numbers`. -pub(crate) fn profile(numbers: &[usize]) -> FaceProv { - generated(numbers.iter().map(|&segment| FaceOrigin::Profile { segment }).collect()) -} - -/// A face made from all of `numbers` (in any order, repeats allowed). -pub(crate) fn profile_union(numbers: impl IntoIterator) -> FaceProv { - let numbers: std::collections::BTreeSet = numbers.into_iter().collect(); +/// A face made from all of profile segments `numbers` (in any order, repeats allowed). +pub(crate) fn profile<'a>(numbers: impl IntoIterator) -> FaceProv { + let numbers: BTreeSet = numbers.into_iter().copied().collect(); generated(numbers.into_iter().map(|segment| FaceOrigin::Profile { segment }).collect()) } -/// Walls from the given profile segments in order, then the start and end caps: the face -/// order of every builder that closes its walls with [`crate::build::cap_and_close`]. -pub(crate) fn walls_then_caps(walls: impl IntoIterator>) -> Vec { - walls.into_iter().map(|w| profile(&w)).chain([cap(false), cap(true)]).collect() -} - -/// The index of each of `body`'s edges by identity, in its edge order. -pub(crate) fn edge_index(body: &Body) -> HashMap { - edge_order(body.solid.face_iter()).0.iter().enumerate().map(|(i, e)| (e.id(), i)).collect() -} - /// A face of operand `operand` and how this one relates to it. pub(crate) fn from_face(operand: usize, face: usize, change: Change) -> FaceProv { FaceProv { origins: vec![FaceOrigin::Face { operand, face }], change } } +/// Every face of the one operand (with `count` faces) but `except`, kept as it is, in order: +/// the faces a builder carries over unchanged when it rebuilds a body around one face. +pub(crate) fn kept_except(count: usize, except: usize) -> impl Iterator { + (0..count).filter(move |i| *i != except).map(|i| from_face(0, i, Change::Kept)) +} + /// A start (`end == false`) or end cap. pub(crate) fn cap(end: bool) -> FaceProv { generated(vec![FaceOrigin::Cap { end }]) @@ -104,26 +46,62 @@ pub(crate) fn generated(origins: Vec) -> FaceProv { FaceProv { origins, change: Change::Generated } } -/// Attach to `body`, made from `operands`, the history whose faces are `faces` (in its face -/// order). Each edge's origin is the operand edge it is (the very edge, kept by the building -/// code), if any. -pub(crate) fn attach(body: Body, operands: &[&Body], faces: Vec) -> Result { - attach_edges(body, operands, faces, HashMap::new()) +/// The index of each of `body`'s edges by identity, in its edge order. +fn edge_index(body: &Body) -> HashMap { + edge_order(body.solid.face_iter()).0.iter().enumerate().map(|(i, e)| (e.id(), i)).collect() } -/// [`attach`], with `copies`: result edges the building code made as exact copies of operand -/// edges (the same curve, rebuilt), and which. -pub(crate) fn attach_edges(body: Body, operands: &[&Body], faces: Vec, copies: HashMap) -> Result { +/// An operation's operand, with its edges indexed by identity (in its edge order). +pub(crate) struct Operand<'a> { + pub body: &'a Body, + pub edges: HashMap, +} + +impl<'a> Operand<'a> { + pub(crate) fn new(body: &'a Body) -> Operand<'a> { + Operand { body, edges: edge_index(body) } + } +} + +/// Attach to `body`, made from `operands`, the history whose faces are `faces` (in its face +/// order); a body whose faces could not be followed (`None`) has no history. Each edge's +/// origin is the operand edge it is (the very edge, kept by the building code), or what +/// `copies` says: result edges the building code made as exact copies of operand edges. +pub(crate) fn attach(body: Body, operands: &[Operand], faces: Option>, copies: HashMap) -> Result { + let Some(faces) = faces else { return Ok(body) }; let mut owned = copies; - for (operand, b) in operands.iter().enumerate() { - for (id, edge) in edge_index(b) { - owned.entry(id).or_insert(EdgeOrigin::Edge { operand, edge }); + for (operand, o) in operands.iter().enumerate() { + for (id, edge) in &o.edges { + owned.entry(*id).or_insert(EdgeOrigin::Edge { operand, edge: *edge }); } } let (edges, bounds) = edge_order(body.solid.face_iter()); let edges = edges.iter().zip(bounds).map(|(e, faces)| EdgeProv { faces, origins: owned.get(&e.id()).copied().into_iter().collect() }).collect(); let history = History { operands: operands.len(), faces, edges }; - body.with_history(history, operands) + let counts: Vec<(usize, usize)> = operands.iter().map(|o| (o.body.face_count(), o.edges.len())).collect(); + body.with_history_counts(history, &counts) +} + +/// 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). +pub(crate) fn ruled(faces: &mt::Shell, a: &mt::Wire, b: &mt::Wire, a_numbers: &[Vec], b_numbers: &[Vec]) -> Vec { + let under = |w: &mt::Wire, numbers: &[Vec]| -> HashMap> { + w.edge_iter().map(|e| e.id()).zip(numbers.iter().cloned()).collect() + }; + let (ua, ub) = (under(a, a_numbers), under(b, b_numbers)); + faces + .face_iter() + .map(|f| { + let held = |u: &HashMap>| -> Option> { + let mut found = f.absolute_boundaries().iter().flat_map(|w| w.edge_iter()).filter_map(|e| u.get(&e.id())); + let one = found.next()?.clone(); + found.next().is_none().then_some(one) + }; + let prov = held(&ua).zip(held(&ub)).map(|(x, y)| profile(x.iter().chain(&y))); + (f.clone(), prov) + }) + .collect() } /// How far a column of swept faces runs. @@ -136,17 +114,19 @@ pub(crate) enum Column { Steps(usize), } -/// Follow swept faces from the profile edges (`seeds`) they start at. A face swept from an -/// edge is bounded by one loop of four edges with the edge and its moved copy opposite each -/// other, and the copy is where the next step of the sweep starts (truck builds every swept, -/// revolved and ruled side face that way). The face at `skip` is never entered. Returns, for each -/// face, the seed whose column it lies in; `None` when the faces don't follow that pattern -/// (a face reached twice, a column with a fork or of the wrong length). +/// Follow the faces truck sweeps from the profile edges (`seeds`) they start at. A face swept +/// from an edge is bounded by one loop: the edge, its moved copy, and the paths its ends sweep +/// (none where an end stays put: a cone's apex, `fixed`). The copy is the loop's one other edge +/// that shares no moving vertex with the edge, and is where the next step of the sweep starts. +/// The face at `skip` is never entered. Returns, for each face, the seed whose column it lies +/// in; `None` when the faces don't follow that pattern (a face reached twice, a column with a +/// fork or of the wrong length). pub(crate) fn follow<'a>( faces: impl IntoIterator, seeds: &[mt::EdgeID], skip: Option, run: Column, + fixed: &[mt::VertexID], ) -> Option>> { let faces: Vec<&mt::Face> = faces.into_iter().collect(); let mut by_edge: HashMap> = HashMap::new(); @@ -187,12 +167,11 @@ pub(crate) fn follow<'a>( } *slot = Some(seed); let [wire] = faces.get(next)?.absolute_boundaries().as_slice() else { return None }; - let ids: Vec = wire.edge_iter().map(|e| e.id()).collect(); - if ids.len() != 4 { - return None; - } - let at = ids.iter().position(|id| *id == edge)?; - edge = *ids.get((at + 2) % 4)?; + let here = wire.edge_iter().find(|e| e.id() == edge)?; + let moving: Vec = [here.front().id(), here.back().id()].into_iter().filter(|v| !fixed.contains(v)).collect(); + let mut copies = wire.edge_iter().filter(|e| e.id() != edge && !moving.contains(&e.front().id()) && !moving.contains(&e.back().id())); + let (Some(copy), None) = (copies.next(), copies.next()) else { return None }; + edge = copy.id(); prev = Some(next); first.get_or_insert(next); count += 1; @@ -204,8 +183,21 @@ pub(crate) fn follow<'a>( Some(owner) } -/// The history of a solid swept from the planar face `profile` (truck's `tsweep` or `rsweep` -/// of it), whose boundary edges `seeds` come from the profile segments given with them. Open +/// Each face's provenance by the column it lies in ([`follow`]), from the profile segments +/// under each seed edge. +pub(crate) fn columns<'a>( + faces: impl IntoIterator, + seeds: &[(mt::EdgeID, Vec)], + skip: Option, + run: Column, + fixed: &[mt::VertexID], +) -> Option>> { + let ids: Vec = seeds.iter().map(|(id, _)| *id).collect(); + Some(follow(faces, &ids, skip, run, fixed)?.into_iter().map(|o| o.and_then(|s| seeds.get(s)).map(|(_, numbers)| profile(numbers))).collect()) +} + +/// The history of a solid truck swept from the planar face `profile` (`rsweep` of it), whose +/// boundary edges `seeds` come from the profile segments given with them. Open /// (`closed == false`): the profile face is the start cap, each edge's faces follow from it to /// the end cap (the one face left), the same number of faces for every edge. Closed (a full /// turn): no caps, each edge's faces go round. @@ -225,7 +217,7 @@ pub(crate) fn swept_face(solid: &crate::body::Solid, profile: &mt::Face, seeds: _ => return None, }; // Every face but the caps lies in a column, so the one face left open is the end cap. - columns(faces, seeds, start, run)? + columns(faces, seeds, start, run, &[])? .into_iter() .enumerate() .map(|(i, p)| match p { @@ -236,38 +228,6 @@ pub(crate) fn swept_face(solid: &crate::body::Solid, profile: &mt::Face, seeds: .collect() } -/// Each face's provenance by the column it lies in ([`follow`]). -fn columns<'a>( - faces: impl IntoIterator, - seeds: &[(mt::EdgeID, Vec)], - skip: Option, - run: Column, -) -> Option>> { - let ids: Vec = seeds.iter().map(|(id, _)| *id).collect(); - Some(follow(faces, &ids, skip, run)?.into_iter().map(|o| o.and_then(|s| seeds.get(s)).map(|(_, numbers)| profile(numbers))).collect()) -} - -/// Side faces swept from `seeds` (edges with the profile segments under them; see [`follow`]), -/// then the start and end caps, the order [`crate::build::cap_and_close`] leaves them in. -/// `None` unless every side lies in some edge's column. -pub(crate) fn capped_sides<'a>( - faces: impl IntoIterator, - seeds: &[(mt::EdgeID, Vec)], - run: Column, -) -> Option> { - let mut out: Vec = columns(faces, seeds, None, run)?.into_iter().collect::>()?; - out.extend([cap(false), cap(true)]); - Some(out) -} - -/// Attach `faces` when recorded; a body whose faces could not be followed has no history. -pub(crate) fn attach_if(body: Body, operands: &[&Body], faces: Option>) -> Result { - match faces { - Some(faces) => attach(body, operands, faces), - None => Ok(body), - } -} - #[cfg(test)] #[path = "record_tests.rs"] mod tests; diff --git a/crates/kernel/src/record_tests.rs b/crates/kernel/src/record_tests.rs index c8d4c2d0..35814593 100644 --- a/crates/kernel/src/record_tests.rs +++ b/crates/kernel/src/record_tests.rs @@ -78,26 +78,46 @@ fn rotated(l: &Loop2, k: usize) -> Loop2 { Loop2 { segs } } -/// An extrusion (`h` up from `plane`): its caps by height, its sides by the profile segment -/// under them. -fn expected_extrusion(b: &Body, plane: &Plane, r: &Region2, h: f64) -> Vec> { +/// A body swept from `r`: each face from the profile segments it lies over (`to_2d` takes a +/// point back into the profile's plane, within `tol`), or else the cap `cap` says its points +/// lie on (if any). +fn expected_swept( + b: &Body, + r: &Region2, + to_2d: impl Fn(Vec3) -> Vec2, + tol: f64, + cap: impl Fn(&[Vec3]) -> Option, +) -> Vec> { let loops: Vec<&Loop2> = std::iter::once(&r.outer).chain(&r.holes).collect(); - let sides = swept_from(b, &loops, |p| plane.to_local(p), 1e-7); + let swept = swept_from(b, &loops, to_2d, tol); points(b) .iter() - .zip(sides) - .map(|(pts, segs)| { - if pts.iter().all(|p| plane.height(*p).abs() < 1e-9) { - vec![START] - } else if pts.iter().all(|p| (plane.height(*p) - h).abs() < 1e-9) { - vec![END] - } else { - segs.into_iter().map(P).collect() - } - }) + .zip(swept) + .map(|(pts, segs)| if segs.is_empty() { cap(pts).into_iter().collect() } else { segs.into_iter().map(P).collect() }) .collect() } +/// An extrusion (`h` up from `plane`): its sides by the profile segment under them, its caps +/// by height. +fn expected_extrusion(b: &Body, plane: &Plane, r: &Region2, h: f64) -> Vec> { + let at = |pts: &[Vec3], z: f64| pts.iter().all(|p| (plane.height(*p) - z).abs() < 1e-9); + expected_swept( + b, + r, + |p| plane.to_local(p), + 1e-7, + |pts| { + if at(pts, 0.0) { + Some(START) + } else if at(pts, h) { + Some(END) + } else { + None + } + }, + ) +} + /// Side k comes from segment k whatever order and direction the loops are given in. #[test] fn extrude_sides_follow_profile_segments_in_any_order() { @@ -176,21 +196,13 @@ const SIDE: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z }; /// A revolution's faces by the profile segment under each point turned back into the sketch, /// the angle caps by where they lie (the start in the sketch plane). fn expected_revolution(b: &Body, r: &Region2) -> Vec> { - let loops: Vec<&Loop2> = std::iter::once(&r.outer).chain(&r.holes).collect(); - let swept = swept_from(b, &loops, |p| Vec2::new(p.x.hypot(p.y), p.z), 1e-6); - points(b) - .iter() - .zip(swept) - .map(|(pts, segs)| { - if !segs.is_empty() { - segs.into_iter().map(P).collect() - } else if pts.iter().all(|p| p.y.abs() < 1e-9 && p.x > -1e-9) { - vec![START] - } else { - vec![END] - } - }) - .collect() + expected_swept( + b, + r, + |p| Vec2::new(p.x.hypot(p.y), p.z), + 1e-6, + |pts| Some(if pts.iter().all(|p| p.y.abs() < 1e-9 && p.x > -1e-9) { START } else { END }), + ) } #[test] @@ -208,6 +220,47 @@ fn revolve_partial_and_full() { } } +/// Loops given clockwise, a hole that is a full circle of three arcs: extrude numbers every +/// face by the segment as given; revolve re-seams the circle into two half circles, each from +/// every given arc it runs over. +#[test] +fn clockwise_loops_and_a_reseamed_circle_keep_their_numbers() { + let (c, rad) = (Vec2::new(20.0, 10.0), 4.0); + let arcs = [(0.3, 2.0), (2.3, 2.5), (4.8, TAU - 4.5)]; + let ccw = Loop2 { segs: arcs.iter().map(|&(start, sweep)| Seg2::Arc { center: c, radius: rad, start, sweep }).collect() }; + // Clockwise: segment 4 is the third arc above, 5 the second, 6 the first. + let r = Region2 { outer: rect(10.0, 0.0, 30.0, 20.0).reversed(), holes: vec![ccw.reversed()] }; + let b = crate::extrude(&Plane::XY, std::slice::from_ref(&r), 0.0, 10.0).unwrap().pop().unwrap(); + let got = origins(&b); + assert_eq!(got, expected_extrusion(&b, &Plane::XY, &r, 10.0)); + for k in 0..7 { + assert_eq!(got.iter().filter(|o| **o == [P(k)]).count(), 1, "segment {k}"); + } + // Revolved, the circle restarts at 0.4142 rad: the half from there runs over the first two + // arcs (6, 5), the other half over all three (it ends past the first arc's start). + let first_half = |q: Vec2| (q - c).angle().rem_euclid(TAU); + for angle in [FRAC_PI_2, TAU] { + let b = crate::revolve(&SIDE, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, angle).unwrap().pop().unwrap(); + let got = origins(&b); + let want = expected_revolution(&b, &r); + let (_, centres) = mesh_points(&b); + let mut halves = [0, 0]; + for ((g, w), centres) in got.iter().zip(&want).zip(¢res) { + let flat: Vec = centres.iter().map(|p| Vec2::new(p.x.hypot(p.y), p.z)).collect(); + if flat.iter().all(|q| (q.dist(c) - rad).abs() < 0.1) { + let first = flat.iter().all(|q| (0.4142 - 1e-3..=0.4142 + PI + 1e-3).contains(&first_half(*q))); + assert_eq!(g, &if first { vec![P(5), P(6)] } else { vec![P(4), P(5), P(6)] }, "angle {angle}"); + halves[usize::from(!first)] += 1; + } else { + assert_eq!(g, w, "angle {angle}"); + } + } + // Truck turns each half in as many parts as each rectangle side. + let parts = got.iter().filter(|o| **o == [P(0)]).count(); + assert_eq!(halves, [parts, parts], "angle {angle}"); + } +} + #[test] fn revolve_with_an_edge_on_the_axis() { // Segment 3 runs down the axis: it makes no face. diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index 39d5f4a1..d0780ac7 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -433,7 +433,8 @@ pub fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result { let mut out = Vec::new(); // History (one operand, the surface): a copy at no distance is the face itself, a copy // further out its offset, a wall is made along its free edge. - let edge_index = crate::record::edge_index(body); + let operand = crate::record::Operand::new(body); + let edge_index = &operand.edges; let mut history = Vec::new(); let copy = |i: usize, d: f64| { if d == 0.0 { @@ -499,7 +500,7 @@ pub fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result { } } } - crate::record::attach_edges(Body::new(mt::Solid::new_unchecked(vec![shell]))?, &[body], history, copies) + crate::record::attach(Body::new(mt::Solid::new_unchecked(vec![shell]))?, &[operand], Some(history), copies) }) } diff --git a/crates/kernel/src/sweep_path.rs b/crates/kernel/src/sweep_path.rs index df644163..c0099c2a 100644 --- a/crates/kernel/src/sweep_path.rs +++ b/crates/kernel/src/sweep_path.rs @@ -4,11 +4,13 @@ //! between stations, mitred at the corners. use mt::builder; -use solvecraft_geom::{Loop2, Plane, Region2, Vec2, Vec3}; +use solvecraft_geom::{Plane, Region2, Vec2, Vec3}; use truck_modeling as mt; use crate::body::{Body, p3}; use crate::build::{cap_and_close, interpolate_cubic, uniform_wire}; +use crate::numbered::{NumberedLoop, NumberedRegion}; +use crate::record::{self, Side}; use crate::{KernelError, Result, guard}; /// Most path points accepted. @@ -138,8 +140,10 @@ pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result CORNER); - let lp = region.outer.ccw(); - let origins = crate::record::loop_origins(std::slice::from_ref(region)); + let profile = crate::numbered::number_regions(std::slice::from_ref(region)) + .pop() + .map(|r| r.oriented()) + .ok_or_else(|| KernelError::Invalid("profile".into()))?; if !corner && region.holes.is_empty() { // Smooth: evenly spaced stations, tangents from neighbours. let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum(); @@ -148,12 +152,11 @@ pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result Result> { @@ -172,10 +175,9 @@ fn start_cos(plane: &Plane, tangents: &[Vec3]) -> f64 { tangents.first().map(|t| t.dot(plane.normal()).abs()).unwrap_or(1.0).max(1e-3) } -/// Ruled walls between stations at the path's points (mitred at corners), every loop -/// (`origins`: the profile segments under the loops' segments, from -/// [`crate::record::loop_origins`]). -fn ruled(plane: &Plane, region: &Region2, pts: &[Vec3], origins: &[Vec>]) -> Result { +/// Ruled walls between stations at the path's points (mitred at corners), every loop of the +/// (oriented) profile. +fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3]) -> Result { let pts: Vec = if pts.len() > MAX_RULED_STATIONS { resample(pts, MAX_RULED_STATIONS) } else { pts.to_vec() }; let n = pts.len(); let seg_dir = |i: usize| -> Option { (*pts.get(i + 1)? - *pts.get(i)?).normalized() }; @@ -201,31 +203,30 @@ fn ruled(plane: &Plane, region: &Region2, pts: &[Vec3], origins: &[Vec = std::iter::once(region.outer.ccw()).chain(region.holes.iter().map(|h| h.ccw().reversed())).collect(); + let loops: Vec<&NumberedLoop> = profile.loops().collect(); let mut wires: Vec> = Vec::with_capacity(st.len()); for s in &st { let pl = station_plane(plane, s0, s); - wires.push(loops.iter().map(|l| uniform_wire(&pl, l)).collect::>>()?); + wires.push(loops.iter().map(|l| uniform_wire(&pl, &l.lp)).collect::>>()?); } guard("path sweep", || { - let mut faces: Vec = Vec::new(); + // Every station's wires have the loops' segments, edge for edge. + let mut faces: Vec = Vec::new(); for w in wires.windows(2) { - for (a, b) in w[0].iter().zip(&w[1]) { + let (Some(w0), Some(w1)) = (w.first(), w.get(1)) else { continue }; + for ((a, b), l) in w0.iter().zip(w1).zip(&loops) { let sides = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("path sweep: {e}")))?; - faces.extend(sides.face_iter().cloned()); + faces.extend(record::ruled(&sides, a, b, &l.from, &l.from)); } } let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("path sweep".into())) }; - // Each edge of the first station's loops starts a column of walls along the path. - let seeds: Vec<(mt::EdgeID, Vec)> = - first.iter().zip(origins).flat_map(|(w, os)| w.edge_iter().map(|e| e.id()).zip(os.iter().cloned())).collect(); - let history = crate::record::capped_sides(&faces, &seeds, crate::record::Column::ToEnd); - crate::record::attach_if(cap_and_close(faces, first, last)?, &[], history) + cap_and_close(faces, first, last, false) }) } /// One B-spline wall per profile segment through the stations, capped; checked by volume. -fn smooth_walls(plane: &Plane, lp: &Loop2, st: &[Station], expected: f64) -> Result { +fn smooth_walls(plane: &Plane, profile: &NumberedLoop, st: &[Station], expected: f64) -> Result { + let lp = &profile.lp; let nseg = lp.segs.len(); let fail = |w: &str| KernelError::Failed(format!("path sweep: {w}")); if nseg == 0 || nseg > 64 || st.len() < 4 { @@ -278,13 +279,14 @@ fn smooth_walls(plane: &Plane, lp: &Loop2, st: &[Station], expected: f64) -> Res return Err(fail("edges")); }; let w: mt::Wire = vec![sa.clone(), b.clone(), sb.inverse(), a.inverse()].into(); - walls.push(mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surf.clone())).map_err(|e| fail(&e.to_string()))?); + let wall = mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surf.clone())).map_err(|e| fail(&e.to_string()))?; + walls.push((wall, Some(record::profile(profile.numbers(j))))); } let w0: mt::Wire = e0.iter().cloned().collect(); let w1: mt::Wire = e1.iter().cloned().collect(); let tol = (lp.signed_area().abs().sqrt() * 0.05).max(1e-3); - for ws in [walls.clone(), walls.iter().map(|f| f.inverse()).collect()] { - if let Ok(b) = cap_and_close(ws, std::slice::from_ref(&w0), std::slice::from_ref(&w1)) + for ws in [walls.clone(), walls.iter().map(|(f, prov)| (f.inverse(), prov.clone())).collect()] { + if let Ok(b) = cap_and_close(ws, std::slice::from_ref(&w0), std::slice::from_ref(&w1), false) && b.tessellate(tol).is_ok_and(|m| (m.measure().volume - expected).abs() <= 0.15 * expected.abs()) { return Ok(b);