Skip to main content

brep_kernel/intersect/
arrangement.rs

1use crate::spatial::{Aabb, Bvh};
2use crate::Vec3;
3use rustc_hash::FxHashMap;
4use serde::{Deserialize, Serialize};
5
6const PARALLEL_THRESHOLD: f64 = 1e-12;
7
8#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
9pub struct Vec2 {
10    pub x: f64,
11    pub y: f64,
12}
13
14impl Vec2 {
15    /// Distance to a segment. `guard` is the caller's squared-length threshold.
16    #[inline]
17    pub fn distance_to_segment(self, start: Self, end: Self, guard: f64) -> f64 {
18        let segment = end.sub(start);
19        let length_squared = segment.dot(segment);
20        if length_squared <= guard {
21            return self.sub(start).length();
22        }
23        let parameter = (self.sub(start).dot(segment) / length_squared).clamp(0.0, 1.0);
24        self.sub(start.add(segment.scale(parameter))).length()
25    }
26
27    /// Squared distance, retaining the seed sweep's projection on zero-length segments.
28    #[inline]
29    pub fn distance_to_segment_squared(self, start: Self, end: Self) -> f64 {
30        let segment = end.sub(start);
31        let length_squared = segment.dot(segment);
32        let parameter = if length_squared > 0.0 {
33            (self.sub(start).dot(segment) / length_squared).clamp(0.0, 1.0)
34        } else {
35            0.0
36        };
37        let residual = self.sub(start.add(segment.scale(parameter)));
38        residual.dot(residual)
39    }
40
41    pub(crate) fn sub(self, other: Self) -> Self {
42        Self {
43            x: self.x - other.x,
44            y: self.y - other.y,
45        }
46    }
47    pub(crate) fn add(self, other: Self) -> Self {
48        Self {
49            x: self.x + other.x,
50            y: self.y + other.y,
51        }
52    }
53    pub(crate) fn scale(self, factor: f64) -> Self {
54        Self {
55            x: self.x * factor,
56            y: self.y * factor,
57        }
58    }
59    fn distance(self, other: Self) -> f64 {
60        self.sub(other).length()
61    }
62    pub(crate) fn length(self) -> f64 {
63        (self.x * self.x + self.y * self.y).sqrt()
64    }
65    fn cross(self, other: Self) -> f64 {
66        self.x * other.y - self.y * other.x
67    }
68    pub(crate) fn dot(self, other: Self) -> f64 {
69        self.x * other.x + self.y * other.y
70    }
71}
72
73#[derive(Clone, Debug, Deserialize, Serialize)]
74pub struct Segment2 {
75    pub a: Vec2,
76    pub b: Vec2,
77    #[serde(default)]
78    pub tag: serde_json::Value,
79}
80
81#[derive(Clone, Debug, Serialize)]
82pub struct ArrangementPiece {
83    pub a: Vec2,
84    pub b: Vec2,
85    pub parent: Segment2,
86}
87
88#[derive(Clone, Debug, Serialize)]
89pub struct CycleUse {
90    pub piece: ArrangementPiece,
91    pub forward: bool,
92}
93
94#[derive(Clone, Debug, Serialize)]
95pub struct ArrangementRegion {
96    pub outer: Vec<CycleUse>,
97    pub holes: Vec<Vec<CycleUse>>,
98    pub area: f64,
99    /// The outer cycle's area alone, the key regions are returned by
100    /// (ascending); `area` nets the holes out.
101    pub outer_area: f64,
102}
103
104#[derive(Clone)]
105struct Node {
106    point: Vec2,
107    outgoing: Vec<usize>,
108}
109
110/// Append-only node lookup. A match always selects the lowest existing node
111/// index, exactly like the original linear `.position` scan, not the nearest
112/// point or the first occupied neighboring cell.
113struct NodeLookup {
114    tolerance: f64,
115    width: f64,
116    cells: Option<FxHashMap<[i64; 2], Vec<usize>>>,
117}
118
119impl NodeLookup {
120    fn new(tolerance: f64, indexed: bool) -> Self {
121        let width = tolerance * 2.0;
122        Self {
123            tolerance,
124            width,
125            // Squared distances can underflow and accept farther points at
126            // tiny tolerances. Preserve that legacy behavior with a scan.
127            cells: (indexed && tolerance >= 1e-150 && width.is_finite()).then(FxHashMap::default),
128        }
129    }
130
131    fn key(&self, point: Vec2) -> Option<[i64; 2]> {
132        let q = [point.x / self.width, point.y / self.width];
133        // Two-radius cells leave half a cell of rounding headroom. Bound the
134        // quotient so division rounding cannot consume it, or integer neighbors
135        // overflow. This also rejects non-finite coordinates.
136        q.iter()
137            .all(|v| v.is_finite() && v.abs() <= (1u64 << 48) as f64)
138            .then(|| q.map(|v| v.floor() as i64))
139    }
140
141    fn find_or_insert(&mut self, point: Vec2, nodes: &mut Vec<Node>) -> usize {
142        let key = self.cells.as_ref().and_then(|_| self.key(point));
143        if key.is_none() {
144            // Once a point cannot be indexed, retain the scan for the rest of
145            // the stream, including comparisons against this newly added node.
146            self.cells = None;
147        }
148        let found = if let (Some(cells), Some([x, y])) = (&self.cells, key) {
149            let mut first = None;
150            for dx in -1..=1 {
151                for dy in -1..=1 {
152                    if let Some(indices) = cells.get(&[x + dx, y + dy]) {
153                        // Each bucket is in insertion order. Later nodes in
154                        // this bucket cannot beat an already earlier match.
155                        for &index in indices {
156                            if first.is_some_and(|first| index >= first) {
157                                break;
158                            }
159                            if nodes[index].point.distance(point) <= self.tolerance {
160                                first = Some(index);
161                                break;
162                            }
163                        }
164                    }
165                }
166            }
167            first
168        } else {
169            nodes
170                .iter()
171                .position(|node| node.point.distance(point) <= self.tolerance)
172        };
173        if let Some(index) = found {
174            return index;
175        }
176        let index = nodes.len();
177        nodes.push(Node {
178            point,
179            outgoing: Vec::new(),
180        });
181        if let (Some(cells), Some(key)) = (&mut self.cells, key) {
182            cells.entry(key).or_default().push(index);
183        }
184        index
185    }
186}
187
188fn tolerance_parameter(segment: &Segment2, tolerance: f64) -> f64 {
189    let length = segment.a.distance(segment.b);
190    if length <= tolerance {
191        0.5
192    } else {
193        tolerance / length
194    }
195}
196
197fn interpolate(a: Vec2, b: Vec2, parameter: f64) -> Vec2 {
198    Vec2 {
199        x: a.x + (b.x - a.x) * parameter,
200        y: a.y + (b.y - a.y) * parameter,
201    }
202}
203
204pub fn segment_intersection(
205    a1: Vec2,
206    b1: Vec2,
207    a2: Vec2,
208    b2: Vec2,
209    tolerance: f64,
210) -> Option<[f64; 2]> {
211    let d1 = b1.sub(a1);
212    let d2 = b2.sub(a2);
213    let denominator = d1.cross(d2);
214    let length1 = d1.length();
215    let length2 = d2.length();
216    if denominator.abs() <= PARALLEL_THRESHOLD * length1 * length2 {
217        return None;
218    }
219    let offset = a2.sub(a1);
220    let t1 = offset.cross(d2) / denominator;
221    let t2 = offset.cross(d1) / denominator;
222    let epsilon1 = tolerance / length1.max(tolerance);
223    let epsilon2 = tolerance / length2.max(tolerance);
224    if t1 < -epsilon1 || t1 > 1.0 + epsilon1 || t2 < -epsilon2 || t2 > 1.0 + epsilon2 {
225        None
226    } else {
227        Some([t1.clamp(0.0, 1.0), t2.clamp(0.0, 1.0)])
228    }
229}
230
231/// Where the ends of `end` touch the interior of `bar`, for a pair the line
232/// crossing rejected. `segment_intersection` measures its tolerance ALONG each
233/// segment, so a stem meeting a bar at a shallow angle overruns it: a gap `g`
234/// is `g / sin(angle)` along the stem. An end within tolerance of the bar then
235/// gets no node on it, keeps degree 1, and pruning removes the whole stem
236/// (a reported box and pin model: a 9.8e-8 gap at sin 0.035 against a 1.4e-6 tolerance).
237/// Parallel pairs stay excluded, as they are from the crossing test, and the
238/// caller keeps only an end no other segment shares: a chain's interior point
239/// riding within tolerance of a nearly collinear curve is not a T, and noding
240/// it would pinch the two together.
241/// `BREP_ARRANGE_END_ON_SEGMENT=0` turns this off.
242fn end_on_segment_parameters(end: &Segment2, bar: &Segment2, tolerance: f64) -> Vec<(Vec2, f64)> {
243    let (stem, span) = (end.b.sub(end.a), bar.b.sub(bar.a));
244    let squared = span.dot(span);
245    if squared <= 0.0 || stem.cross(span).abs() <= PARALLEL_THRESHOLD * stem.length() * span.length() {
246        return Vec::new();
247    }
248    let margin = tolerance_parameter(bar, tolerance);
249    [end.a, end.b]
250        .into_iter()
251        .filter_map(|point| {
252            let parameter = point.sub(bar.a).dot(span) / squared;
253            (parameter > margin
254                && parameter < 1.0 - margin
255                && point_segment_distance(point, bar.a, bar.b) <= tolerance)
256                .then_some((point, parameter))
257        })
258        .collect()
259}
260
261pub use crate::polygon::PolygonClass;
262
263fn point_segment_distance(point: Vec2, a: Vec2, b: Vec2) -> f64 {
264    point.distance_to_segment(a, b, 1e-300)
265}
266
267/// The hole assignment's box rejection (2026-11-02): a point
268/// beyond a region polygon's vertex box by more than the classifier's
269/// tolerance plus a rounding pad is classified Outside without the O(n) test,
270/// and the full classifier would say the same. Its boundary band cannot fire
271/// (every computed segment distance exceeds the tolerance: the clamped foot
272/// lies in the box up to rounding the pad covers). Its form-K parity reads
273/// Outside: above or below the box no edge straddles the ray; to the right no
274/// crossing lies right of the point; to the left every straddling edge crosses
275/// right of it, and a closed loop has an even number of them. The hole loop
276/// was 60-70% of the arrangement's time on the package models (one negative
277/// cycle tested node by node against every positive region's polygon).
278/// Returns `[min_x, min_y, max_x, max_y, reach]`; `reach` is NaN (never
279/// reject) for an empty or non-finite polygon.
280fn polygon_reject_box(polygon: &[Vec2], tolerance: f64) -> [f64; 5] {
281    let mut bounds = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY];
282    let mut magnitude = 1.0f64;
283    for point in polygon {
284        bounds[0] = bounds[0].min(point.x);
285        bounds[1] = bounds[1].min(point.y);
286        bounds[2] = bounds[2].max(point.x);
287        bounds[3] = bounds[3].max(point.y);
288        magnitude = magnitude.max(point.x.abs()).max(point.y.abs());
289    }
290    let finite = !polygon.is_empty() && bounds.iter().all(|value| value.is_finite()) && tolerance.is_finite() && tolerance >= 0.0;
291    let reach = if finite { tolerance * (1.0 + 1e-9) + 64.0 * f64::EPSILON * magnitude } else { f64::NAN };
292    [bounds[0], bounds[1], bounds[2], bounds[3], reach]
293}
294
295/// The classifier's boundary band, indexed (2026-11-02): a
296/// uniform grid over the polygon's vertex box whose cells list every edge whose
297/// box, widened by the tolerance plus a rounding pad, overlaps them. An edge
298/// whose computed distance to a point is within the tolerance has that point
299/// inside its widened box (the clamped foot lies in the edge's box up to
300/// rounding the pad covers), so the point's cell lists it; the band is an
301/// existence test, so testing the listed edges with the classifier's own
302/// distance gives the classifier's own answer. Parity is the original full
303/// scan. Why: a negative cycle that runs along a region's own boundary
304/// classifies Boundary at every node and goes on to the next, so the hole loop
305/// paid one full O(n) band scan per node.
306struct BoundaryBand {
307    origin: [f64; 2],
308    cell: [f64; 2],
309    side: usize,
310    reach: f64,
311    starts: Vec<u32>,
312    items: Vec<u32>,
313    usable: bool,
314}
315
316impl BoundaryBand {
317    fn new(polygon: &[Vec2], tolerance: f64) -> Self {
318        let reject = polygon_reject_box(polygon, tolerance);
319        let reach = reject[4];
320        let count = polygon.len();
321        let usable = reach.is_finite() && count >= 16 && count <= (1 << 24);
322        let side = if usable { ((count as f64).sqrt() as usize).clamp(4, 512) } else { 0 };
323        let origin = [reject[0] - reach, reject[1] - reach];
324        let extent = [(reject[2] + reach) - origin[0], (reject[3] + reach) - origin[1]];
325        let cell = [(extent[0] / side.max(1) as f64).max(f64::MIN_POSITIVE), (extent[1] / side.max(1) as f64).max(f64::MIN_POSITIVE)];
326        let mut band = Self { origin, cell, side, reach, starts: Vec::new(), items: Vec::new(), usable };
327        if !band.usable {
328            return band;
329        }
330        let mut counts = vec![0u32; side * side + 1];
331        let ranges: Vec<[usize; 4]> = (0..count)
332            .map(|index| {
333                let (a, b) = (polygon[index], polygon[(index + 1) % count]);
334                let pad = reach + 64.0 * f64::EPSILON * a.x.abs().max(a.y.abs()).max(b.x.abs()).max(b.y.abs());
335                [band.column(a.x.min(b.x) - pad), band.column(a.x.max(b.x) + pad), band.row(a.y.min(b.y) - pad), band.row(a.y.max(b.y) + pad)]
336            })
337            .collect();
338        for range in &ranges {
339            for y in range[2]..=range[3] {
340                for x in range[0]..=range[1] {
341                    counts[y * side + x + 1] += 1;
342                }
343            }
344        }
345        for index in 1..counts.len() {
346            counts[index] += counts[index - 1];
347        }
348        // Long edges cover many cells; past a budget the original scan is cheaper.
349        if counts[side * side] as usize > 64 * count + 4096 {
350            band.usable = false;
351            return band;
352        }
353        let mut cursor = counts.clone();
354        band.items = vec![0u32; counts[side * side] as usize];
355        for (edge, range) in ranges.iter().enumerate() {
356            for y in range[2]..=range[3] {
357                for x in range[0]..=range[1] {
358                    let slot = &mut cursor[y * side + x];
359                    band.items[*slot as usize] = edge as u32;
360                    *slot += 1;
361                }
362            }
363        }
364        band.starts = counts;
365        band
366    }
367    fn column(&self, x: f64) -> usize {
368        (((x - self.origin[0]) / self.cell[0]).floor().max(0.0) as usize).min(self.side - 1)
369    }
370    fn row(&self, y: f64) -> usize {
371        (((y - self.origin[1]) / self.cell[1]).floor().max(0.0) as usize).min(self.side - 1)
372    }
373    fn classify(&self, point: Vec2, polygon: &[Vec2], tolerance: f64) -> PolygonClass {
374        if !self.usable || !point.x.is_finite() || !point.y.is_finite() {
375            return point_in_polygon_class(point, polygon, tolerance);
376        }
377        let cell = self.row(point.y) * self.side + self.column(point.x);
378        let count = polygon.len();
379        for &edge in &self.items[self.starts[cell] as usize..self.starts[cell + 1] as usize] {
380            let edge = edge as usize;
381            if point.distance_to_segment(polygon[edge], polygon[(edge + 1) % count], 1e-300) <= tolerance {
382                return PolygonClass::Boundary;
383            }
384        }
385        if crate::polygon::parity_inside([point.x, point.y], count, |i| [polygon[i].x, polygon[i].y]) {
386            PolygonClass::Inside
387        } else {
388            PolygonClass::Outside
389        }
390    }
391}
392
393fn outside_reject_box(point: Vec2, reject: &[f64; 5]) -> bool {
394    let reach = reject[4] + 64.0 * f64::EPSILON * point.x.abs().max(point.y.abs());
395    // NaN `reach` (or a NaN point) makes every comparison false: never reject.
396    point.x < reject[0] - reach || point.y < reject[1] - reach || point.x > reject[2] + reach || point.y > reject[3] + reach
397}
398
399/// `BREP_ARRANGE_DIGON_ORDER` (default on; `=0` restores insertion order for
400/// equal angles): see DIGON ORDER in [`arrange_segments`].
401fn digon_order_enabled() -> bool {
402    switch!("BREP_ARRANGE_DIGON_ORDER", true)
403}
404
405fn hole_box_reject_enabled() -> bool {
406    switch!("BREP_ARRANGE_HOLE_BOX", true)
407}
408
409
410fn point_in_polygon_class(point: Vec2, polygon: &[Vec2], tolerance: f64) -> PolygonClass {
411    crate::polygon::classify(point, polygon, tolerance, 1e-300)
412}
413
414pub fn point_in_polygon(point: Vec2, polygon: &[Vec2], tolerance: f64) -> &'static str {
415    match point_in_polygon_class(point, polygon, tolerance) {
416        PolygonClass::Inside => "in",
417        PolygonClass::Outside => "out",
418        PolygonClass::Boundary => "boundary",
419    }
420}
421
422// Arrangement decisions read endpoints and topology, never parent tags. Keep
423// the immutable source segment borrowed until a retained cycle needs an owned
424// output. The eager arm preserves the original allocation path for A/B.
425struct PieceInput<'a> {
426    a: Vec2,
427    b: Vec2,
428    parent: std::borrow::Cow<'a, Segment2>,
429}
430impl PieceInput<'_> {
431    fn materialize(&self, forward: bool) -> CycleUse {
432        CycleUse { piece: ArrangementPiece { a: self.a, b: self.b, parent: self.parent.as_ref().clone() }, forward }
433    }
434}
435
436#[derive(Clone)]
437enum CycleUses {
438    Eager(Vec<CycleUse>),
439    Deferred(Vec<usize>),
440}
441impl CycleUses {
442    fn push(&mut self, half: usize, pieces: &[PieceInput<'_>]) {
443        match self {
444            Self::Eager(uses) => uses.push(pieces[half >> 1].materialize(half % 2 == 0)),
445            Self::Deferred(halves) => halves.push(half),
446        }
447    }
448    fn into_uses(self, pieces: &[PieceInput<'_>]) -> Vec<CycleUse> {
449        match self {
450            Self::Eager(uses) => uses,
451            Self::Deferred(halves) => halves.into_iter().map(|h| pieces[h >> 1].materialize(h % 2 == 0)).collect(),
452        }
453    }
454    fn copy_uses(&self, pieces: &[PieceInput<'_>]) -> Vec<CycleUse> {
455        match self {
456            Self::Eager(uses) => uses.clone(),
457            Self::Deferred(halves) => halves.iter().map(|&h| pieces[h >> 1].materialize(h % 2 == 0)).collect(),
458        }
459    }
460}
461
462#[derive(Clone)]
463struct Cycle {
464    uses: CycleUses,
465    area: f64,
466    node_indices: Vec<usize>,
467}
468
469/// Visit a conservative subset of pairs in original input order. The narrow
470/// phase remains authoritative, including its endpoint tolerance and rounding.
471fn visit_segment_pairs(segments: &[Segment2], tolerance: f64, mut visit: impl FnMut(usize, usize)) {
472    let all_pairs = |visit: &mut dyn FnMut(usize, usize)| {
473        for first in 0..segments.len() {
474            for second in first + 1..segments.len() {
475                visit(first, second);
476            }
477        }
478    };
479    if segments.len() <= 32
480        || segments.len() > u32::MAX as usize
481        || !tolerance.is_finite()
482        || tolerance < 0.0
483        || tolerance > 1e100
484    {
485        all_pairs(&mut visit);
486        return;
487    }
488    let mut scale = 0.0f64;
489    let mut boxes = Vec::with_capacity(segments.len());
490    let mut extent = Aabb::empty();
491    for segment in segments {
492        let coordinates = [segment.a.x, segment.a.y, segment.b.x, segment.b.y];
493        let length = segment.a.distance(segment.b);
494        // Keep the legacy scan for numeric extremes: underflow/overflow or NaN
495        // in the narrow phase need not behave like geometric intersection.
496        if coordinates
497            .iter()
498            .any(|v| !v.is_finite() || v.abs() > 1e100)
499            || !(1e-100..=1e100).contains(&length)
500        {
501            all_pairs(&mut visit);
502            return;
503        }
504        for coordinate in coordinates {
505            scale = scale.max(coordinate.abs());
506        }
507        let bounds = Aabb {
508            minimum: Vec3::new(
509                segment.a.x.min(segment.b.x),
510                segment.a.y.min(segment.b.y),
511                0.0,
512            ),
513            maximum: Vec3::new(
514                segment.a.x.max(segment.b.x),
515                segment.a.y.max(segment.b.y),
516                0.0,
517            ),
518        };
519        extent.include(bounds);
520        boxes.push(bounds);
521    }
522    // Each accepted parameter may extend its segment by up to tolerance.
523    // Cross-product roundoff is amplified by up to 1/PARALLEL_THRESHOLD when
524    // solving the parameters. Bound it using the global coordinate magnitude
525    // (including translation), with headroom for subtraction, products, length
526    // and division rounding. This deliberately admits extra pairs near parallel
527    // lines instead of treating exact endpoint boxes as a bound on an inexact
528    // solve. The numeric guards above keep these operations in the normal range.
529    let padding = 2.0 * tolerance + 128.0 * f64::EPSILON / PARALLEL_THRESHOLD * scale;
530    // A large translation or tolerance can swallow the entire arrangement.
531    // An index cannot reject anything then, so avoid its query/sort overhead.
532    if padding >= extent.diagonal() {
533        all_pairs(&mut visit);
534        return;
535    }
536    let direction_tiers = direction_index_on();
537    // Row 29: the direction tiers' small-padding query is answered by a
538    // one-axis sweep instead of the tree, which is then not built (`None`
539    // from the sweep keeps the tree).
540    let small_padding = 2.0 * tolerance + 128.0 * f64::EPSILON / DIRECTION_SINE * scale;
541    let swept = if direction_tiers && arrange_sweep_on() { swept_pairs(&boxes, small_padding) } else { None };
542    let tree = swept.is_none().then(|| Bvh::build(&boxes));
543    let mut candidates = Vec::new();
544    if direction_tiers {
545        // DIRECTION TIERS (lane O, 2026-10-28). The padding above is what a
546        // pair as near-parallel as the narrow phase admits (`sin θ` down to
547        // PARALLEL_THRESHOLD) can need, and on a face whose uv sit far from
548        // the origin it reaches across most of the arrangement: the filleted
549        // box's outward offset sends a 252-segment quarter-arc chain through
550        // here twice per attempt and the index visited nearly all 37 000
551        // pairs (0.7 ms a face). The same rounding model bounds a pair whose
552        // directions differ by `sin θ ≥ s` with the padding at `s` in place of
553        // the threshold. So every pair is visited under that small padding,
554        // and a pair whose directions lie within one bucket of each other
555        // (width `2s`, wrapping at π) — the only pairs that can be nearer
556        // parallel than `s` — under the full padding as before. Visit order
557        // and the narrow phase are unchanged; the visited set still contains
558        // every pair the full padding could accept.
559        let width = 2.0 * DIRECTION_SINE;
560        let bucket_count = ((std::f64::consts::PI / width).ceil() as usize).max(1);
561        let mut buckets: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
562        let bucket_of: Vec<usize> = segments
563            .iter()
564            .map(|segment| {
565                let angle = (segment.b.y - segment.a.y).atan2(segment.b.x - segment.a.x).rem_euclid(std::f64::consts::PI);
566                ((angle / width) as usize).min(bucket_count - 1)
567            })
568            .collect();
569        for (index, bucket) in bucket_of.iter().enumerate() {
570            buckets.entry(*bucket).or_default().push(index);
571        }
572        let near = |a: &Aabb, b: &Aabb| {
573            a.minimum.x - padding <= b.maximum.x
574                && b.minimum.x - padding <= a.maximum.x
575                && a.minimum.y - padding <= b.maximum.y
576                && b.minimum.y - padding <= a.maximum.y
577        };
578        for (first, &bounds) in boxes.iter().enumerate() {
579            candidates.clear();
580            match (&swept, &tree) {
581                (Some(lists), _) => candidates.extend_from_slice(&lists[first]),
582                (None, Some(tree)) => tree.overlapping(bounds, small_padding, &mut candidates),
583                (None, None) => unreachable!("the tree is built whenever the sweep is not"),
584            }
585            let bucket = bucket_of[first];
586            for neighbour in [(bucket + bucket_count - 1) % bucket_count, bucket, (bucket + 1) % bucket_count] {
587                if let Some(members) = buckets.get(&neighbour) {
588                    candidates.extend(members.iter().copied().filter(|&second| second > first && near(&bounds, &boxes[second])));
589                }
590                if bucket_count < 3 {
591                    break;
592                }
593            }
594            candidates.retain(|&second| second > first);
595            candidates.sort_unstable();
596            candidates.dedup();
597            for &second in &candidates {
598                visit(first, second);
599            }
600        }
601        return;
602    }
603    let Some(tree) = tree else { unreachable!("the sweep runs only under the direction tiers") };
604    for (first, &bounds) in boxes.iter().enumerate() {
605        candidates.clear();
606        tree.overlapping(bounds, padding, &mut candidates);
607        candidates.retain(|&second| second > first);
608        candidates.sort_unstable();
609        for &second in &candidates {
610            visit(first, second);
611        }
612    }
613}
614
615/// The direction sine above which [`visit_segment_pairs`] pads a pair with
616/// the small padding (see DIRECTION TIERS there).
617const DIRECTION_SINE: f64 = 1e-3;
618
619/// Lane O row 29 (2026-10-30, `BREP_ARRANGE_SWEEP`, default on since batch 12;
620/// `0`/`off` restores the tree): for each box `first`, every `second > first` whose box passes
621/// the tree's own leaf test `boxes[second].intersects(boxes[first], padding)`
622/// — the set [`Bvh::overlapping`] returns past `second > first`, since a node
623/// box contains its items' boxes — in ascending order. The candidates come
624/// from a sweep over the boxes sorted by their minimum on one axis, each
625/// scanning forward while the next minimum is within its maximum plus twice
626/// the padding: a pair the leaf test admits lies within the padding plus one
627/// rounding of either subtraction there, and the padding (at least
628/// 128 ε / DIRECTION_SINE of the largest coordinate) exceeds that rounding.
629///
630/// The axis is the one whose sweep windows (counted by binary search before
631/// any test runs) are fewer in total; when even those exceed 32 a box, the
632/// boxes overlap along both axes and `None` hands the query back to the tree,
633/// which prunes on both at once (a guard: of the D1, inward opposed and
634/// outward 3-adjacent shell cases probed, only D1 reaches the sweep, and
635/// never the fallback). D1's two 272-segment faces spent 0.38 ms a run
636/// building and querying the tree for 570 narrow-phase pairs that cost
637/// 0.04 ms.
638fn swept_pairs(boxes: &[Aabb], padding: f64) -> Option<Vec<Vec<usize>>> {
639    let reach = 2.0 * padding;
640    let low = |index: usize, axis: usize| if axis == 0 { boxes[index].minimum.x } else { boxes[index].minimum.y };
641    let high = |index: usize, axis: usize| if axis == 0 { boxes[index].maximum.x } else { boxes[index].maximum.y };
642    let mut best: Option<(usize, Vec<usize>, usize)> = None;
643    for axis in [0, 1] {
644        let mut order: Vec<usize> = (0..boxes.len()).collect();
645        order.sort_by(|&a, &b| low(a, axis).total_cmp(&low(b, axis)));
646        let lows: Vec<f64> = order.iter().map(|&index| low(index, axis)).collect();
647        let windows: usize = order
648            .iter()
649            .enumerate()
650            .map(|(position, &a)| {
651                let limit = high(a, axis) + reach;
652                lows.partition_point(|&value| value <= limit).saturating_sub(position + 1)
653            })
654            .sum();
655        if best.as_ref().is_none_or(|(least, _, _)| windows < *least) {
656            best = Some((windows, order, axis));
657        }
658    }
659    let (windows, order, axis) = best?;
660    if windows > 32 * boxes.len() {
661        return None;
662    }
663    let mut lists = vec![Vec::new(); boxes.len()];
664    for (position, &a) in order.iter().enumerate() {
665        let limit = high(a, axis) + reach;
666        for &b in &order[position + 1..] {
667            if low(b, axis) > limit {
668                break;
669            }
670            let (first, second) = if a < b { (a, b) } else { (b, a) };
671            if boxes[second].intersects(boxes[first], padding) {
672                lists[first].push(second);
673            }
674        }
675    }
676    for list in &mut lists {
677        list.sort_unstable();
678    }
679    Some(lists)
680}
681
682/// Row 29's switch ([`swept_pairs`]), read once.
683fn arrange_sweep_on() -> bool {
684    switch!("BREP_ARRANGE_SWEEP", true)
685}
686
687
688/// The direction tiers of [`visit_segment_pairs`], on unless
689/// `BREP_ARRANGE_DIRECTION_INDEX` is `0`/`off` (the A/B lever). Read once.
690fn direction_index_on() -> bool {
691    switch!("BREP_ARRANGE_DIRECTION_INDEX", true)
692}
693
694pub fn arrange_segments(
695    segments: &[Segment2],
696    tolerance: f64,
697) -> Result<Vec<ArrangementRegion>, String> {
698    arrange_segments_impl(segments, tolerance, true, true)
699}
700
701fn arrange_segments_impl(
702    segments: &[Segment2],
703    tolerance: f64,
704    indexed: bool,
705    indexed_nodes: bool,
706) -> Result<Vec<ArrangementRegion>, String> {
707    let defer_tags = switch!("BREP_ARRANGE_DEFER_TAGS", true);
708    let end_on_segment = switch!("BREP_ARRANGE_END_ON_SEGMENT", true);
709    let mut cuts = vec![Vec::<f64>::new(); segments.len()];
710    let mut intersect_pair = |first: usize, second: usize| {
711        let Some([first_parameter, second_parameter]) = segment_intersection(
712            segments[first].a,
713            segments[first].b,
714            segments[second].a,
715            segments[second].b,
716            tolerance,
717        ) else {
718            if end_on_segment {
719                for (end, bar) in [(first, second), (second, first)] {
720                    for (point, parameter) in end_on_segment_parameters(&segments[end], &segments[bar], tolerance) {
721                        let shared = segments.iter().enumerate().any(|(index, other)| {
722                            index != end && (other.a.distance(point) <= tolerance || other.b.distance(point) <= tolerance)
723                        });
724                        if !shared {
725                            cuts[bar].push(parameter);
726                        }
727                    }
728                }
729            }
730            return;
731        };
732        let first_tolerance = tolerance_parameter(&segments[first], tolerance);
733        let second_tolerance = tolerance_parameter(&segments[second], tolerance);
734        if first_parameter > first_tolerance && first_parameter < 1.0 - first_tolerance {
735            cuts[first].push(first_parameter);
736        }
737        if second_parameter > second_tolerance && second_parameter < 1.0 - second_tolerance {
738            cuts[second].push(second_parameter);
739        }
740    };
741    if indexed {
742        visit_segment_pairs(segments, tolerance, &mut intersect_pair);
743    } else {
744        for first in 0..segments.len() {
745            for second in first + 1..segments.len() {
746                intersect_pair(first, second);
747            }
748        }
749    }
750
751    let mut pieces = Vec::new();
752    for (index, segment) in segments.iter().enumerate() {
753        let mut parameters = vec![0.0];
754        cuts[index].sort_by(f64::total_cmp);
755        parameters.extend(cuts[index].iter().copied());
756        parameters.push(1.0);
757        let parameter_tolerance = tolerance_parameter(segment, tolerance);
758        let mut deduplicated: Vec<f64> = Vec::new();
759        for parameter in parameters {
760            if deduplicated
761                .last()
762                .is_none_or(|previous| parameter - *previous > parameter_tolerance)
763            {
764                deduplicated.push(parameter);
765            } else if parameter == 1.0 {
766                *deduplicated.last_mut().unwrap() = 1.0;
767            }
768        }
769        for pair in deduplicated.windows(2) {
770            let a = interpolate(segment.a, segment.b, pair[0]);
771            let b = interpolate(segment.a, segment.b, pair[1]);
772            if a.distance(b) <= tolerance {
773                continue;
774            }
775            pieces.push(PieceInput {
776                a,
777                b,
778                parent: if defer_tags { std::borrow::Cow::Borrowed(segment) } else { std::borrow::Cow::Owned(segment.clone()) },
779            });
780        }
781    }
782
783    let mut nodes: Vec<Node> = Vec::new();
784    let mut lookup = NodeLookup::new(tolerance, indexed_nodes && pieces.len() > 64);
785    let mut piece_start = Vec::with_capacity(pieces.len());
786    let mut piece_end = Vec::with_capacity(pieces.len());
787    for piece in &pieces {
788        piece_start.push(lookup.find_or_insert(piece.a, &mut nodes));
789        piece_end.push(lookup.find_or_insert(piece.b, &mut nodes));
790    }
791
792    let mut alive = vec![true; pieces.len()];
793    loop {
794        let mut pruned = false;
795        let mut degree = vec![0usize; nodes.len()];
796        for index in 0..pieces.len() {
797            if !alive[index] {
798                continue;
799            }
800            if piece_start[index] == piece_end[index] {
801                alive[index] = false;
802                pruned = true;
803                continue;
804            }
805            degree[piece_start[index]] += 1;
806            degree[piece_end[index]] += 1;
807        }
808        for index in 0..pieces.len() {
809            if alive[index] && (degree[piece_start[index]] == 1 || degree[piece_end[index]] == 1) {
810                alive[index] = false;
811                pruned = true;
812            }
813        }
814        if !pruned {
815            break;
816        }
817    }
818
819    let half_tail = |half_edge: usize| {
820        if half_edge % 2 == 0 {
821            piece_start[half_edge >> 1]
822        } else {
823            piece_end[half_edge >> 1]
824        }
825    };
826    let half_head = |half_edge: usize| {
827        if half_edge % 2 == 0 {
828            piece_end[half_edge >> 1]
829        } else {
830            piece_start[half_edge >> 1]
831        }
832    };
833    for node in &mut nodes {
834        node.outgoing.clear();
835    }
836    for index in 0..pieces.len() {
837        if !alive[index] {
838            continue;
839        }
840        nodes[piece_start[index]].outgoing.push(index * 2);
841        nodes[piece_end[index]].outgoing.push(index * 2 + 1);
842    }
843    let angles: Vec<f64> = (0..pieces.len() * 2)
844        .map(|half_edge| {
845            let tail = nodes[half_tail(half_edge)].point;
846            let head = nodes[half_head(half_edge)].point;
847            (head.y - tail.y).atan2(head.x - tail.x)
848        })
849        .collect();
850    // DIGON ORDER: two pieces joining the SAME two nodes (a cut lying along a
851    // trim edge for its last stretch, merged onto the edge's nodes) leave
852    // the same angle at both ends, and a sort by angle alone keeps them in
853    // insertion order at BOTH nodes, which no planar embedding has: the walk
854    // then crosses between the two sides of the pair and stitches the region
855    // beside it into the outer cycle, so the region is lost (an offset
856    // fillet's skin vanished at a miter corner, 2026-11-07). Equal
857    // angles are ordered by piece index, reversed at the higher node, so the
858    // pair bounds an empty digon and both neighbours close.
859    let digon_order = digon_order_enabled();
860    for node in &mut nodes {
861        node.outgoing.sort_by(|a, b| {
862            angles[*a].total_cmp(&angles[*b]).then_with(|| {
863                if !digon_order {
864                    return std::cmp::Ordering::Equal;
865                }
866                let rank = |half_edge: usize| {
867                    let piece = (half_edge >> 1) as i64;
868                    if half_tail(half_edge) < half_head(half_edge) {
869                        piece
870                    } else {
871                        -piece
872                    }
873                };
874                rank(*a).cmp(&rank(*b))
875            })
876        });
877    }
878
879    let mut visited = rustc_hash::FxHashSet::default();
880    let mut positive = Vec::new();
881    let mut negative = Vec::new();
882    for index in 0..pieces.len() {
883        if !alive[index] {
884            continue;
885        }
886        for start in [index * 2, index * 2 + 1] {
887            if visited.contains(&start) {
888                continue;
889            }
890            let mut uses = if defer_tags { CycleUses::Deferred(Vec::new()) } else { CycleUses::Eager(Vec::new()) };
891            let mut node_indices = Vec::new();
892            let mut area = 0.0;
893            let mut half_edge = start;
894            let mut guard = 0;
895            loop {
896                visited.insert(half_edge);
897                uses.push(half_edge, &pieces);
898                let tail_index = half_tail(half_edge);
899                let head_index = half_head(half_edge);
900                let tail = nodes[tail_index].point;
901                let head = nodes[head_index].point;
902                area += 0.5 * (tail.x * head.y - head.x * tail.y);
903                node_indices.push(tail_index);
904                let reverse = half_edge ^ 1;
905                let outgoing = &nodes[head_index].outgoing;
906                let reverse_index = outgoing
907                    .iter()
908                    .position(|candidate| *candidate == reverse)
909                    .ok_or_else(|| "arrangeSegments: reverse half-edge missing".to_string())?;
910                half_edge = outgoing[(reverse_index + outgoing.len() - 1) % outgoing.len()];
911                guard += 1;
912                if guard > pieces.len() * 4 + 8 {
913                    return Err("arrangeSegments: face walk did not terminate".into());
914                }
915                if half_edge == start {
916                    break;
917                }
918            }
919            let cycle = Cycle {
920                uses,
921                area,
922                node_indices,
923            };
924            if area > tolerance * tolerance {
925                positive.push(cycle);
926            } else if area < -tolerance * tolerance {
927                negative.push(cycle);
928            }
929        }
930    }
931
932    positive.sort_by(|a, b| a.area.total_cmp(&b.area));
933    let mut regions: Vec<(ArrangementRegion, Vec<Vec2>)> = positive
934        .into_iter()
935        .map(|cycle| {
936            let polygon = cycle
937                .node_indices
938                .iter()
939                .map(|index| nodes[*index].point)
940                .collect();
941            (
942                ArrangementRegion {
943                    outer: cycle.uses.into_uses(&pieces),
944                    holes: Vec::new(),
945                    area: cycle.area,
946                    outer_area: cycle.area,
947                },
948                polygon,
949            )
950        })
951        .collect();
952    let hole_boxes = hole_box_reject_enabled();
953    let boxes: Vec<[f64; 5]> = if hole_boxes {
954        regions.iter().map(|(_, polygon)| polygon_reject_box(polygon, tolerance)).collect()
955    } else {
956        Vec::new()
957    };
958    let mut bands: Vec<Option<BoundaryBand>> = (0..regions.len()).map(|_| None).collect();
959    for cycle in negative {
960        for (region_index, (region, polygon)) in regions.iter_mut().enumerate() {
961            let mut inside = false;
962            let mut on_boundary = false;
963            for node_index in &cycle.node_indices {
964                let point = nodes[*node_index].point;
965                let class = if hole_boxes && outside_reject_box(point, &boxes[region_index]) {
966                    PolygonClass::Outside
967                } else if hole_boxes {
968                    let band = bands[region_index].get_or_insert_with(|| BoundaryBand::new(polygon, tolerance));
969                    band.classify(point, polygon, tolerance)
970                } else {
971                    point_in_polygon_class(point, polygon, tolerance)
972                };
973                match class {
974                    PolygonClass::Boundary => {
975                        on_boundary = true;
976                        continue;
977                    }
978                    PolygonClass::Inside => {
979                        inside = true;
980                        on_boundary = false;
981                        break;
982                    }
983                    PolygonClass::Outside => {
984                        inside = false;
985                        on_boundary = false;
986                        break;
987                    }
988                }
989            }
990            if on_boundary {
991                continue;
992            }
993            if inside {
994                region.holes.push(cycle.uses.copy_uses(&pieces));
995                region.area += cycle.area;
996                break;
997            }
998        }
999    }
1000    if crate::profile::profile_enabled() {
1001        let holes: usize = regions.iter().flat_map(|(r, _)| r.holes.iter()).map(Vec::len).sum();
1002        let returned: usize = holes + regions.iter().map(|(r, _)| r.outer.len()).sum::<usize>();
1003        let clones = if defer_tags { returned } else { pieces.len() + visited.len() + holes };
1004        crate::imprint::profile_emit(format_args!("arrange.tags segments={} pieces={} walked={} returned={returned} parent_clones={clones} deferred={defer_tags}", segments.len(), pieces.len(), visited.len()));
1005    }
1006    Ok(regions.into_iter().map(|(region, _)| region).collect())
1007}
1008
1009