Skip to main content

brep_kernel/intersect/
crossing_node.rs

1//! CROSSING-NODE SPLIT — cut a marched section at every passage through an
2//! isolated tangent node (2026-11-06, `BREP_SSI_NODE_SPLIT`, default on
3//! since the 2026-11-06 promotion, `=0` restores the march as it was).
4//!
5//! Where two carriers touch at one point with a SADDLE height difference
6//! (the eigenvalues of `H = II_a − II_b` of opposite sign, see
7//! `tangent_curve.rs`), the section is two smooth branches crossing there:
8//! Viviani's figure eight on a ball and the cylinder tangent inside it, or
9//! the two loops of a tee whose branch touches the main pipe's crown. The
10//! marcher does not stop at the node. Its step direction `n_a × n_b`
11//! vanishes only AT the node, and a predictor step carries the trace across
12//! it and on along the same smooth branch, so the whole figure eight comes
13//! back as ONE branch (brep-bench `tangent/viviani-common`: one branch of
14//! 6368 stations, 76.40 long, the curve's whole length). The node is then an
15//! interior station of that branch, twice. Each face needs the node as a
16//! VERTEX where four half-branches meet, or the arrangement cannot carve
17//! the regions that meet at the point. The run reached the node's vertex only where a
18//! trim edge happened to pass through it (the tee: one passage split on the
19//! main's seam, the other carried through), and the shell tore.
20//!
21//! This pass finds the nodes along each branch and splits the branch at
22//! every passage:
23//!
24//! * **Witness.** A station where `|n̂_a × n̂_b|` has a local minimum below
25//!   [`NODE_WITNESS_CROSS`]. Along a branch through a saddle node the
26//!   crossing falls linearly to zero, so the station nearest the node is
27//!   such a minimum. The gate is loose: the refinement and the saddle test
28//!   below decide, and the node must lie within the witness's own chord.
29//! * **Node.** The witness is refined onto `{S_a = S_b, n_a × n_b = 0}` and
30//!   classified (`classify_tangent_contact_at`). Only a SADDLE that does not
31//!   extend is a crossing node. An extended contact or a definite one is
32//!   left alone, as before.
33//! * **Passage.** A local minimum of the stations' distance to the node
34//!   whose adjacent chord passes within the ambiguity radius
35//!   `3 √(2 tol / κ)` of it (the band in which the carriers are within the
36//!   tolerance of each other across the contact, so the corrector's stations
37//!   there are noise; the same radius the imprint's node landing drops), or
38//!   within a quarter of the chord where the march stepped coarsely past.
39//! * **Cut.** The stations of the passage inside the ambiguity radius are
40//!   dropped (with none inside it, the nearest station stays on its own side
41//!   of the node), the exact node is inserted on both sides (its parameters
42//!   taken from the refinement, shifted by a period where the carrier is
43//!   closed so they continue the neighbouring station's), and the branch is
44//!   split there. A closed branch becomes as many open branches as it has
45//!   passages; an open one gains one piece per passage.
46//!
47//! Nothing else moves. A branch with no node, or a node with no passage
48//! close enough, is returned unchanged.
49
50use crate::surface_surface_intersection::SurfaceIntersectionCurve;
51use crate::tangent_curve::classify_tangent_contact_at;
52use crate::{NurbsSurface, Vec3};
53
54/// `|n̂_a × n̂_b|` (about 14.5°) below which a local minimum along a branch is
55/// refined as a possible node witness. Loose on purpose: along a branch
56/// through a saddle node the crossing falls linearly with the distance, so a
57/// march that stepped coarsely past the node reads it at its nearest station
58/// only as low as that station's distance allows. The refinement and the
59/// saddle test decide; a witness near no contact refines onto none.
60const NODE_WITNESS_CROSS: f64 = 0.25;
61
62/// Is the crossing-node split on (`BREP_SSI_NODE_SPLIT`, default on; `=0`
63/// off)?
64pub fn crossing_node_split_enabled() -> bool {
65    crate::switch!("BREP_SSI_NODE_SPLIT", true)
66}
67
68/// One crossing node: its point, its parameters on both carriers, and the
69/// ambiguity radius around it.
70#[derive(Clone, Copy, Debug)]
71struct Node {
72    point: Vec3,
73    params_a: [f64; 2],
74    params_b: [f64; 2],
75    radius: f64,
76}
77
78/// The periods of a carrier's closed directions (`0.0` where open).
79fn periods(surface: &NurbsSurface) -> Result<[f64; 2], String> {
80    let (closed_u, closed_v) = surface.closed_directions()?;
81    let [u0, u1] = surface.domain_u()?;
82    let [v0, v1] = surface.domain_v()?;
83    Ok([
84        if closed_u { u1 - u0 } else { 0.0 },
85        if closed_v { v1 - v0 } else { 0.0 },
86    ])
87}
88
89/// `params` moved by whole periods to lie nearest `near`.
90fn continue_params(params: [f64; 2], near: [f64; 2], periods: [f64; 2]) -> [f64; 2] {
91    let mut out = params;
92    for index in 0..2 {
93        let period = periods[index];
94        if period > 0.0 {
95            out[index] += period * ((near[index] - params[index]) / period).round();
96        }
97    }
98    out
99}
100
101fn crossing(first: &NurbsSurface, second: &NurbsSurface, a: [f64; 2], b: [f64; 2]) -> Option<f64> {
102    let normal_a = first.normal(a[0], a[1]).ok()?;
103    let normal_b = second.normal(b[0], b[1]).ok()?;
104    Some(normal_a.cross(normal_b).length())
105}
106
107/// The crossing nodes along `curves`, deduplicated.
108fn find_nodes(
109    first: &NurbsSurface,
110    second: &NurbsSurface,
111    curves: &[SurfaceIntersectionCurve],
112    tolerance: f64,
113) -> Result<Vec<Node>, String> {
114    let mut nodes: Vec<Node> = Vec::new();
115    for curve in curves {
116        let count = curve.points.len();
117        if count < 3 || curve.tangent || curve.params_a.len() != count || curve.params_b.len() != count {
118            continue;
119        }
120        let crossings: Vec<f64> = (0..count)
121            .map(|index| {
122                crossing(first, second, curve.params_a[index], curve.params_b[index]).unwrap_or(f64::INFINITY)
123            })
124            .collect();
125        for index in 0..count {
126            let value = crossings[index];
127            if !(value < NODE_WITNESS_CROSS) {
128                continue;
129            }
130            let before = if index > 0 { crossings[index - 1] } else if curve.closed { crossings[count - 2] } else { f64::INFINITY };
131            let after = if index + 1 < count { crossings[index + 1] } else if curve.closed { crossings[1] } else { f64::INFINITY };
132            if value > before || value > after {
133                continue;
134            }
135            let witness = curve.points[index];
136            if nodes.iter().any(|node| node.point.sub(witness).length() <= node.radius) {
137                continue;
138            }
139            let Some(contact) = classify_tangent_contact_at(first, second, witness, tolerance)? else {
140                continue;
141            };
142            if !contact.saddle || contact.is_extended() || contact.curvature_scale <= 0.0 {
143                continue;
144            }
145            // The refinement must land near the witness: a contact found
146            // elsewhere on the carriers is not this branch's node.
147            let before = if index > 0 { index - 1 } else if curve.closed { count - 2 } else { index };
148            let after = if index + 1 < count { index + 1 } else if curve.closed { 1 } else { index };
149            let chord = curve.points[before]
150                .sub(witness)
151                .length()
152                .max(curve.points[after].sub(witness).length());
153            let reach = (1e-2 * (1.0 + witness.length())).max(chord);
154            if contact.point.sub(witness).length() > reach {
155                continue;
156            }
157            if nodes.iter().any(|node| node.point.sub(contact.point).length() <= tolerance * 10.0) {
158                continue;
159            }
160            nodes.push(Node {
161                point: contact.point,
162                params_a: contact.params_a,
163                params_b: contact.params_b,
164                radius: 3.0 * (2.0 * tolerance / contact.curvature_scale).sqrt(),
165            });
166        }
167    }
168    Ok(nodes)
169}
170
171/// One cut of a branch at a passage through a node: the last station kept
172/// before it and the first kept after it (indices into the branch, or into
173/// the cycle for a closed one), and the node inserted between them.
174#[derive(Clone, Copy, Debug)]
175struct Cut {
176    left: usize,
177    right: usize,
178    node: usize,
179}
180
181fn point_segment_distance(point: Vec3, start: Vec3, end: Vec3) -> f64 {
182    point.distance_to_segment(start, end, crate::tolerance::FP_FLOOR)
183}
184
185/// The passages of `curve` (open, or closed WITHOUT its repeated last
186/// station: `points` is then the cycle) through each node.
187///
188/// A passage is the station nearest the node (a local minimum of the
189/// distance) whose adjacent chords pass within the node's ambiguity radius,
190/// or within a quarter of the longer chord where the march stepped coarsely
191/// past it (a chord's sag from the curve it samples). The stations inside
192/// the radius are dropped; when none is, the nearest station stays on the
193/// side of the node it lies on along the branch.
194fn passages(points: &[Vec3], cyclic: bool, nodes: &[Node]) -> Vec<Cut> {
195    let count = points.len() as isize;
196    let at = |index: isize| -> Vec3 { points[index.rem_euclid(count) as usize] };
197    // (left, right, node) in unwrapped indices, right > left.
198    let mut cuts: Vec<(isize, isize, usize)> = Vec::new();
199    for (node_index, node) in nodes.iter().enumerate() {
200        let distance = |index: isize| at(index).sub(node.point).length();
201        for index in 0..count {
202            if !cyclic && (index == 0 || index + 1 == count) {
203                // An open branch ending at the node needs no cut: it already
204                // ends there, and the imprint's landing finishes it.
205                continue;
206            }
207            let here = distance(index);
208            if here > distance(index - 1) || here >= distance(index + 1) {
209                continue;
210            }
211            let before = at(index - 1);
212            let after = at(index + 1);
213            let near_chord = point_segment_distance(node.point, before, at(index))
214                .min(point_segment_distance(node.point, at(index), after));
215            let longest = before.sub(at(index)).length().max(after.sub(at(index)).length());
216            if near_chord > node.radius.max(0.25 * longest) {
217                continue;
218            }
219            let (left, right) = if here <= node.radius {
220                // Drop every station inside the radius, but never past the
221                // branch's ends or round the whole cycle.
222                let mut low = index;
223                let mut high = index;
224                let limit = count / 2 - 1;
225                while index - low < limit && distance(low - 1) <= node.radius && (cyclic || low > 1) {
226                    low -= 1;
227                }
228                while high - index < limit && distance(high + 1) <= node.radius && (cyclic || high + 2 < count) {
229                    high += 1;
230                }
231                (low - 1, high + 1)
232            } else if at(index).sub(node.point).dot(after.sub(before)) < 0.0 {
233                (index, index + 1)
234            } else {
235                (index - 1, index)
236            };
237            if !cyclic && (left < 0 || right >= count) {
238                continue;
239            }
240            cuts.push((left, right, node_index));
241        }
242    }
243    cuts.sort_by_key(|cut| cut.0);
244    // Two passages whose windows overlap are one cut.
245    let mut merged: Vec<(isize, isize, usize)> = Vec::new();
246    for cut in cuts {
247        if let Some(last) = merged.last() {
248            if cut.0 < last.1 {
249                continue;
250            }
251        }
252        merged.push(cut);
253    }
254    if cyclic && merged.len() > 1 {
255        let first = merged[0];
256        let last = merged[merged.len() - 1];
257        if last.1 > first.0 + count {
258            merged.pop();
259        }
260    }
261    merged
262        .into_iter()
263        .map(|(left, right, node)| Cut {
264            left: left.rem_euclid(count) as usize,
265            right: right.rem_euclid(count) as usize,
266            node,
267        })
268        .collect()
269}
270
271/// A piece of a branch from one cut (or the branch start) to the next (or
272/// its end), with the nodes put on its ends.
273#[allow(clippy::too_many_arguments)]
274fn piece(
275    curve: &SurfaceIntersectionCurve,
276    indices: &[usize],
277    start_node: Option<&Node>,
278    end_node: Option<&Node>,
279    periods_a: [f64; 2],
280    periods_b: [f64; 2],
281) -> Option<SurfaceIntersectionCurve> {
282    if indices.is_empty() {
283        return None;
284    }
285    let mut points: Vec<Vec3> = indices.iter().map(|&index| curve.points[index]).collect();
286    let mut params_a: Vec<[f64; 2]> = indices.iter().map(|&index| curve.params_a[index]).collect();
287    let mut params_b: Vec<[f64; 2]> = indices.iter().map(|&index| curve.params_b[index]).collect();
288    let mut tangents: Option<Vec<Vec3>> =
289        curve.tangents.as_ref().map(|tangents| indices.iter().map(|&index| tangents[index]).collect());
290    if let Some(node) = start_node {
291        let a = continue_params(node.params_a, params_a[0], periods_a);
292        let b = continue_params(node.params_b, params_b[0], periods_b);
293        let chord = points[0].sub(node.point).normalized().ok();
294        points.insert(0, node.point);
295        params_a.insert(0, a);
296        params_b.insert(0, b);
297        if let Some(tangents) = tangents.as_mut() {
298            let first = tangents[0];
299            tangents.insert(0, chord.unwrap_or(first));
300        }
301    }
302    if let Some(node) = end_node {
303        let last = points.len() - 1;
304        let a = continue_params(node.params_a, params_a[last], periods_a);
305        let b = continue_params(node.params_b, params_b[last], periods_b);
306        let chord = node.point.sub(points[last]).normalized().ok();
307        points.push(node.point);
308        params_a.push(a);
309        params_b.push(b);
310        if let Some(tangents) = tangents.as_mut() {
311            let previous = tangents[last];
312            tangents.push(chord.unwrap_or(previous));
313        }
314    }
315    if points.len() < 2 {
316        return None;
317    }
318    Some(SurfaceIntersectionCurve {
319        points,
320        params_a,
321        params_b,
322        closed: false,
323        tangents,
324        span_deviation: curve.span_deviation,
325        tangent: false,
326    })
327}
328
329/// Split `curves` at every passage through a crossing node of the two
330/// carriers. Returns the curves (unchanged when no node is found) and the
331/// points of the nodes a branch was cut at.
332pub fn split_at_crossing_nodes(
333    first: &NurbsSurface,
334    second: &NurbsSurface,
335    curves: Vec<SurfaceIntersectionCurve>,
336    tolerance: f64,
337) -> Result<(Vec<SurfaceIntersectionCurve>, Vec<Vec3>), String> {
338    let nodes = find_nodes(first, second, &curves, tolerance)?;
339    if nodes.is_empty() {
340        return Ok((curves, Vec::new()));
341    }
342    let periods_a = periods(first)?;
343    let periods_b = periods(second)?;
344    let debug = crate::debug_flag!("BREP_DEBUG_NODE_SPLIT");
345    // Only a node some branch was cut at is reported: the caller records it
346    // as a tangent node, which changes how the imprint lands run ends near
347    // it, so a contact no branch passes through must not reach it.
348    let mut used = vec![false; nodes.len()];
349    let mut out = Vec::with_capacity(curves.len() + 2 * nodes.len());
350    for curve in curves {
351        let full = curve.points.len();
352        if full < 3 || curve.tangent || curve.params_a.len() != full || curve.params_b.len() != full {
353            out.push(curve);
354            continue;
355        }
356        // A closed branch repeats its start as its last station.
357        let cyclic = curve.closed && curve.points[0].sub(curve.points[full - 1]).length() <= tolerance * 10.0;
358        let count = if cyclic { full - 1 } else { full };
359        let cuts = passages(&curve.points[..count], cyclic, &nodes);
360        if debug {
361            eprintln!(
362                "node split: branch of {full} stations (closed {cyclic}), nodes {:?}, cuts {:?}",
363                nodes.iter().map(|node| (node.point, node.radius)).collect::<Vec<_>>(),
364                cuts
365            );
366        }
367        if cuts.is_empty() {
368            out.push(curve);
369            continue;
370        }
371        for cut in &cuts {
372            used[cut.node] = true;
373        }
374        let mut pieces = Vec::new();
375        if cyclic {
376            for (index, cut) in cuts.iter().enumerate() {
377                let next = cuts[(index + 1) % cuts.len()];
378                let mut indices = Vec::new();
379                let mut station = cut.right;
380                let stop = (next.left + 1) % count;
381                loop {
382                    indices.push(station);
383                    station = (station + 1) % count;
384                    if station == stop || indices.len() >= count {
385                        break;
386                    }
387                }
388                pieces.push(piece(&curve, &indices, Some(&nodes[cut.node]), Some(&nodes[next.node]), periods_a, periods_b));
389            }
390        } else {
391            let mut start = 0usize;
392            let mut start_node: Option<&Node> = None;
393            for cut in &cuts {
394                let indices: Vec<usize> = (start..=cut.left).collect();
395                pieces.push(piece(&curve, &indices, start_node, Some(&nodes[cut.node]), periods_a, periods_b));
396                start = cut.right;
397                start_node = Some(&nodes[cut.node]);
398            }
399            let indices: Vec<usize> = (start..count).collect();
400            pieces.push(piece(&curve, &indices, start_node, None, periods_a, periods_b));
401        }
402        let pieces: Vec<SurfaceIntersectionCurve> = pieces.into_iter().flatten().collect();
403        if debug {
404            eprintln!(
405                "node split: -> {} piece(s) of {:?} stations",
406                pieces.len(),
407                pieces.iter().map(|piece| piece.points.len()).collect::<Vec<_>>()
408            );
409        }
410        out.extend(pieces);
411    }
412    Ok((
413        out,
414        nodes
415            .iter()
416            .zip(&used)
417            .filter(|(_, used)| **used)
418            .map(|(node, _)| node.point)
419            .collect(),
420    ))
421}
422