From 97bcdb4d06d4cb7b9d37b7738f8820378c33ea97 Mon Sep 17 00:00:00 2001 From: Iraklis Kourtis Date: Mon, 12 Oct 2026 04:07:56 -0400 Subject: [PATCH] feat(kernel): settle a slab's far cap edge by operand topology (#136) The far edge of a stepped slab (the operand cap edge where a segment's wall ends) was recorded by testing whether each slab segment runs along an ending cap's segment in 2D, a tolerance comparison of coordinates. Matching by topology makes the far edge exact by construction instead: when the wall's top is the ending level, nothing crosses it there, and it meets the caps at that level in exactly one edge, that edge is over every segment beside the wall, with no position compared. A vertex walk up the wall's side edges from the segment's ends was tried first and does not settle the cases that need it: a notched or stepped wall continues past a segment's end (no side edge rises from it), and walls split by earlier stations have mid-chain vertices with no side edge. Whenever the walk succeeds, the wall is a rectangle over the segment and the one-edge rule above already holds. The positional match remains only where topology cannot say which edge is over a segment: a wall that meets the ending caps in several edges (a top split at earlier station vertices, or beside a wall crossing it at that level) or that carries on past the level elsewhere. It is documented in `Section::mark_far` and its pair budget no longer stops topological marking. Far edges are also only marked on the slab that actually ends at the level (a gap level no longer revisits an earlier slab). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8 --- crates/kernel/src/prism.rs | 118 ++++++++++++++++++++++++++++++------- 1 file changed, 97 insertions(+), 21 deletions(-) diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index cc03891f..dbbfa298 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -246,24 +246,44 @@ impl Section { } } - /// Mark each segment that runs end to end along a segment of the outline of one of `caps` - /// (the operand's caps ending its slab) read beside the same wall with that segment's edge - /// as its `far` edge: there the wall meets that cap. A segment along none, or along segments - /// of different edges, stays unmarked; so do those past `budget` (segment pairs compared). - fn mark_far(&mut self, caps: &[&Section], tol: f64, budget: &mut usize) { - let mut by_wall: std::collections::HashMap)>> = std::collections::HashMap::new(); - for (segs, tags) in caps.iter().map(|c| c.segments()) { - for (seg, tag) in segs.into_iter().zip(tags) { - if let Some(w) = tag.wall { - by_wall.entry(w).or_default().push((seg, tag.edge)); - } - } - } + /// Mark each segment of a slab with its `far` edge: the operand's edge where its wall meets + /// one of `caps` (the outlines of the operand's caps ending the slab). + /// + /// The operand's topology settles it where `top` gives the wall's edge (the wall ends at the + /// caps in that one edge, [`Stations::top_edge`]): that edge is over every segment beside it. + /// + /// Otherwise, for a wall meeting the caps in several edges (its top split at vertices an + /// earlier operation left, or beside a wall across it there) or carrying on past the caps + /// elsewhere, which edge is over a segment depends on where the segment lies along the wall, + /// and nothing in the topology says that. Only there is position compared: the segment takes + /// the edge of the cap segment beside the same wall it runs along end to end. A segment + /// along none, or along segments of different edges, stays unmarked; so do those past + /// `budget` (segment pairs compared). + fn mark_far(&mut self, caps: &[&Section], top: impl Fn(usize) -> Option, tol: f64, budget: &mut usize) { + let mut tops: std::collections::HashMap> = std::collections::HashMap::new(); + // The caps' segments by wall, gathered only once a wall needs position. + let mut by_wall: Option)>>> = None; for (reg, tags) in self.regions.iter().zip(&mut self.tags) { for (lp, tags) in std::iter::once(®.outer).chain(®.holes).zip(tags) { for (seg, tag) in lp.segs.iter().zip(tags) { - let Some(beside) = tag.wall.and_then(|w| by_wall.get(&w)) else { continue }; - let Some(left) = budget.checked_sub(beside.len()) else { return }; + let Some(w) = tag.wall else { continue }; + if let Some(e) = *tops.entry(w).or_insert_with(|| top(w)) { + tag.far = Some(e); + continue; + } + let by_wall = by_wall.get_or_insert_with(|| { + let mut by_wall: std::collections::HashMap)>> = std::collections::HashMap::new(); + for (segs, tags) in caps.iter().map(|c| c.segments()) { + for (seg, tag) in segs.into_iter().zip(tags) { + if let Some(w) = tag.wall { + by_wall.entry(w).or_default().push((seg, tag.edge)); + } + } + } + by_wall + }); + let Some(beside) = by_wall.get(&w) else { continue }; + let Some(left) = budget.checked_sub(beside.len()) else { continue }; *budget = left; // The one edge it runs along (`Some(None)`: along none, or not one edge). let mut along: Option> = None; @@ -444,6 +464,22 @@ impl Stations { self.cross.get(&w).map_or(&[], Vec::as_slice) } + /// The operand's edge (in `index`) where wall `w` ends at level `z`, when it ends there in + /// one edge: `z` is its top, no other wall crosses it there, and it meets the faces `caps` + /// (the caps at `z`) in exactly one edge. Found by topology alone, no position compared. The wall reaching no higher, every segment beside + /// it in a slab up to `z` ends on its edges at `z`, which are that one edge alone. + fn top_edge(&self, index: &Index, w: usize, caps: &[usize], z: f64, tol: f64) -> Option { + let ends = self.tops.get(&w).is_some_and(|t| (*t - z).abs() < tol); + if !ends || self.of(w).iter().any(|(_, l, h)| *l < z + tol && *h > z - tol) { + return None; + } + let mut edges = caps.iter().filter_map(|c| index.between.get(&(w.min(*c), w.max(*c)))).flatten(); + match (edges.next(), edges.next()) { + (Some(e), None) => Some(*e), + _ => None, + } + } + /// Does wall `w` meet another wall across it between levels `lo` and `hi` (not at them)? fn across(&self, w: usize, lo: f64, hi: f64, tol: f64) -> bool { self.of(w).iter().any(|(_, l, h)| *l < hi - tol && *h > lo + tol) @@ -1444,7 +1480,7 @@ fn stack_tagged(b: &Body, d: Vec3, plane: &Plane, tol: f64, index: &LazyIndex) - let n = Vec3::new(n.x, n.y, n.z).normalized()?; if n.dot(d).abs() > 1.0 - 1e-9 { let section = Section::from_loops(index.get().loops(f, fi, plane, tol, &mut curve_work, &mut segments)?)?; - events.push((plane.height(from_p3(p.origin())), n.dot(d) > 0.0, section.oriented())); + events.push((plane.height(from_p3(p.origin())), n.dot(d) > 0.0, section.oriented(), fi)); continue; } if n.dot(d).abs() > 1e-9 { @@ -1455,7 +1491,7 @@ fn stack_tagged(b: &Body, d: Vec3, plane: &Plane, tol: f64, index: &LazyIndex) - } walls.insert(fi, top_of(f, plane)); } - let mut levels: Vec = events.iter().map(|(z, _, _)| *z).collect(); + let mut levels: Vec = events.iter().map(|(z, _, _, _)| *z).collect(); levels.sort_by(f64::total_cmp); levels.dedup_by(|x, y| (*x - *y).abs() < tol); if levels.len() < 2 || levels.len() > 64 || levels.iter().any(|z| !z.is_finite()) { @@ -1467,9 +1503,10 @@ fn stack_tagged(b: &Body, d: Vec3, plane: &Plane, tol: f64, index: &LazyIndex) - let mut budget = MAX_PLANAR_PAIRS; for (i, z) in levels.iter().enumerate() { // The caps here end the slab below: its walls meet them. - if let Some(below) = stack.last_mut() { - let caps: Vec<&Section> = events.iter().filter(|(h, _, _)| (*h - *z).abs() < tol).map(|(_, _, s)| s).collect(); - below.section.mark_far(&caps, tol, &mut budget); + if let Some(below) = stack.last_mut().filter(|p| (p.hi - *z).abs() < tol) { + let (faces, caps): (Vec, Vec<&Section>) = + events.iter().filter(|(h, _, _, _)| (*h - *z).abs() < tol).map(|(_, _, s, f)| (*f, s)).unzip(); + below.section.mark_far(&caps, |w| stations.top_edge(index.get(), w, &faces, *z, tol), tol, &mut budget); } // The segments carried here run on past it, beside the walls there. current.retag(|t| match t.wall { @@ -1478,7 +1515,7 @@ fn stack_tagged(b: &Body, d: Vec3, plane: &Plane, tol: f64, index: &LazyIndex) - }); // Remove terminating material before adding any material starting here. for up in [true, false] { - for (_, _, section) in events.iter().filter(|(h, u, _)| (*h - *z).abs() < tol && *u == up) { + for (_, _, section, _) in events.iter().filter(|(h, u, _, _)| (*h - *z).abs() < tol && *u == up) { current = match (current.regions.is_empty(), up) { (true, false) => section.clone(), (true, true) => return None, @@ -2021,6 +2058,45 @@ mod planar_tests { } } #[test] + fn far_edges_of_a_stepped_slab_come_from_the_operand_topology() { + // A block with a notch in its back, read along -y: the slab from the notch floor to the + // front has three segments beside the top face, read off the back and the notch floor. + // The top face ends at the front in one edge: the far edge of all three, found with no + // position compared (no pair budget). + let notched = Loop2::polygon(&[ + Vec2::new(0., 0.), + Vec2::new(40., 0.), + Vec2::new(40., 30.), + Vec2::new(25., 30.), + Vec2::new(25., 25.), + Vec2::new(15., 25.), + Vec2::new(15., 30.), + Vec2::new(0., 30.), + ]); + let block = crate::extrude(&Plane::named("XY").unwrap(), &[Region2 { outer: notched, holes: vec![] }], 0., 20.).unwrap().pop().unwrap(); + let (d, tol) = (-Vec3::Y, 1e-6); + let plane = Plane::from_normal(Vec3::ZERO, d).unwrap(); + let normal = |f: &mt::Face| crate::blend::plane_normal(f).unwrap_or(Vec3::ZERO); + let faces: Vec<(usize, &mt::Face)> = block.solid.face_iter().enumerate().collect(); + let find = |n: Vec3| faces.iter().find(|(_, f)| normal(f).dot(n) > 1. - 1e-9).map(|(i, _)| *i).unwrap(); + let (top, front) = (find(Vec3::Z), find(-Vec3::Y)); + let walls = faces.iter().filter(|(_, f)| normal(f).dot(d).abs() < 1e-9).map(|(i, f)| (*i, top_of(f, &plane))).collect(); + let index = Index::new(&block, 0); + let stations = Stations::new(&index, walls, d, &plane, tol); + let cap = Section::from_loops(index.loops(faces[front].1, front, &plane, tol, &mut 1000, &mut 1000).unwrap()).unwrap().oriented(); + let edge = match index.between.get(&(top.min(front), top.max(front))).map(Vec::as_slice) { + Some([e]) => *e, + other => panic!("one edge between the top and the front: {other:?}"), + }; + let mut slab = stack_of(&block, d, &plane, tol).unwrap().pop().unwrap(); + assert!((slab.lo + 25.).abs() < tol && slab.hi.abs() < tol); + let beside_top = |s: &Section| s.segments().1.into_iter().filter(|t| t.wall == Some(top)).map(|t| t.far).collect::>(); + assert_eq!(beside_top(&slab.section), vec![Some(edge); 3], "read as a stack"); + slab.section.retag(|t| Tag { far: None, ..t }); + slab.section.mark_far(&[&cap], |w| stations.top_edge(&index, w, &[front], 0., tol), tol, &mut 0); + assert_eq!(beside_top(&slab.section), vec![Some(edge); 3], "by topology alone"); + } + #[test] fn compound_planar_extrusion_normalizes_overlap_and_retains_islands() { let plane = Plane::named("XY").unwrap(); let a = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0));