Skip to main content

brep_kernel/healing/
coalesce.rs

1use crate::mass_properties::parameter_space_area;
2use crate::topology::{adaptive_coedge_error, BrepSolid, CoedgeRecord, EdgeRecord};
3use crate::{
4    build_pcurve_on_surface, interpolate_curve, KernelTolerances, NurbsCurve, NurbsSurface, Vec3,
5};
6use rustc_hash::FxHashMap as HashMap;
7
8fn trimmed_curve(curve: &NurbsCurve, t0: f64, t1: f64) -> Result<NurbsCurve, String> {
9    let [start, end] = curve.domain()?;
10    // NurbsCurve::split refuses parameters within its ABSOLUTE knot
11    // tolerance (1e-9) of the domain ends; the skip epsilon must cover that
12    // or a near-edge trim parameter slips past the guard and errors.
13    let epsilon = (1e-9 * (end - start)).max(2e-9);
14    let mut result = curve.clone();
15    if t0 > start + epsilon && t0 < end - epsilon {
16        result = result.split(t0)?.1;
17    }
18    let domain = result.domain()?;
19    if t1 < domain[1] - epsilon && t1 > domain[0] + epsilon {
20        result = result.split(t1)?.0;
21    } else if std::env::var("BREP_DEBUG_SUBRANGE").is_ok() && t1 < domain[1] - epsilon {
22        eprintln!("abnormal subrange skip in coalesce trimmed_curve");
23    }
24    Ok(result)
25}
26
27fn curvature_at(curve: &NurbsCurve, parameter: f64) -> Result<f64, String> {
28    let derivatives = curve.derivatives(parameter, 2)?;
29    let speed = derivatives[1].length();
30    if speed <= 1e-12 {
31        return Ok(0.0);
32    }
33    Ok(derivatives[1].cross(derivatives[2]).length() / speed.powi(3))
34}
35
36/// Preserve the topology contract after a sampled continuation fit.
37///
38/// Interpolation is mathematically endpoint-interpolating, but a high-degree
39/// solve can lose several digits on long or unevenly parameterized pieces.
40/// Clamped NURBS evaluate to their first and last homogeneous control points,
41/// so restoring those two controls is exact and does not perturb the interior
42/// controls produced by the fit.
43fn constrain_curve_endpoints(
44    mut curve: NurbsCurve,
45    start: Vec3,
46    end: Vec3,
47) -> Result<NurbsCurve, String> {
48    let [domain_start, domain_end] = curve.domain()?;
49    let first_weight = curve
50        .control_points
51        .first()
52        .ok_or("cannot constrain an empty curve")?
53        .w;
54    let last_weight = curve
55        .control_points
56        .last()
57        .ok_or("cannot constrain an empty curve")?
58        .w;
59    if first_weight.abs() <= 1e-15 || last_weight.abs() <= 1e-15 {
60        return Err("cannot constrain a curve endpoint with zero weight".into());
61    }
62    let last = curve.control_points.len() - 1;
63    curve.control_points[0].x = start.x * first_weight;
64    curve.control_points[0].y = start.y * first_weight;
65    curve.control_points[0].z = start.z * first_weight;
66    curve.control_points[last].x = end.x * last_weight;
67    curve.control_points[last].y = end.y * last_weight;
68    curve.control_points[last].z = end.z * last_weight;
69    let endpoint_epsilon = 1e-10;
70    if curve.evaluate(domain_start)?.sub(start).length() > endpoint_epsilon
71        || curve.evaluate(domain_end)?.sub(end).length() > endpoint_epsilon
72    {
73        return Err("continuation curve is not clamped at its topology endpoints".into());
74    }
75    Ok(curve)
76}
77
78/// Join separately fitted pieces of the same smooth carrier curve. Exact
79/// homogeneous concatenation is preferred, but independently fitted SSI
80/// branches can represent the same circle with slightly different weights.
81fn concatenate_sampled_pieces(
82    first: &NurbsCurve,
83    second: &NurbsCurve,
84    split: f64,
85    tolerance: f64,
86) -> Result<Option<NurbsCurve>, String> {
87    let first_domain = first.domain()?;
88    let second_domain = second.domain()?;
89    let samples_per_piece = 48;
90    let mut points = Vec::<Vec3>::with_capacity(samples_per_piece * 2 + 1);
91    let mut parameters = Vec::with_capacity(samples_per_piece * 2 + 1);
92    for index in 0..=samples_per_piece {
93        let fraction = index as f64 / samples_per_piece as f64;
94        points.push(
95            first.evaluate(first_domain[0] + (first_domain[1] - first_domain[0]) * fraction)?,
96        );
97        parameters.push(split * fraction);
98    }
99    for index in 1..=samples_per_piece {
100        let fraction = index as f64 / samples_per_piece as f64;
101        points.push(
102            second.evaluate(second_domain[0] + (second_domain[1] - second_domain[0]) * fraction)?,
103        );
104        parameters.push(split + (1.0 - split) * fraction);
105    }
106    let joined = interpolate_curve(&points, first.degree.min(second.degree), &parameters)?;
107    for index in 0..=64 {
108        let fraction = index as f64 / 64.0;
109        let expected = if fraction <= split {
110            let local = if split <= 1e-12 {
111                0.0
112            } else {
113                fraction / split
114            };
115            first.evaluate(first_domain[0] + (first_domain[1] - first_domain[0]) * local)?
116        } else {
117            let local = (fraction - split) / (1.0 - split);
118            second.evaluate(second_domain[0] + (second_domain[1] - second_domain[0]) * local)?
119        };
120        if joined.evaluate(fraction)?.sub(expected).length() > tolerance {
121            return Ok(None);
122        }
123    }
124    Ok(Some(joined))
125}
126
127fn concatenate_compatible_fitted_pieces(
128    first: &NurbsCurve,
129    second: &NurbsCurve,
130    split: f64,
131    tolerance: f64,
132) -> Result<Option<NurbsCurve>, String> {
133    let first_domain = first.domain()?;
134    let second_domain = second.domain()?;
135    let first_curvature = curvature_at(first, first_domain[1])?;
136    let second_curvature = curvature_at(second, second_domain[0])?;
137    let curvature_scale = first_curvature.abs().max(second_curvature.abs()).max(1.0);
138    // Independently fitted SSI pieces can differ by a few tenths of a
139    // percent in endpoint curvature even when their tangent and sampled
140    // loci form one carrier curve. The sampled concatenation below remains
141    // the authoritative geometric guard.
142    if (first_curvature - second_curvature).abs() > curvature_scale * 2e-3 {
143        return Ok(None);
144    }
145    concatenate_sampled_pieces(first, second, split, tolerance)
146}
147
148/// Join two exact NURBS pieces with a degree-multiplicity internal knot.
149pub fn concatenate_exact_curve_pieces(
150    first: &NurbsCurve,
151    second: &NurbsCurve,
152    split: f64,
153    tolerance: f64,
154) -> Result<Option<NurbsCurve>, String> {
155    if first.degree != second.degree || split <= 1e-9 || split >= 1.0 - 1e-9 {
156        return Ok(None);
157    }
158    let degree = first.degree;
159    let [first_start, first_end] = first.domain()?;
160    let [second_start, second_end] = second.domain()?;
161    let first_span = first_end - first_start;
162    let second_span = second_end - second_start;
163    if first_span <= 1e-9 || second_span <= 1e-9 {
164        return Ok(None);
165    }
166    let first_end_control = first.control_points[first.control_points.len() - 1];
167    let second_start_control = second.control_points[0];
168    let second_scale = first_end_control.w / second_start_control.w;
169    let scaled_second = second
170        .control_points
171        .iter()
172        .map(|control| control.scale(second_scale))
173        .collect::<Vec<_>>();
174    let scaled_start = scaled_second[0];
175    let scale = 1.0f64
176        .max(first_end_control.x.abs())
177        .max(first_end_control.y.abs())
178        .max(first_end_control.z.abs())
179        .max(first_end_control.w.abs());
180    let homogeneous_tolerance = (1e-10f64).max(tolerance) * scale;
181    if (first_end_control.x - scaled_start.x).abs() > homogeneous_tolerance
182        || (first_end_control.y - scaled_start.y).abs() > homogeneous_tolerance
183        || (first_end_control.z - scaled_start.z).abs() > homogeneous_tolerance
184        || (first_end_control.w - scaled_start.w).abs() > homogeneous_tolerance
185    {
186        return Ok(None);
187    }
188    let remap = |curve: &NurbsCurve, from_start, from_end, to_start, to_end| {
189        curve
190            .knots
191            .iter()
192            .map(|knot| {
193                to_start + (to_end - to_start) * (knot - from_start) / (from_end - from_start)
194            })
195            .collect::<Vec<_>>()
196    };
197    let first_knots = remap(first, first_start, first_end, 0.0, split);
198    let second_knots = remap(second, second_start, second_end, split, 1.0);
199    let mut knots = first_knots[..first_knots.len() - degree - 1].to_vec();
200    knots.extend(std::iter::repeat_n(split, degree));
201    knots.extend_from_slice(&second_knots[degree + 1..]);
202    let mut controls = first.control_points.clone();
203    controls.extend_from_slice(&scaled_second[1..]);
204    let joined = NurbsCurve::new(degree, knots, controls)?;
205    for fraction in [0.0, 0.23, 0.61, 1.0] {
206        if joined
207            .evaluate(split * fraction)?
208            .sub(first.evaluate(first_start + first_span * fraction)?)
209            .length()
210            > tolerance
211            || joined
212                .evaluate(split + (1.0 - split) * fraction)?
213                .sub(second.evaluate(second_start + second_span * fraction)?)
214                .length()
215                > tolerance
216        {
217            return Ok(None);
218        }
219    }
220    Ok(Some(joined))
221}
222
223fn pcurve_matches_edge(
224    surface: &NurbsSurface,
225    pcurve: &NurbsCurve,
226    curve: &NurbsCurve,
227    reversed: bool,
228    tolerance: f64,
229) -> Result<bool, String> {
230    let [p0, p1] = pcurve.domain()?;
231    let [c0, c1] = curve.domain()?;
232    // Topology validation rejects pcurve-vs-edge deviations above 4e-3.
233    // Guard merges below that threshold with a 25% margin so a concatenation
234    // with drifted parameterization is rebuilt or skipped before validation.
235    let geometry_tolerance = (tolerance * 1.5).max(1e-4).min(3e-3);
236    for index in 0..=32 {
237        let fraction = index as f64 / 32.0;
238        let uv = pcurve.evaluate(p0 + (p1 - p0) * fraction)?;
239        let curve_fraction = if reversed { 1.0 - fraction } else { fraction };
240        if surface
241            .evaluate(uv.x, uv.y)?
242            .sub(curve.evaluate(c0 + (c1 - c0) * curve_fraction)?)
243            .length()
244            > geometry_tolerance
245        {
246            return Ok(false);
247        }
248    }
249    Ok(true)
250}
251
252#[derive(Clone, Copy)]
253struct UseLocation {
254    face: usize,
255    loop_index: usize,
256    coedge: usize,
257}
258
259fn replace_adjacent(
260    coedges: &mut Vec<CoedgeRecord>,
261    first_index: usize,
262    replacement: CoedgeRecord,
263) -> bool {
264    if coedges.len() < 2 {
265        return false;
266    }
267    if first_index + 1 < coedges.len() {
268        coedges.splice(first_index..first_index + 2, [replacement]);
269    } else {
270        coedges.pop();
271        coedges[0] = replacement;
272    }
273    true
274}
275
276fn traversal_vertex_ids(edge: &EdgeRecord, coedge: &CoedgeRecord) -> (u64, u64) {
277    if coedge.forward {
278        (edge.start_vertex_id, edge.end_vertex_id)
279    } else {
280        (edge.end_vertex_id, edge.start_vertex_id)
281    }
282}
283
284/// The largest turn, in radians, two edges may make at their shared vertex and
285/// still count as one carrier continued.
286///
287/// This is the ONLY guard against merging across a genuine corner, because
288/// [`concatenate_exact_curve_pieces`] joins with a knot of degree multiplicity
289/// and therefore reproduces both pieces EXACTLY — a box's two top edges would
290/// concatenate into one C0 polyline and pass every geometric check below. So
291/// the band has to stay small.
292///
293/// It cannot be arbitrarily small either. Edges on one SSI-fitted intersection
294/// curve are fitted PIECE BY PIECE, and a fit is accurate in position, not in
295/// endpoint derivative: two degree-3 pieces of the same sphere/cylinder circle
296/// (the tube joint in report 20260909T063157Z) sit within 4.6e-6 of that circle
297/// and still disagree by 2.8e-4 rad at their shared vertex — the fitted
298/// endpoint derivative is two orders of magnitude looser than the fitted point.
299/// A band of 1e-3 rad clears that measured noise by 3.5x and still refuses any
300/// corner a modeller can express — 0.057 degrees.
301const CONTINUATION_TURN: f64 = 1e-3;
302
303/// The band a merge that CLOSES its edge into a loop must meet instead: the
304/// historical `1 - dot < 1e-10`, restated as the angle it always was.
305///
306/// Measured, on the two shapes that made the distinction necessary. The
307/// crossing-pipe tee (`push_cylinder_side_against_a_crossing_pipe_matches_the_
308/// rebuilt_tee`) has a closed form —
309/// `V = π·9·10 + π·2.25·12 − 4·∫₋ᵦᵇ √((a²−y²)(b²−y²)) dy = 326.525260789` — and
310/// its marched rim, whose two SSI halves turn 8.8e-4 at their join, sits 2.8e-7
311/// from it as two arcs and 1.7e-6 as one closed edge: six times the error, for
312/// a vertex nobody asked to lose. The torus collar
313/// (`collar_chain_on_a_torus_carrier_crosses_the_v_seam`) is the same shape at
314/// 6.9e-4 and 2.2e-4. Against that, the plane × sphere rim of
315/// `pushing_an_obliquely_capped_ball_refuses_as_an_oblique_rim` is two pieces
316/// of ONE analytic circle: it turns nothing measurable, closes as it always
317/// has, and is the reason this is a second band rather than a flat refusal.
318///
319/// Neither parameter area (5.3e-7 for the good closure against 1.1e-7 for a bad
320/// one) nor the p-curve gap at the join (zero for all of them) separates the
321/// cases. Exactness of the tangency does.
322const EXACT_CLOSING_TURN: f64 = 1.4142135623730951e-5;
323
324/// Collapse tangent continuation edges seen by the same two incident faces —
325/// or by the same face TWICE, which is what a split seam looks like.
326///
327/// Both 3D curves and both face p-curves are concatenated exactly. Periodic
328/// branch changes are rejected if they alter either face's parameter area.
329pub fn merge_curve_continuation_edges(
330    solid: &BrepSolid,
331    tolerance: f64,
332) -> Result<BrepSolid, String> {
333    let mut result = solid.clone();
334    let mut rejected = rustc_hash::FxHashSet::<(u64, u64)>::default();
335    loop {
336        let edges = result
337            .edges
338            .iter()
339            .map(|edge| (edge.id, edge))
340            .collect::<HashMap<_, _>>();
341        let vertices = result
342            .vertices
343            .iter()
344            .map(|vertex| (vertex.id, vertex.point))
345            .collect::<HashMap<_, _>>();
346        let faces = result
347            .shells
348            .iter()
349            .flat_map(|shell| shell.faces.iter())
350            .collect::<Vec<_>>();
351        let mut uses: HashMap<u64, Vec<UseLocation>> = HashMap::default();
352        for (face_index, face) in faces.iter().enumerate() {
353            for (loop_index, loop_record) in face.loops.iter().enumerate() {
354                for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
355                    uses.entry(coedge.edge_id).or_default().push(UseLocation {
356                        face: face_index,
357                        loop_index,
358                        coedge: coedge_index,
359                    });
360                }
361            }
362        }
363        let mut accepted = None;
364        'faces: for (face_index, face) in faces.iter().enumerate() {
365            for (loop_index, loop_record) in face.loops.iter().enumerate() {
366                if loop_record.coedges.len() < 2 {
367                    continue;
368                }
369                for first_index in 0..loop_record.coedges.len() {
370                    let second_index = (first_index + 1) % loop_record.coedges.len();
371                    let first = &loop_record.coedges[first_index];
372                    let second = &loop_record.coedges[second_index];
373                    let first_edge = edges[&first.edge_id];
374                    let second_edge = edges[&second.edge_id];
375                    let pair_key = if first.edge_id < second.edge_id {
376                        (first.edge_id, second.edge_id)
377                    } else {
378                        (second.edge_id, first.edge_id)
379                    };
380                    if rejected.contains(&pair_key) {
381                        continue;
382                    }
383                    if first_edge.degenerate || second_edge.degenerate {
384                        continue;
385                    }
386                    let (_, first_end) = traversal_vertex_ids(first_edge, first);
387                    let (second_start, _) = traversal_vertex_ids(second_edge, second);
388                    if first_end != second_start {
389                        continue;
390                    }
391                    let Some(first_uses) = uses.get(&first.edge_id) else {
392                        continue;
393                    };
394                    let Some(second_uses) = uses.get(&second.edge_id) else {
395                        continue;
396                    };
397                    if first_uses.len() != 2 || second_uses.len() != 2 {
398                        continue;
399                    }
400                    let first_mate = first_uses
401                        .iter()
402                        .find(|location| {
403                            location.face != face_index
404                                || location.loop_index != loop_index
405                                || location.coedge != first_index
406                        })
407                        .copied()
408                        .unwrap();
409                    let second_mate = second_uses
410                        .iter()
411                        .find(|location| {
412                            location.face != face_index
413                                || location.loop_index != loop_index
414                                || location.coedge != second_index
415                        })
416                        .copied()
417                        .unwrap();
418                    // A SEAM exposes both uses of both edges on one loop of one
419                    // face — the two sides of the parametric rectangle. That is
420                    // the shape a split sphere seam wears, so it is a case to
421                    // answer rather than to decline; the two indexed splices
422                    // are applied high index first below so neither shifts the
423                    // other. What it must NOT be is the same PAIR twice: the
424                    // two runs have to be disjoint for both splices to land.
425                    let same_loop =
426                        first_mate.face == face_index && first_mate.loop_index == loop_index;
427                    if same_loop
428                        && (first_mate.coedge == first_index
429                            || first_mate.coedge == second_index
430                            || second_mate.coedge == first_index
431                            || second_mate.coedge == second_index)
432                    {
433                        continue;
434                    }
435                    if first_mate.face != second_mate.face
436                        || first_mate.loop_index != second_mate.loop_index
437                        || (second_mate.coedge + 1)
438                            % faces[second_mate.face].loops[second_mate.loop_index]
439                                .coedges
440                                .len()
441                            != first_mate.coedge
442                    {
443                        continue;
444                    }
445                    let first_parameter = if first.forward {
446                        first_edge.t1
447                    } else {
448                        first_edge.t0
449                    };
450                    let second_parameter = if second.forward {
451                        second_edge.t0
452                    } else {
453                        second_edge.t1
454                    };
455                    let mut first_tangent = first_edge.curve.derivatives(first_parameter, 1)?[1];
456                    let mut second_tangent = second_edge.curve.derivatives(second_parameter, 1)?[1];
457                    if !first.forward {
458                        first_tangent = first_tangent.scale(-1.0);
459                    }
460                    if !second.forward {
461                        second_tangent = second_tangent.scale(-1.0);
462                    }
463                    let first_tangent = first_tangent.normalized()?;
464                    let second_tangent = second_tangent.normalized()?;
465                    // Measure the turn as an ANGLE, not as `1 - dot`: the dot
466                    // of two near-parallel unit vectors loses most of its
467                    // digits to cancellation exactly where this test lives.
468                    let turn = first_tangent
469                        .cross(second_tangent)
470                        .length()
471                        .atan2(first_tangent.dot(second_tangent));
472                    // Closing a LOOP asks more of the evidence than continuing
473                    // an open edge. Merging the last two pieces of a closed
474                    // loop removes the loop's only vertex and leaves one closed
475                    // edge whose p-curve has to span the carrier's whole period
476                    // on at least one face — which no exact concatenation can
477                    // produce across the seam, so the p-curve is re-projected,
478                    // and a re-projected full period is where the fit is worst.
479                    // So a closure keeps the historical EXACT band: two pieces
480                    // of one analytic circle (a plane cutting a sphere) still
481                    // close, and two SSI fits of a marched quartic do not.
482                    let closes = traversal_vertex_ids(first_edge, first).0
483                        == traversal_vertex_ids(second_edge, second).1;
484                    let band = if closes {
485                        EXACT_CLOSING_TURN
486                    } else {
487                        CONTINUATION_TURN
488                    };
489                    if turn.abs() > band {
490                        continue;
491                    }
492                    let mut first_curve =
493                        trimmed_curve(&first_edge.curve, first_edge.t0, first_edge.t1)?;
494                    let mut second_curve =
495                        trimmed_curve(&second_edge.curve, second_edge.t0, second_edge.t1)?;
496                    if !first.forward {
497                        first_curve = first_curve.reversed()?;
498                    }
499                    if !second.forward {
500                        second_curve = second_curve.reversed()?;
501                    }
502                    let first_span = {
503                        let domain = first_curve.domain()?;
504                        domain[1] - domain[0]
505                    };
506                    let second_span = {
507                        let domain = second_curve.domain()?;
508                        domain[1] - domain[0]
509                    };
510                    let split = first_span / (first_span + second_span);
511                    let curve = if let Some(curve) = concatenate_exact_curve_pieces(
512                        &first_curve,
513                        &second_curve,
514                        split,
515                        tolerance,
516                    )? {
517                        curve
518                    } else if let Some(curve) = concatenate_compatible_fitted_pieces(
519                        &first_curve,
520                        &second_curve,
521                        split,
522                        tolerance,
523                    )? {
524                        curve
525                    } else {
526                        continue;
527                    };
528                    let (start_vertex_id, _) = traversal_vertex_ids(first_edge, first);
529                    let (_, end_vertex_id) = traversal_vertex_ids(second_edge, second);
530                    let Some(start_point) = vertices.get(&start_vertex_id).copied() else {
531                        continue;
532                    };
533                    let Some(end_point) = vertices.get(&end_vertex_id).copied() else {
534                        continue;
535                    };
536                    let curve = constrain_curve_endpoints(curve, start_point, end_point)?;
537                    let (mut face_pcurve, face_pcurve_rebuilt) = if let Some(pcurve) =
538                        concatenate_exact_curve_pieces(
539                            &first.pcurve,
540                            &second.pcurve,
541                            split,
542                            tolerance,
543                        )? {
544                        (pcurve, false)
545                    } else {
546                        (build_pcurve_on_surface(&face.surface, &curve)?, true)
547                    };
548                    let mate_loop = &faces[first_mate.face].loops[first_mate.loop_index];
549                    let second_mate_coedge = &mate_loop.coedges[second_mate.coedge];
550                    let first_mate_coedge = &mate_loop.coedges[first_mate.coedge];
551                    let (mut mate_pcurve, mate_pcurve_rebuilt) = if let Some(pcurve) =
552                        concatenate_exact_curve_pieces(
553                            &second_mate_coedge.pcurve,
554                            &first_mate_coedge.pcurve,
555                            1.0 - split,
556                            tolerance,
557                        )? {
558                        (pcurve, false)
559                    } else {
560                        (
561                            build_pcurve_on_surface(
562                                &faces[first_mate.face].surface,
563                                &curve.reversed()?,
564                            )?,
565                            true,
566                        )
567                    };
568                    let mut pcurve_rebuilt = face_pcurve_rebuilt || mate_pcurve_rebuilt;
569                    if !pcurve_matches_edge(&face.surface, &face_pcurve, &curve, false, tolerance)?
570                    {
571                        face_pcurve = concatenate_sampled_pieces(
572                            &first.pcurve,
573                            &second.pcurve,
574                            split,
575                            tolerance * 10.0,
576                        )?
577                        .unwrap_or(build_pcurve_on_surface(&face.surface, &curve)?);
578                        pcurve_rebuilt = true;
579                    }
580                    if !pcurve_matches_edge(
581                        &faces[first_mate.face].surface,
582                        &mate_pcurve,
583                        &curve,
584                        true,
585                        tolerance,
586                    )? {
587                        mate_pcurve = concatenate_sampled_pieces(
588                            &second_mate_coedge.pcurve,
589                            &first_mate_coedge.pcurve,
590                            1.0 - split,
591                            tolerance * 10.0,
592                        )?
593                        .unwrap_or(build_pcurve_on_surface(
594                            &faces[first_mate.face].surface,
595                            &curve.reversed()?,
596                        )?);
597                        pcurve_rebuilt = true;
598                    }
599                    if !pcurve_matches_edge(&face.surface, &face_pcurve, &curve, false, tolerance)?
600                        || !pcurve_matches_edge(
601                            &faces[first_mate.face].surface,
602                            &mate_pcurve,
603                            &curve,
604                            true,
605                            tolerance,
606                        )?
607                    {
608                        // Mixed parameterizations (one pcurve concatenated
609                        // exactly with a speed kink, the other rebuilt
610                        // uniformly) cannot both track the curve linearly.
611                        // Re-derive BOTH pcurves from the merged curve so all
612                        // three share one parameterization by construction.
613                        face_pcurve = build_pcurve_on_surface(&face.surface, &curve)?;
614                        mate_pcurve = build_pcurve_on_surface(
615                            &faces[first_mate.face].surface,
616                            &curve.reversed()?,
617                        )?;
618                        pcurve_rebuilt = true;
619                        // Both p-curves now come from the merged curve, so
620                        // there is no third construction left to fall back to
621                        // and nothing for the coarse screen to CHOOSE between.
622                        // Its 1e-4 floor is tighter than the kernel's own
623                        // pcurve contract by a factor of 40, and a rebuilt
624                        // p-curve can sit a few 1e-4 off the equal-fraction
625                        // station on a curved carrier while tracking the same
626                        // locus (the tube joint's cylinder/cylinder edge in
627                        // report 20260909T063157Z measures 3.5e-4 against a
628                        // 4e-3 contract). Let the adaptive contract below —
629                        // the same one final validation applies — arbitrate
630                        // instead of vetoing here on the cheap screen.
631                    }
632                    // The local 32-point guard above is a cheap branch check.
633                    // Before committing topology, enforce the same adaptive
634                    // contract as final validation so a narrow fitted-curve
635                    // error cannot be introduced by coalescing.
636                    let [candidate_t0, candidate_t1] = curve.domain()?;
637                    let candidate_edge = EdgeRecord {
638                        id: first.edge_id,
639                        curve: curve.clone(),
640                        t0: candidate_t0,
641                        t1: candidate_t1,
642                        start_vertex_id,
643                        end_vertex_id,
644                        degenerate: false,
645                        // A concatenated continuation keeps the first
646                        // constituent's persistent name.
647                        name: first_edge.name.clone(),
648                    };
649                    let pcurve_contract =
650                        KernelTolerances::for_solid(&result, 1e-7).pcurve_consistency;
651                    if adaptive_coedge_error(
652                        &face.surface,
653                        &face_pcurve,
654                        &curve,
655                        &candidate_edge,
656                        true,
657                        pcurve_contract,
658                    )? > pcurve_contract
659                        || adaptive_coedge_error(
660                            &faces[first_mate.face].surface,
661                            &mate_pcurve,
662                            &curve,
663                            &candidate_edge,
664                            false,
665                            pcurve_contract,
666                        )? > pcurve_contract
667                    {
668                        rejected.insert(pair_key);
669                        continue;
670                    }
671                    accepted = Some((
672                        face_index,
673                        loop_index,
674                        first_index,
675                        first_mate,
676                        second_mate,
677                        first.edge_id,
678                        second.edge_id,
679                        curve,
680                        face_pcurve,
681                        mate_pcurve,
682                        pcurve_rebuilt,
683                        start_vertex_id,
684                        end_vertex_id,
685                    ));
686                    break 'faces;
687                }
688            }
689        }
690        let Some((
691            face_index,
692            loop_index,
693            first_index,
694            first_mate,
695            second_mate,
696            first_edge_id,
697            second_edge_id,
698            curve,
699            face_pcurve,
700            mate_pcurve,
701            pcurve_rebuilt,
702            start_vertex_id,
703            end_vertex_id,
704        )) = accepted
705        else {
706            break;
707        };
708        let before_face = parameter_space_area(faces[face_index])?;
709        let before_mate = parameter_space_area(faces[first_mate.face])?;
710        drop(faces);
711        drop(edges);
712        let mut candidate = result.clone();
713        let new_edge_id = candidate
714            .edges
715            .iter()
716            .map(|edge| edge.id)
717            .max()
718            .unwrap_or(0)
719            + 1;
720        let domain = curve.domain()?;
721        let merged_name = candidate
722            .edges
723            .iter()
724            .find(|edge| edge.id == first_edge_id)
725            .and_then(|edge| edge.name.clone());
726        candidate.edges.push(EdgeRecord {
727            id: new_edge_id,
728            curve,
729            t0: domain[0],
730            t1: domain[1],
731            start_vertex_id,
732            end_vertex_id,
733            degenerate: false,
734            name: merged_name,
735        });
736        let mut next_coedge_id = candidate
737            .shells
738            .iter()
739            .flat_map(|shell| &shell.faces)
740            .flat_map(|face| &face.loops)
741            .flat_map(|loop_record| &loop_record.coedges)
742            .map(|coedge| coedge.id)
743            .max()
744            .unwrap_or(0)
745            + 1;
746        let face_replacement = CoedgeRecord {
747            id: next_coedge_id,
748            edge_id: new_edge_id,
749            forward: true,
750            pcurve: face_pcurve,
751        };
752        next_coedge_id += 1;
753        let mate_replacement = CoedgeRecord {
754            id: next_coedge_id,
755            edge_id: new_edge_id,
756            forward: false,
757            pcurve: mate_pcurve,
758        };
759        let mut candidate_faces = candidate
760            .shells
761            .iter_mut()
762            .flat_map(|shell| shell.faces.iter_mut())
763            .collect::<Vec<_>>();
764        // A seam puts both runs on ONE loop, and a splice shortens the vector
765        // under every index above the one it consumed. Doing the higher run
766        // first leaves the lower run's index still pointing at its own pair —
767        // and the wrap-around run, which starts at the last index, is the
768        // higher one whenever it is present. Two runs on different loops do
769        // not interact, so one order serves both cases.
770        let splices = {
771            let face_splice = (face_index, loop_index, first_index, face_replacement);
772            let mate_splice = (
773                first_mate.face,
774                first_mate.loop_index,
775                second_mate.coedge,
776                mate_replacement,
777            );
778            if face_splice.0 == mate_splice.0
779                && face_splice.1 == mate_splice.1
780                && mate_splice.2 > face_splice.2
781            {
782                [mate_splice, face_splice]
783            } else {
784                [face_splice, mate_splice]
785            }
786        };
787        for (splice_face, splice_loop, splice_index, replacement) in splices {
788            replace_adjacent(
789                &mut candidate_faces[splice_face].loops[splice_loop].coedges,
790                splice_index,
791                replacement,
792            );
793        }
794        // Even exact pcurve concatenation changes the quadrature partition at
795        // the removed knot. Allow its few-parts-per-million integration drift
796        // while still rejecting material trim changes and branch crossings.
797        let area_tolerance_ratio = if pcurve_rebuilt { 5e-3 } else { 3e-6 };
798        let preserves_area = |before: f64, after: f64| {
799            (after - before).abs() <= 1e-9f64.max(before.abs() * area_tolerance_ratio)
800                && (before.abs() <= 1e-12 || before.is_sign_positive() == after.is_sign_positive())
801        };
802        if !preserves_area(
803            before_face,
804            parameter_space_area(candidate_faces[face_index])?,
805        ) || !preserves_area(
806            before_mate,
807            parameter_space_area(candidate_faces[first_mate.face])?,
808        ) {
809            // This exact pair crosses a periodic p-curve branch. Leave it
810            // split and continue searching for other safe candidates.
811            rejected.insert(if first_edge_id < second_edge_id {
812                (first_edge_id, second_edge_id)
813            } else {
814                (second_edge_id, first_edge_id)
815            });
816            continue;
817        }
818        drop(candidate_faces);
819        candidate
820            .edges
821            .retain(|edge| edge.id != first_edge_id && edge.id != second_edge_id);
822        let used_vertices = candidate
823            .edges
824            .iter()
825            .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
826            .collect::<rustc_hash::FxHashSet<_>>();
827        candidate
828            .vertices
829            .retain(|vertex| used_vertices.contains(&vertex.id));
830        // Offset-shell coalescing also runs while completion boundaries are
831        // intentionally still open. Requiring closed-solid validation here
832        // misattributes those pre-existing one-use edges to this local
833        // rewrite. The completed result is validated at the kernel boundary.
834        result = candidate;
835    }
836    Ok(result)
837}
838
839// BREP private tests: 558cbd2292d8508e