Skip to main content

brep_kernel/intersect/
curve_curve_intersection.rs

1use crate::curve::KNOT_IDENTITY_TOL as KNOT_TOLERANCE;
2use crate::{NurbsCurve, Vec3};
3use serde::Serialize;
4
5const EPSILON: f64 = crate::tolerance::FP_FLOOR;
6
7use crate::spatial::Aabb as Bounds;
8
9
10#[derive(Clone, Copy)]
11struct Segment {
12    start: f64,
13    end: f64,
14    bounds: Bounds,
15}
16
17fn interior_knots(curve: &NurbsCurve) -> Vec<f64> {
18    let start = curve.knots[curve.degree];
19    let end = curve.knots[curve.knots.len() - 1 - curve.degree];
20    let mut result = Vec::new();
21    for &knot in &curve.knots {
22        if knot <= start + KNOT_TOLERANCE || knot >= end - KNOT_TOLERANCE {
23            continue;
24        }
25        if result
26            .last()
27            .is_none_or(|previous: &f64| (*previous - knot).abs() > KNOT_TOLERANCE)
28        {
29            result.push(knot);
30        }
31    }
32    result
33}
34
35/// `segment_boxes` served from a small content-keyed memo: the same curve
36/// content and count give the same boxes to the bit, and the imprint asks for
37/// one section's boxes once per boundary edge it is crossed with (87 times on
38/// the planter's wall, 1660 splits of a 280-control-point curve each, 107 ms
39/// of a 560 ms imprint, 2026-11-02). Eight entries, most
40/// recent first; the key is the curve's knot and control bits and the count.
41fn segment_boxes_memoized(curve: &NurbsCurve, count: usize) -> Result<std::rc::Rc<Vec<Segment>>, String> {
42    thread_local! {
43        static SEGMENT_MEMO: std::cell::RefCell<Vec<([u64; 2], std::rc::Rc<Vec<Segment>>)>> = const { std::cell::RefCell::new(Vec::new()) };
44    }
45    let key = {
46        let mut lane = crate::mass_properties::TwoLane::new();
47        crate::mass_properties::hash_curve(&mut lane, curve);
48        lane.usize(count);
49        lane.finish()
50    };
51    let hit = SEGMENT_MEMO.with(|memo| {
52        let mut memo = memo.borrow_mut();
53        let at = memo.iter().position(|(stored, _)| *stored == key)?;
54        if at != 0 {
55            let entry = memo.remove(at);
56            memo.insert(0, entry);
57        }
58        Some(memo[0].1.clone())
59    });
60    if let Some(boxes) = hit {
61        return Ok(boxes);
62    }
63    let boxes = std::rc::Rc::new(segment_boxes(curve, count)?);
64    SEGMENT_MEMO.with(|memo| {
65        let mut memo = memo.borrow_mut();
66        memo.insert(0, (key, boxes.clone()));
67        memo.truncate(8);
68    });
69    Ok(boxes)
70}
71
72/// The control-point box of each knot span of `curve` (its Bezier pieces,
73/// split at every interior knot): for positive weights each span lies inside
74/// its box, so two curves whose span boxes are pairwise apart on some axis by
75/// more than `d` are more than `d` apart everywhere. `None` when a weight is
76/// not positive (the hull property does not hold).
77pub(crate) fn span_boxes(curve: &NurbsCurve) -> Result<Option<Vec<Bounds>>, String> {
78    if !curve.control_points.iter().all(|control| control.w > 0.0) {
79        return Ok(None);
80    }
81    Ok(Some(segment_boxes(curve, 1)?.into_iter().map(|segment| segment.bounds).collect()))
82}
83
84fn segment_boxes(curve: &NurbsCurve, count: usize) -> Result<Vec<Segment>, String> {
85    if crate::switch!(live "BREP_SEGMENT_BOX_WALK", true) {
86        if let Some(segments) = segment_boxes_walk(curve, count)? {
87            return Ok(segments);
88        }
89    }
90    segment_boxes_split(curve, count)
91}
92
93/// SEGMENT-BOX WALK (`BREP_SEGMENT_BOX_WALK`, default on, =0 restores;
94/// 2026-11-03). [`segment_boxes_split`] splits the REMAINDER of the curve at
95/// every parameter, and each `NurbsCurve::split` copies and validates the whole
96/// remainder: O(n) per split, about 2000 splits of a 300-control-point section
97/// (8-9 ms per curve natively; the elephant's and the planter's crossing
98/// searches are 60 and 73 ms of box building). Boehm insertion touches only
99/// the knots and controls around the split, and the remainder's tail is always
100/// the ORIGINAL curve's, bit for bit; so this keeps the remainder as the last
101/// split's right half (a short head) followed by an offset into the original,
102/// and splits a WINDOW of it (the head and the original up to `degree + 1`
103/// knots past the parameter) with [`split_window`], `split`'s own arithmetic
104/// on slices. Every box is the same bits as the split path's; anything this
105/// cannot take (a parameter `split` would refuse) hands the whole curve back to
106/// [`segment_boxes_split`], so errors are the split path's too.
107fn segment_boxes_walk(curve: &NurbsCurve, count: usize) -> Result<Option<Vec<Segment>>, String> {
108    let [start, end] = curve.domain()?;
109    let mut parameters: Vec<f64> = (0..=count)
110        .map(|index| start + (end - start) * index as f64 / count as f64)
111        .collect();
112    parameters.extend(interior_knots(curve));
113    parameters.sort_by(f64::total_cmp);
114    parameters.dedup_by(|a, b| (*a - *b).abs() <= KNOT_TOLERANCE);
115
116    let degree = curve.degree;
117    let (knots, points) = (&curve.knots, &curve.control_points);
118    // The remainder: `head_*` followed by `knots[tail_knot..]`, `points[tail_point..]`.
119    let mut head_knots: Vec<f64> = Vec::new();
120    let mut head_points: Vec<crate::Vec4> = Vec::new();
121    let (mut tail_knot, mut tail_point) = (0usize, 0usize);
122    let mut segments = Vec::new();
123    let mut rest_start = start;
124    for (index, &parameter) in parameters.iter().enumerate().skip(1) {
125        if parameter - rest_start <= KNOT_TOLERANCE {
126            continue;
127        }
128        let knot_count = head_knots.len() + knots.len() - tail_knot;
129        let point_count = head_points.len() + points.len() - tail_point;
130        let rest_knot = |at: usize| if at < head_knots.len() { head_knots[at] } else { knots[tail_knot + at - head_knots.len()] };
131        let rest_point = |at: usize| if at < head_points.len() { head_points[at] } else { points[tail_point + at - head_points.len()] };
132        if index == parameters.len() - 1 {
133            let rest = NurbsCurve::new(degree, (0..knot_count).map(rest_knot).collect(), (0..point_count).map(rest_point).collect())?;
134            segments.push(Segment { start: rest_start, end, bounds: Bounds::from_curve_controls(&rest)? });
135            continue;
136        }
137        // `split`'s own refusal: a parameter not strictly inside the remainder.
138        let (rest_low, rest_high) = (rest_knot(degree), rest_knot(knot_count - 1 - degree));
139        if parameter <= rest_low + KNOT_TOLERANCE || parameter >= rest_high - KNOT_TOLERANCE {
140            return Ok(None);
141        }
142        let mut beyond = degree + 1;
143        while beyond < knot_count && rest_knot(beyond) <= parameter + KNOT_TOLERANCE {
144            beyond += 1;
145        }
146        let window_points = (beyond + degree + 1).max(head_points.len()).min(point_count);
147        let window_knots = window_points + degree + 1;
148        let window_knot_values: Vec<f64> = (0..window_knots).map(rest_knot).collect();
149        let window_point_values: Vec<crate::Vec4> = (0..window_points).map(rest_point).collect();
150        let Some((left_knots, left_points, right_knots, right_points)) =
151            split_window(&window_knot_values, &window_point_values, degree, parameter)
152        else {
153            return Ok(None);
154        };
155        let piece = NurbsCurve::new(degree, left_knots, left_points)?;
156        segments.push(Segment { start: rest_start, end: parameter, bounds: Bounds::from_curve_controls(&piece)? });
157        tail_point += window_points - head_points.len();
158        tail_knot += window_knots - head_knots.len();
159        head_points = right_points;
160        head_knots = right_knots;
161        rest_start = parameter;
162    }
163    Ok(Some(segments))
164}
165
166/// `NurbsCurve::split` (and the `insert_knot` it calls) on the leading
167/// WINDOW of a curve's knots and controls, with the same operations in the
168/// same order: Boehm insertion reads only the knots up to `span + degree` and
169/// the controls up to `span`, the span search finds the one interval holding
170/// the parameter, and the window reaches `degree + 1` knots past it. Returns
171/// the left piece and the window's part of the right piece; `None` where
172/// `split` would refuse.
173#[allow(clippy::type_complexity)]
174fn split_window(
175    knots: &[f64],
176    points: &[crate::Vec4],
177    degree: usize,
178    parameter: f64,
179) -> Option<(Vec<f64>, Vec<crate::Vec4>, Vec<f64>, Vec<crate::Vec4>)> {
180    use crate::curve::{knot_clamp, knot_find_span};
181    let parameter = knots.iter().copied().find(|knot| (knot - parameter).abs() <= KNOT_TOLERANCE).unwrap_or(parameter);
182    let multiplicity = knots.iter().filter(|knot| (**knot - parameter).abs() <= KNOT_TOLERANCE).count();
183    let requested = degree.saturating_sub(multiplicity);
184    // insert_knot(parameter, requested)
185    let inserted = {
186        let at = knot_clamp(knots, degree, parameter);
187        let at = knots.iter().copied().find(|knot| (knot - at).abs() <= KNOT_TOLERANCE).unwrap_or(at);
188        let multiplicity = knots.iter().filter(|knot| (**knot - at).abs() <= KNOT_TOLERANCE).count();
189        let insertion_count = requested.min(degree.saturating_sub(multiplicity));
190        if insertion_count == 0 {
191            (knots.to_vec(), points.to_vec())
192        } else {
193            let span = knot_find_span(knots, degree, at);
194            let last_control = points.len() - 1;
195            let mut refined_knots = Vec::with_capacity(knots.len() + insertion_count);
196            refined_knots.extend_from_slice(&knots[..=span]);
197            refined_knots.extend(std::iter::repeat_n(at, insertion_count));
198            refined_knots.extend_from_slice(&knots[span + 1..]);
199            let mut output = vec![crate::Vec4 { x: 0.0, y: 0.0, z: 0.0, w: 1.0 }; last_control + 1 + insertion_count];
200            output[..=span - degree].copy_from_slice(&points[..=span - degree]);
201            for index in span - multiplicity..=last_control {
202                output[index + insertion_count] = points[index];
203            }
204            let mut affected = vec![crate::Vec4 { x: 0.0, y: 0.0, z: 0.0, w: 1.0 }; degree + 1];
205            affected[..=degree - multiplicity].copy_from_slice(&points[span - degree..=span - multiplicity]);
206            let mut left = 0;
207            for insertion in 1..=insertion_count {
208                left = span - degree + insertion;
209                for index in 0..=degree - insertion - multiplicity {
210                    let denominator = knots[index + span + 1] - knots[left + index];
211                    let alpha = (at - knots[left + index]) / denominator;
212                    affected[index] = affected[index + 1].scale(alpha).add(affected[index].scale(1.0 - alpha));
213                }
214                output[left] = affected[0];
215                output[span + insertion_count - insertion - multiplicity] = affected[degree - insertion - multiplicity];
216            }
217            for index in left + 1..span - multiplicity {
218                output[index] = affected[index - left];
219            }
220            (refined_knots, output)
221        }
222    };
223    let (refined_knots, refined_points) = inserted;
224    let first = refined_knots.iter().position(|knot| (*knot - parameter).abs() <= KNOT_TOLERANCE)?;
225    if first == 0 {
226        return None;
227    }
228    let mut left_knots = refined_knots[..first + degree].to_vec();
229    left_knots.push(parameter);
230    let left_points = refined_points[..first].to_vec();
231    let mut right_knots = vec![parameter; degree + 1];
232    right_knots.extend_from_slice(&refined_knots[first + degree..]);
233    let right_points = refined_points[first - 1..].to_vec();
234    Some((left_knots, left_points, right_knots, right_points))
235}
236
237fn segment_boxes_split(curve: &NurbsCurve, count: usize) -> Result<Vec<Segment>, String> {
238    let [start, end] = curve.domain()?;
239    let mut parameters: Vec<f64> = (0..=count)
240        .map(|index| start + (end - start) * index as f64 / count as f64)
241        .collect();
242    parameters.extend(interior_knots(curve));
243    parameters.sort_by(f64::total_cmp);
244    parameters.dedup_by(|a, b| (*a - *b).abs() <= KNOT_TOLERANCE);
245
246    let mut segments = Vec::new();
247    let mut rest = curve.clone();
248    let mut rest_start = start;
249    for (index, &parameter) in parameters.iter().enumerate().skip(1) {
250        if parameter - rest_start <= KNOT_TOLERANCE {
251            continue;
252        }
253        if index == parameters.len() - 1 {
254            segments.push(Segment {
255                start: rest_start,
256                end,
257                bounds: Bounds::from_curve_controls(&rest)?,
258            });
259        } else {
260            let (piece, remainder) = rest.split(parameter)?;
261            segments.push(Segment {
262                start: rest_start,
263                end: parameter,
264                bounds: Bounds::from_curve_controls(&piece)?,
265            });
266            rest = remainder;
267            rest_start = parameter;
268        }
269    }
270    Ok(segments)
271}
272
273fn newton_closest_pair(
274    first: &NurbsCurve,
275    second: &NurbsCurve,
276    seed_s: f64,
277    seed_t: f64,
278    curvature: bool,
279) -> Result<[f64; 2], String> {
280    let [s0, s1] = first.domain()?;
281    let [t0, t1] = second.domain()?;
282    let mut s = seed_s;
283    let mut t = seed_t;
284    for _ in 0..40 {
285        let first_derivatives = first.derivatives_small(s, 2)?;
286        let second_derivatives = second.derivatives_small(t, 2)?;
287        let residual = first_derivatives[0].sub(second_derivatives[0]);
288        let f = first_derivatives[1].dot(residual);
289        let g = -second_derivatives[1].dot(residual);
290        let j00 = first_derivatives[1].length_squared()
291            + if curvature { first_derivatives[2].dot(residual) } else { 0.0 };
292        let j01 = -first_derivatives[1].dot(second_derivatives[1]);
293        let j10 = -second_derivatives[1].dot(first_derivatives[1]);
294        let j11 = second_derivatives[1].length_squared()
295            - if curvature { second_derivatives[2].dot(residual) } else { 0.0 };
296        let residual_length = residual.length();
297        let converged_first =
298            f.abs() <= EPSILON + 1e-10 * first_derivatives[1].length() * residual_length.max(1e-30);
299        let converged_second = g.abs()
300            <= EPSILON + 1e-10 * second_derivatives[1].length() * residual_length.max(1e-30);
301        if (converged_first && converged_second) || residual_length <= EPSILON {
302            return Ok([s, t]);
303        }
304        let determinant = j00 * j11 - j01 * j10;
305        if determinant.abs() <= EPSILON {
306            return Ok([s, t]);
307        }
308        let mut ds = (-f * j11 + g * j01) / determinant;
309        let mut dt = (-g * j00 + f * j10) / determinant;
310        ds = ds.clamp(-(s1 - s0) / 4.0, (s1 - s0) / 4.0);
311        dt = dt.clamp(-(t1 - t0) / 4.0, (t1 - t0) / 4.0);
312        let next_s = (s + ds).clamp(s0, s1);
313        let next_t = (t + dt).clamp(t0, t1);
314        if (next_s - s).abs() <= 1e-15 * (s1 - s0) && (next_t - t).abs() <= 1e-15 * (t1 - t0) {
315            return Ok([next_s, next_t]);
316        }
317        s = next_s;
318        t = next_t;
319    }
320    Ok([s, t])
321}
322
323fn parameter_tolerance(curve: &NurbsCurve, parameter: f64, tolerance: f64) -> Result<f64, String> {
324    let speed = curve.deriv1(parameter)?.1.length();
325    let [start, end] = curve.domain()?;
326    if speed <= EPSILON {
327        Ok((end - start) * 1e-3)
328    } else {
329        Ok(((tolerance / speed) * 10.0).max((end - start) * 1e-9))
330    }
331}
332
333fn parameter_distance(a: f64, b: f64, start: f64, end: f64, closed: bool) -> f64 {
334    let distance = (a - b).abs();
335    if closed {
336        distance.min(end - start - distance)
337    } else {
338        distance
339    }
340}
341
342#[derive(Clone, Copy, Debug, Serialize)]
343pub struct CurveCurveIntersection {
344    pub s: f64,
345    pub t: f64,
346    pub point: Vec3,
347    pub gap: f64,
348    pub tangential: bool,
349}
350
351/// `BREP_EXACT_LINE_LINE` (lane X row 7): two straight segments that are not
352/// near parallel meet where their exact closest pair says
353/// ([`exact_line_line`]), instead of where Newton runs from box seeds settle.
354/// On by default; `0`/`off` returns to the general search, and `census`
355/// answers by the general search and appends one line per call whose answers
356/// differ to `BREP_EXACT_LINE_LINE_CENSUS`.
357///
358/// Promoted 2026-10-16 on top of the canonical fragment order. 239 documents:
359/// 6,853 calls change, every one with the same hit count; the exact pair
360/// removes residuals up to 2.02e-8, and the per-hit gap goes from a 2.17e-8
361/// maximum to 7.9e-9. The exact pair is exact by construction, and the 1,387
362/// hits whose evaluated gap reads above the general search's, by at most
363/// 1.21e-13 (p50 8.9e-16), are the rounding floor of measuring the gap, not
364/// lost accuracy. 49 documents change bytes; every body is geometrically the
365/// same, with no name moved.
366#[derive(Clone, Copy, PartialEq, Eq)]
367enum LineLineMode {
368    Off,
369    On,
370    Census,
371}
372
373fn line_line_mode() -> LineLineMode {
374    static MODE: std::sync::OnceLock<LineLineMode> = std::sync::OnceLock::new();
375    let mode = *MODE.get_or_init(|| match std::env::var("BREP_EXACT_LINE_LINE").as_deref() {
376        Ok("0") | Ok("off") => LineLineMode::Off,
377        Ok("census") => LineLineMode::Census,
378        _ => LineLineMode::On,
379    });
380    mode
381}
382
383
384/// Lane O batch 22 (tier-3 finding 1, `BREP_CURVE_TANGENT_MERGE_LOCAL`,
385/// default on since batch 27; `1`/`on`, `census`): the duplicate guard's radius for
386/// TANGENTIAL hits and the cluster's representative.
387///
388/// Today a tangential hit merges every other hit within
389/// `sqrt(tolerance)·(1 + |p|)` of it — a radius that grows with the point's
390/// distance from the origin, not with the contact: at |p| ≈ 76 it is 0.24
391/// and swallows every tangential hit of a 3.8e-3-radius pair but one (the
392/// 2026-11-02 conic-conic census, record 6), and the one it keeps is the
393/// first seed's, not the closest (record 54: a 1.1e-7 graze represents a
394/// 9e-14 crossing). `On` derives the radius from the contact itself: the
395/// half-width of the band where the two curves stay within the tolerance,
396/// `min(tolerance / sin θ, sqrt(2·tolerance·ρ))` with θ the tangent angle
397/// and ρ = 1 / |κ₁n₁ − κ₂n₂| the relative curvature radius, CAPPED by
398/// today's radius (never wider; a near-coincident stretch with ρ → ∞ merges
399/// exactly as today) and floored at the transverse 10·tolerance; and a
400/// cluster is represented by its smallest-gap hit. `Census` answers as
401/// today and appends one record per call where the two rules differ in a
402/// merge decision or a representative to the file
403/// `BREP_CURVE_TANGENT_MERGE_CENSUS` names (one write per record).
404#[derive(Clone, Copy, PartialEq, Eq)]
405enum TangentMergeMode {
406    Off,
407    On,
408    Census,
409}
410
411fn tangent_merge_mode() -> TangentMergeMode {
412    static MODE: std::sync::OnceLock<TangentMergeMode> = std::sync::OnceLock::new();
413    // On by default since 2026-11-03 (lane O batch 27); `0`/`off` restores
414    // today's |p|-scaled radius.
415    let mode = *MODE.get_or_init(|| match std::env::var("BREP_CURVE_TANGENT_MERGE_LOCAL").as_deref() {
416        Ok("0") | Ok("off") => TangentMergeMode::Off,
417        Ok("census") => TangentMergeMode::Census,
418        _ => TangentMergeMode::On,
419    });
420    mode
421}
422
423
424/// The contact-derived merge radius at a hit `(s, t)`: the half-width of the
425/// band where `first` and `second` stay within `tolerance` of each other,
426/// `min(tolerance / sin θ, sqrt(2·tolerance·ρ))`, capped by `legacy` (today's
427/// radius) and floored at `10·tolerance`. A hit whose tangents cannot be read
428/// keeps `legacy`.
429fn local_tangent_merge_radius(
430    first: &NurbsCurve,
431    second: &NurbsCurve,
432    s: f64,
433    t: f64,
434    tolerance: f64,
435    legacy: f64,
436) -> Result<(f64, f64, f64), String> {
437    let a = first.derivatives_small(s, 2)?;
438    let b = second.derivatives_small(t, 2)?;
439    let (speed_a, speed_b) = (a[1].length(), b[1].length());
440    if speed_a <= EPSILON || speed_b <= EPSILON {
441        return Ok((legacy, f64::NAN, f64::NAN));
442    }
443    let tangent_a = a[1].scale(1.0 / speed_a);
444    let tangent_b = b[1].scale(1.0 / speed_b);
445    let sin_theta = tangent_a.cross(tangent_b).length().min(1.0);
446    // Curvature vectors κn = (d2 − (d2·t)t) / |d1|²: orientation-free, so the
447    // two curves' traversal senses do not matter.
448    let curvature_a = a[2].sub(tangent_a.scale(a[2].dot(tangent_a))).scale(1.0 / (speed_a * speed_a));
449    let curvature_b = b[2].sub(tangent_b.scale(b[2].dot(tangent_b))).scale(1.0 / (speed_b * speed_b));
450    let relative = curvature_a.sub(curvature_b).length();
451    let mut radius = f64::INFINITY;
452    if sin_theta > 0.0 {
453        radius = radius.min(tolerance / sin_theta);
454    }
455    if relative > 0.0 {
456        radius = radius.min((2.0 * tolerance / relative).sqrt());
457    }
458    Ok((radius.min(legacy).max(10.0 * tolerance), sin_theta, relative))
459}
460
461/// The segment a degree-1 curve of two equal-weight control points traces
462/// linearly over its clamped knots: (start point, end point, domain).
463fn straight_segment(curve: &NurbsCurve) -> Option<(Vec3, Vec3, [f64; 2])> {
464    if curve.degree != 1 || curve.control_points.len() != 2 || curve.knots.len() != 4 {
465        return None;
466    }
467    let k = &curve.knots;
468    if k[0] != k[1] || k[2] != k[3] || !(k[1] < k[2]) {
469        return None;
470    }
471    if curve.control_points[0].w != curve.control_points[1].w {
472        return None;
473    }
474    Some((curve.control_points[0].point().ok()?, curve.control_points[1].point().ok()?, [k[1], k[2]]))
475}
476
477/// The exact meeting of two straight segments that are not near parallel:
478/// their unique closest pair (each parameter clamped to its segment, the
479/// other then re-solved), one transverse hit when its gap is within
480/// `tolerance`, none otherwise. `None` (the general search answers) for
481/// anything else: a curve that is not a straight segment, a degenerate one,
482/// or two directions within the tangential band of `intersect_curves`.
483pub(crate) fn exact_line_line(first: &NurbsCurve, second: &NurbsCurve, tolerance: f64) -> Option<Vec<CurveCurveIntersection>> {
484    let (p0, p1, [s0, s1]) = straight_segment(first)?;
485    let (q0, q1, [t0, t1]) = straight_segment(second)?;
486    let d1 = p1.sub(p0);
487    let d2 = q1.sub(q0);
488    let a = d1.dot(d1);
489    let e = d2.dot(d2);
490    if a <= EPSILON || e <= EPSILON {
491        return None;
492    }
493    if d1.cross(d2).length() <= 1e-3_f64.sin() * (a * e).sqrt() {
494        return None;
495    }
496    let r = p0.sub(q0);
497    let b = d1.dot(d2);
498    let c = d1.dot(r);
499    let f = d2.dot(r);
500    let denominator = a * e - b * b;
501    let mut s = ((b * f - c * e) / denominator).clamp(0.0, 1.0);
502    let mut t = (b * s + f) / e;
503    if t < 0.0 {
504        t = 0.0;
505        s = (-c / a).clamp(0.0, 1.0);
506    } else if t > 1.0 {
507        t = 1.0;
508        s = ((b - c) / a).clamp(0.0, 1.0);
509    }
510    let first_point = p0.add(d1.scale(s));
511    let second_point = q0.add(d2.scale(t));
512    let gap = first_point.sub(second_point).length();
513    if gap > tolerance {
514        return Some(Vec::new());
515    }
516    Some(vec![CurveCurveIntersection {
517        s: (s0 + (s1 - s0) * s).clamp(s0, s1),
518        t: (t0 + (t1 - t0) * t).clamp(t0, t1),
519        point: first_point.add(second_point).scale(0.5),
520        gap,
521        tangential: false,
522    }])
523}
524
525pub fn intersect_curves(
526    first: &NurbsCurve,
527    second: &NurbsCurve,
528    tolerance: f64,
529) -> Result<Vec<CurveCurveIntersection>, String> {
530    let mode = line_line_mode();
531    if mode != LineLineMode::Off {
532        if let Some(exact) = exact_line_line(first, second, tolerance) {
533            if mode == LineLineMode::On {
534                return Ok(exact);
535            }
536            let general = intersect_curves_general(first, second, tolerance)?;
537            line_line_census(first, second, tolerance, &general, &exact)?;
538            return Ok(general);
539        }
540    }
541    let mode = line_conic_mode();
542    if mode != LineLineMode::Off {
543        if let Some(seeds) = exact_line_conic_pairs(first, second, tolerance) {
544            let exact = intersect_curves_from_seeds(first, second, tolerance, seeds)?;
545            if mode == LineLineMode::On {
546                return Ok(exact);
547            }
548            let general = intersect_curves_general(first, second, tolerance)?;
549            crossing_census("line-conic", "BREP_EXACT_LINE_CONIC_CENSUS", first, second, tolerance, &general, &exact)?;
550            return Ok(general);
551        }
552    }
553    let mode = conic_conic_mode();
554    if mode != LineLineMode::Off {
555        if let Some(seeds) = exact_conic_conic_pairs(first, second, tolerance) {
556            let exact = intersect_curves_from_seeds(first, second, tolerance, seeds)?;
557            if mode == LineLineMode::On {
558                return Ok(exact);
559            }
560            let general = intersect_curves_general(first, second, tolerance)?;
561            // Self-check (lane O batch 22, finding 2): the 2026-11-02 census
562            // recorded one general crossing for a pair every later replay
563            // answers with two; a second call here, compared by bits, makes
564            // the record say whether the search itself wavered in the run.
565            general_rerun_census("conic-conic", "BREP_EXACT_CONIC_CONIC_CENSUS", first, second, tolerance, &general)?;
566            crossing_census("conic-conic", "BREP_EXACT_CONIC_CONIC_CENSUS", first, second, tolerance, &general, &exact)?;
567            return Ok(general);
568        }
569    }
570    intersect_curves_general(first, second, tolerance)
571}
572
573/// Lane O row 40 (`BREP_EXACT_CONIC_CONIC`, default on since batch 27, `0` restores; `1`/`on` answers by
574/// it, `census` answers by the general search and appends one line per call
575/// whose answers differ to `BREP_EXACT_CONIC_CONIC_CENSUS`): two planar curves
576/// of degree two seed the general search's own Newton, gap filter, duplicate
577/// guard and tangency reading ([`intersect_curves_from_seeds`]) at closed-form
578/// crossings instead of at the midpoints of every overlapping pair of segment
579/// boxes. See [`exact_conic_conic_pairs`].
580fn conic_conic_mode() -> LineLineMode {
581    static MODE: std::sync::OnceLock<LineLineMode> = std::sync::OnceLock::new();
582    // On by default since 2026-11-03 (lane O batch 27); `0`/`off` restores
583    // the general search for two planar conics.
584    let mode = *MODE.get_or_init(|| match std::env::var("BREP_EXACT_CONIC_CONIC").as_deref() {
585        Ok("0") | Ok("off") => LineLineMode::Off,
586        Ok("census") => LineLineMode::Census,
587        _ => LineLineMode::On,
588    });
589    mode
590}
591
592
593/// The plane of a planar curve of degree two with positive finite weights:
594/// a control point, the unit normal of the widest control triangle, and the
595/// net's extent. `None` for collinear controls or a net out of one plane by
596/// more than 1e-12 of its extent (row 28's conic test).
597fn conic_plane(curve: &NurbsCurve) -> Option<(Vec3, Vec3, f64)> {
598    if curve.degree != 2 || curve.control_points.len() < 3 {
599        return None;
600    }
601    if curve.control_points.iter().any(|p| !(p.w > 0.0 && p.w.is_finite())) {
602        return None;
603    }
604    let points = curve.control_points.iter().map(|p| p.point().ok()).collect::<Option<Vec<_>>>()?;
605    let base = points[0];
606    let extent = points.iter().fold(0.0_f64, |m, p| m.max(p.sub(base).length()));
607    if !(extent > 0.0 && extent.is_finite()) {
608        return None;
609    }
610    let mut normal = Vec3::new(0.0, 0.0, 0.0);
611    let mut area = 0.0_f64;
612    for i in 1..points.len() {
613        for j in i + 1..points.len() {
614            let cross = points[i].sub(base).cross(points[j].sub(base));
615            if cross.length() > area {
616                area = cross.length();
617                normal = cross;
618            }
619        }
620    }
621    if !(area > 1e-9 * extent * extent) {
622        return None;
623    }
624    let normal = normal.scale(1.0 / area);
625    if points.iter().any(|p| normal.dot(p.sub(base)).abs() > 1e-12 * extent) {
626        return None;
627    }
628    Some((base, normal, extent))
629}
630
631/// The `[s, t]` Newton seeds of row 40 for two planar curves of degree two;
632/// `None` when the lane declines.
633///
634/// - Planes that cross (more than a milliradian apart): every crossing lies on
635///   the line the planes share, so `first`'s seeds are row 28's crossings of
636///   `first` with that line ([`crate::exact_line_surface::exact_line_conic_seeds`],
637///   grazes within a hundred tolerances included).
638/// - One plane, `second` a circle (recognized to 1e-12 of its size): a curve
639///   in a circle's plane crosses it where it crosses the sphere on that
640///   circle, so `first`'s seeds are row 27's sphere roots
641///   ([`crate::exact_line_surface::exact_curve_sphere_seeds`], which declines
642///   a span on the sphere and a double root). A sample of `first` within a
643///   hundred tolerances of the circle with no seed declines too: a near
644///   graze the general search would report.
645/// - Anything else (parallel planes, an ellipse sharing a plane, a non-planar
646///   net) declines.
647///
648/// Each seed is paired with its point's projection onto `second`; the general
649/// search's Newton and gap filter decide what is a crossing.
650fn exact_conic_conic_pairs(first: &NurbsCurve, second: &NurbsCurve, tolerance: f64) -> Option<Vec<[f64; 2]>> {
651    let (base_a, normal_a, extent_a) = conic_plane(first)?;
652    let (base_b, normal_b, extent_b) = conic_plane(second)?;
653    let extent = extent_a.max(extent_b);
654    let shared = normal_a.cross(normal_b);
655    let seeds = if shared.length() > 1e-3_f64.sin() {
656        // The line both planes hold, through the point of it nearest `base_a`.
657        let direction = shared.scale(1.0 / shared.length());
658        let (h_a, h_b) = (normal_a.dot(base_a), normal_b.dot(base_b));
659        let denominator = shared.dot(shared);
660        let on_line = normal_b.cross(shared).scale(h_a / denominator).add(shared.cross(normal_a).scale(h_b / denominator));
661        let foot = on_line.add(direction.scale(base_a.sub(on_line).dot(direction)));
662        let reach = 2.0 * (extent + base_a.sub(foot).length()).max(1.0);
663        crate::exact_line_surface::exact_line_conic_seeds(foot.sub(direction.scale(reach)), foot.add(direction.scale(reach)), first, tolerance)?
664    } else if normal_a.dot(base_b.sub(base_a)).abs() <= 1e-12 * extent {
665        let [t0, t1] = second.domain().ok()?;
666        let circle = crate::recognize_circle(second, t0, t1, 1e-12 * extent.max(1.0))?;
667        let seeds = crate::exact_line_surface::exact_curve_sphere_seeds(first, circle.centre, circle.radius, tolerance)?;
668        if seeds.is_empty() {
669            let [s0, s1] = first.domain().ok()?;
670            let band = 100.0 * tolerance;
671            for i in 0..=64 {
672                let point = first.evaluate(s0 + (s1 - s0) * i as f64 / 64.0).ok()?;
673                if (point.sub(circle.centre).length() - circle.radius).abs() <= band {
674                    return None;
675                }
676            }
677        }
678        seeds
679    } else {
680        return None;
681    };
682    seeds
683        .into_iter()
684        .map(|s| {
685            let point = first.evaluate(s).ok()?;
686            let projection = crate::project_point_to_curve(second, point).ok()?;
687            Some([s, projection.u])
688        })
689        .collect()
690}
691
692/// Lane O row 28 (`BREP_EXACT_LINE_CONIC`, default on since batch 12; `0`/`off`
693/// restores the general search, `census` answers by it and appends one line per call
694/// whose answers differ to `BREP_EXACT_LINE_CONIC_CENSUS`): a straight
695/// segment against a planar curve of degree two, either order, seeds the
696/// general search's own Newton, gap filter, duplicate guard and tangency
697/// reading ([`intersect_curves_from_seeds`]) at the closed-form crossings of
698/// [`crate::exact_line_surface::exact_line_conic_seeds`] instead of at the
699/// midpoints of every overlapping pair of segment boxes. Each conic seed is
700/// paired with its point's projection onto the segment.
701fn line_conic_mode() -> LineLineMode {
702    static MODE: std::sync::OnceLock<LineLineMode> = std::sync::OnceLock::new();
703    let mode = *MODE.get_or_init(|| match std::env::var("BREP_EXACT_LINE_CONIC").as_deref() {
704        Ok("0") | Ok("off") => LineLineMode::Off,
705        Ok("census") => LineLineMode::Census,
706        _ => LineLineMode::On,
707    });
708    mode
709}
710
711
712/// The `[s, t]` Newton seeds of row 28 for `first` against `second`, when one
713/// is a straight segment and the other a planar curve of degree two; `None`
714/// when the lane declines.
715fn exact_line_conic_pairs(first: &NurbsCurve, second: &NurbsCurve, tolerance: f64) -> Option<Vec<[f64; 2]>> {
716    let (line_first, (p0, p1, [l0, l1]), conic) = match (straight_segment(first), straight_segment(second)) {
717        (Some(segment), None) => (true, segment, second),
718        (None, Some(segment)) => (false, segment, first),
719        _ => return None,
720    };
721    let seeds = crate::exact_line_surface::exact_line_conic_seeds(p0, p1, conic, tolerance)?;
722    let direction = p1.sub(p0);
723    let length_squared = direction.dot(direction);
724    seeds
725        .into_iter()
726        .map(|t| {
727            let point = conic.evaluate(t).ok()?;
728            let along = (point.sub(p0).dot(direction) / length_squared).clamp(0.0, 1.0);
729            let s = l0 + along * (l1 - l0);
730            Some(if line_first { [s, t] } else { [t, s] })
731        })
732        .collect()
733}
734
735/// One census line per call whose general and exact answers differ: both
736/// hit lists, and for each general hit its points' distances from the exact
737/// closest pair (the residual the exact answer removes).
738fn line_line_census(
739    first: &NurbsCurve,
740    second: &NurbsCurve,
741    tolerance: f64,
742    general: &[CurveCurveIntersection],
743    exact: &[CurveCurveIntersection],
744) -> Result<(), String> {
745    crossing_census("line-line", "BREP_EXACT_LINE_LINE_CENSUS", first, second, tolerance, general, exact)
746}
747
748/// Census self-check: the general search run a second time on the same
749/// pair, appended as `<lane> rerun census: … general <n> rerun <m>` with
750/// both hit lists when any hit differs by bits (one write per record).
751fn general_rerun_census(
752    lane: &str,
753    variable: &str,
754    first: &NurbsCurve,
755    second: &NurbsCurve,
756    tolerance: f64,
757    general: &[CurveCurveIntersection],
758) -> Result<(), String> {
759    let Ok(path) = std::env::var(variable) else {
760        return Ok(());
761    };
762    let rerun = intersect_curves_general(first, second, tolerance)?;
763    let same = general.len() == rerun.len()
764        && general.iter().zip(&rerun).all(|(g, r)| g.s.to_bits() == r.s.to_bits() && g.t.to_bits() == r.t.to_bits());
765    if same {
766        return Ok(());
767    }
768    let mut line = format!("{lane} rerun census: tolerance={tolerance:e} general {} rerun {}", general.len(), rerun.len());
769    for g in general {
770        line.push_str(&format!(" | general s={:e} t={:e} gap={:e}", g.s, g.t, g.gap));
771    }
772    for r in &rerun {
773        line.push_str(&format!(" | rerun s={:e} t={:e} gap={:e}", r.s, r.t, r.gap));
774    }
775    if let (Ok(a), Ok(b)) = (serde_json::to_string(first), serde_json::to_string(second)) {
776        line.push_str(&format!(" | curves=[{a},{b}]"));
777    }
778    line.push('\n');
779    use std::io::Write;
780    if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
781        let _ = file.write_all(line.as_bytes());
782    }
783    Ok(())
784}
785
786/// [`line_line_census`] for the lane named `lane`, appending to the file that
787/// `variable` names. The record names the call's tolerance, every exact hit,
788/// and every general hit with its parameter offset and spatial residual from
789/// the nearest exact hit. When the two answers disagree on the hit count, or a
790/// general hit sits further than the tolerance from every exact hit, the
791/// record ends with both curves as JSON (`curves=`) so the pair can be
792/// replayed. One record is one `write` so concurrent callers never splice.
793fn crossing_census(
794    lane: &str,
795    variable: &str,
796    first: &NurbsCurve,
797    second: &NurbsCurve,
798    tolerance: f64,
799    general: &[CurveCurveIntersection],
800    exact: &[CurveCurveIntersection],
801) -> Result<(), String> {
802    let same = general.len() == exact.len()
803        && general.iter().zip(exact).all(|(g, x)| g.s.to_bits() == x.s.to_bits() && g.t.to_bits() == x.t.to_bits());
804    if same {
805        return Ok(());
806    }
807    let Ok(path) = std::env::var(variable) else {
808        return Ok(());
809    };
810    let mut line = format!(
811        "{lane} census: label {} tolerance={:e} general {} exact {}",
812        std::env::var("BREP_CENSUS_LABEL").unwrap_or_default(),
813        tolerance,
814        general.len(),
815        exact.len()
816    );
817    let mut exact_points = Vec::with_capacity(exact.len());
818    for x in exact {
819        exact_points.push((first.evaluate(x.s)?, second.evaluate(x.t)?));
820        line.push_str(&format!(" | exact s={:e} t={:e} gap={:e}", x.s, x.t, x.gap));
821    }
822    let mut unmatched = false;
823    for g in general {
824        let (on_first, on_second) = (first.evaluate(g.s)?, second.evaluate(g.t)?);
825        let nearest = exact
826            .iter()
827            .zip(&exact_points)
828            .map(|(x, (xf, xs))| (on_first.sub(*xf).length().max(on_second.sub(*xs).length()), x))
829            .min_by(|a, b| a.0.total_cmp(&b.0));
830        match nearest {
831            Some((residual, x)) => {
832                unmatched |= residual > tolerance;
833                line.push_str(&format!(
834                    " | general s={:e} t={:e} gap={:e} ds={:e} dt={:e} residual={:e}",
835                    g.s,
836                    g.t,
837                    g.gap,
838                    g.s - x.s,
839                    g.t - x.t,
840                    residual
841                ));
842            }
843            None => {
844                unmatched = true;
845                line.push_str(&format!(" | general s={:e} t={:e} gap={:e}", g.s, g.t, g.gap));
846            }
847        }
848    }
849    if general.len() != exact.len() || unmatched {
850        if let (Ok(a), Ok(b)) = (serde_json::to_string(first), serde_json::to_string(second)) {
851            line.push_str(&format!(" | curves=[{a},{b}]"));
852        }
853    }
854    line.push('\n');
855    use std::io::Write;
856    if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
857        let _ = file.write_all(line.as_bytes());
858    }
859    Ok(())
860}
861
862/// Every `(i, j)` with `first[i].bounds` and `second[j].bounds` within
863/// `tolerance` of each other, in ascending `(i, j)` order — exactly the pairs
864/// and the order the nested loop produced. The second list is sorted by its
865/// boxes' minimum x once; for each first segment the candidates are the
866/// second segments whose minimum x lies at or below the first's maximum x
867/// plus the tolerance (a prefix of the sorted order), filtered by the full
868/// box test. Each pair is tested by the same `Aabb::intersects` as before.
869fn overlapping_segment_pairs(first: &[Segment], second: &[Segment], tolerance: f64) -> Vec<(usize, usize)> {
870    let mut order: Vec<usize> = (0..second.len()).collect();
871    order.sort_by(|&a, &b| second[a].bounds.minimum.x.total_cmp(&second[b].bounds.minimum.x));
872    let minimum_x: Vec<f64> = order.iter().map(|&j| second[j].bounds.minimum.x).collect();
873    let mut pairs = Vec::new();
874    for (i, segment) in first.iter().enumerate() {
875        let limit = segment.bounds.maximum.x + tolerance;
876        // Candidates: the prefix with minimum x <= limit.
877        let end = minimum_x.partition_point(|&x| x <= limit);
878        let mut row: Vec<usize> = order[..end]
879            .iter()
880            .copied()
881            .filter(|&j| segment.bounds.intersects_upper(second[j].bounds, tolerance))
882            .collect();
883        row.sort_unstable();
884        pairs.extend(row.into_iter().map(|j| (i, j)));
885    }
886    pairs
887}
888
889fn intersect_curves_general(
890    first: &NurbsCurve,
891    second: &NurbsCurve,
892    tolerance: f64,
893) -> Result<Vec<CurveCurveIntersection>, String> {
894    let first_segment_count = 8usize.max((interior_knots(first).len() + 1) * (first.degree + 1));
895    let second_segment_count = 8usize.max((interior_knots(second).len() + 1) * (second.degree + 1));
896    let first_segments = segment_boxes_memoized(first, first_segment_count)?;
897    let second_segments = segment_boxes_memoized(second, second_segment_count)?;
898    // The overlapping segment pairs, in the order the nested loop over
899    // `first_segments` then `second_segments` visits them: the seeds' order
900    // decides which of two converging seeds is kept as the hit below, so the
901    // pairs are gathered by a sweep over the x extents (sorted once, a binary
902    // search per first segment) and then put back into that order. Two fitted
903    // sections of two thousand segments each cost four million box tests
904    // a call before (2026-10-17, the shelled-box report's shell: 0.57 s of
905    // `lies_on` spans and 0.55 s of section crossings on three face pairs).
906    let seeds = overlapping_segment_pairs(&first_segments, &second_segments, tolerance)
907        .into_iter()
908        .map(|(i, j)| {
909            [
910                (first_segments[i].start + first_segments[i].end) * 0.5,
911                (second_segments[j].start + second_segments[j].end) * 0.5,
912            ]
913        })
914        .collect::<Vec<_>>();
915    intersect_curves_from_seeds(first, second, tolerance, seeds)
916}
917
918/// The general search past its seeding: a Newton run from each `[s, t]`
919/// seed (a Gauss–Newton retry for those that fail), then the gap filter, the
920/// duplicate guard and the tangency reading, sorted by `s`.
921fn intersect_curves_from_seeds(
922    first: &NurbsCurve,
923    second: &NurbsCurve,
924    tolerance: f64,
925    seeds: Vec<[f64; 2]>,
926) -> Result<Vec<CurveCurveIntersection>, String> {
927    let [s0, s1] = first.domain()?;
928    let [t0, t1] = second.domain()?;
929    let closed_first = first.evaluate(s0)?.sub(first.evaluate(s1)?).length() <= tolerance;
930    let closed_second = second.evaluate(t0)?.sub(second.evaluate(t1)?).length() <= tolerance;
931    // The distance Hessian can lead Newton to a nonzero stationary point
932    // instead of a crossing. For a secant of a closed circle, every box seed
933    // can converge to the circle's seam this way. Retry failed seeds with
934    // the positive-semidefinite Gauss–Newton system (no curvature terms).
935    // Keep the successful Newton candidates first so their established
936    // parameters win deduplication; the retry must meet the same gap test.
937    let mut candidates = Vec::with_capacity(seeds.len());
938    let mut failed = Vec::new();
939    for [seed_s, seed_t] in seeds {
940        let pair = newton_closest_pair(first, second, seed_s, seed_t, true)?;
941        if first.evaluate(pair[0])?.sub(second.evaluate(pair[1])?).length() <= tolerance {
942            candidates.push(pair);
943        } else {
944            failed.push([seed_s, seed_t]);
945        }
946    }
947    for [seed_s, seed_t] in failed {
948        candidates.push(newton_closest_pair(first, second, seed_s, seed_t, false)?);
949    }
950    let mut results: Vec<CurveCurveIntersection> = Vec::new();
951    // The contact radius of each kept TANGENTIAL hit (`BREP_CURVE_TANGENT_MERGE_LOCAL`
952    // on or census), so a later TRANSVERSE candidate near it is judged by that
953    // hit's contact, not only by its own transverse band: the rule is the same
954    // whichever of the two seeds arrives first.
955    let mut kept_radii: Vec<Option<f64>> = Vec::new();
956    for [s, t] in candidates {
957        let first_point = first.evaluate(s)?;
958        let second_point = second.evaluate(t)?;
959        let gap = first_point.sub(second_point).length();
960        if gap > tolerance {
961            continue;
962        }
963        let first_tangent = first.deriv1(s)?.1;
964        let second_tangent = second.deriv1(t)?.1;
965        let tangential = match (first_tangent.normalized(), second_tangent.normalized()) {
966            (Ok(a), Ok(b)) => a.cross(b).length() <= 1e-3_f64.sin(),
967            _ => false,
968        };
969        let point = first_point.add(second_point).scale(0.5);
970        let scale = 1.0 + point.length();
971        let spatial_tolerance = if tangential {
972            tolerance.sqrt() * scale
973        } else {
974            tolerance * 10.0
975        };
976        // Lane O batch 22: the contact-derived tangential radius (see
977        // `TangentMergeMode`). Read only when the rule is on or counted.
978        let merge_mode = tangent_merge_mode();
979        let local = if tangential && merge_mode != TangentMergeMode::Off {
980            Some(local_tangent_merge_radius(first, second, s, t, tolerance, spatial_tolerance)?)
981        } else {
982            None
983        };
984        let local_tolerance = local.map(|(radius, _, _)| radius).unwrap_or(spatial_tolerance);
985        // The duplicate each rule finds: today's radius and the local one.
986        let (mut legacy_duplicate, mut local_duplicate) = (None, None);
987        for (index, result) in results.iter().enumerate() {
988            let in_parameter = parameter_distance(result.s, s, s0, s1, closed_first)
989                <= parameter_tolerance(first, result.s, tolerance)?
990                && parameter_distance(result.t, t, t0, t1, closed_second)
991                    <= parameter_tolerance(second, result.t, tolerance)?;
992            let distance = result.point.sub(point).length();
993            let either_tangential = result.tangential || tangential;
994            if legacy_duplicate.is_none()
995                && (in_parameter || distance <= if either_tangential { spatial_tolerance } else { tolerance * 10.0 })
996            {
997                legacy_duplicate = Some(index);
998            }
999            // Local: the tangential member's contact radius, the larger of the
1000            // two where both are tangential; a transverse pair keeps 10·tol.
1001            let local_radius = match (tangential, kept_radii.get(index).copied().flatten()) {
1002                (true, Some(kept)) => local_tolerance.max(kept),
1003                (true, None) => local_tolerance,
1004                (false, Some(kept)) => kept,
1005                (false, None) => tolerance * 10.0,
1006            };
1007            if local_duplicate.is_none() && (in_parameter || distance <= local_radius) {
1008                local_duplicate = Some(index);
1009            }
1010            if legacy_duplicate.is_some() && (merge_mode == TangentMergeMode::Off || local_duplicate.is_some()) {
1011                break;
1012            }
1013        }
1014        let candidate = CurveCurveIntersection {
1015            s: s.clamp(s0, s1),
1016            t: t.clamp(t0, t1),
1017            point,
1018            gap,
1019            tangential,
1020        };
1021        let candidate_radius = local.map(|(radius, _, _)| radius);
1022        match merge_mode {
1023            TangentMergeMode::Off => {
1024                if legacy_duplicate.is_some() {
1025                    continue;
1026                }
1027                results.push(candidate);
1028            }
1029            TangentMergeMode::On => match local_duplicate {
1030                // The cluster keeps its smallest-gap hit.
1031                Some(index) => {
1032                    if gap < results[index].gap {
1033                        results[index] = candidate;
1034                        kept_radii[index] = candidate_radius;
1035                    }
1036                }
1037                None => {
1038                    results.push(candidate);
1039                    kept_radii.push(candidate_radius);
1040                }
1041            },
1042            TangentMergeMode::Census => {
1043                // Answer as today; record where the local rule would differ:
1044                // a different merge decision, or a representative the local
1045                // rule would replace by this smaller-gap hit.
1046                let legacy_keeps = legacy_duplicate.is_none();
1047                let local_keeps = local_duplicate.is_none();
1048                let would_replace = local_duplicate.is_some_and(|index| gap < results[index].gap);
1049                if legacy_keeps != local_keeps || would_replace {
1050                    if let Ok(path) = std::env::var("BREP_CURVE_TANGENT_MERGE_CENSUS") {
1051                        let (local_radius, sin_theta, relative) = local.unwrap_or((spatial_tolerance, f64::NAN, f64::NAN));
1052                        let against = legacy_duplicate.or(local_duplicate).map(|index| &results[index]);
1053                        let mut line = format!(
1054                            "tangent-merge census: label {} tolerance={:e} p={:e} legacy_radius={:e} local_radius={:e} sin_theta={:e} relative_curvature={:e} gap={:e} tangential={} legacy_keeps={} local_keeps={} would_replace={}",
1055                            std::env::var("BREP_CENSUS_LABEL").unwrap_or_default(),
1056                            tolerance,
1057                            point.length(),
1058                            spatial_tolerance,
1059                            local_radius,
1060                            sin_theta,
1061                            relative,
1062                            gap,
1063                            tangential,
1064                            legacy_keeps,
1065                            local_keeps,
1066                            would_replace,
1067                        );
1068                        if let Some(other) = against {
1069                            line.push_str(&format!(
1070                                " | against s={:e} t={:e} gap={:e} tangential={} distance={:e}",
1071                                other.s,
1072                                other.t,
1073                                other.gap,
1074                                other.tangential,
1075                                other.point.sub(point).length()
1076                            ));
1077                        }
1078                        if let (Ok(a), Ok(b)) = (serde_json::to_string(first), serde_json::to_string(second)) {
1079                            line.push_str(&format!(" | curves=[{a},{b}]"));
1080                        }
1081                        line.push('\n');
1082                        use std::io::Write;
1083                        if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
1084                            let _ = file.write_all(line.as_bytes());
1085                        }
1086                    }
1087                }
1088                if legacy_duplicate.is_some() {
1089                    continue;
1090                }
1091                results.push(candidate);
1092                kept_radii.push(candidate_radius);
1093            }
1094        }
1095    }
1096    results.sort_by(|a, b| a.s.total_cmp(&b.s));
1097    Ok(results)
1098}
1099
1100
1101