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/// Collapse tangent continuation edges seen by the same two incident faces.
285///
286/// Both 3D curves and both face p-curves are concatenated exactly. Periodic
287/// branch changes are rejected if they alter either face's parameter area.
288pub fn merge_curve_continuation_edges(
289    solid: &BrepSolid,
290    tolerance: f64,
291) -> Result<BrepSolid, String> {
292    let mut result = solid.clone();
293    let mut rejected = rustc_hash::FxHashSet::<(u64, u64)>::default();
294    loop {
295        let edges = result
296            .edges
297            .iter()
298            .map(|edge| (edge.id, edge))
299            .collect::<HashMap<_, _>>();
300        let vertices = result
301            .vertices
302            .iter()
303            .map(|vertex| (vertex.id, vertex.point))
304            .collect::<HashMap<_, _>>();
305        let faces = result
306            .shells
307            .iter()
308            .flat_map(|shell| shell.faces.iter())
309            .collect::<Vec<_>>();
310        let mut uses: HashMap<u64, Vec<UseLocation>> = HashMap::default();
311        for (face_index, face) in faces.iter().enumerate() {
312            for (loop_index, loop_record) in face.loops.iter().enumerate() {
313                for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
314                    uses.entry(coedge.edge_id).or_default().push(UseLocation {
315                        face: face_index,
316                        loop_index,
317                        coedge: coedge_index,
318                    });
319                }
320            }
321        }
322        let mut accepted = None;
323        'faces: for (face_index, face) in faces.iter().enumerate() {
324            for (loop_index, loop_record) in face.loops.iter().enumerate() {
325                if loop_record.coedges.len() < 2 {
326                    continue;
327                }
328                for first_index in 0..loop_record.coedges.len() {
329                    let second_index = (first_index + 1) % loop_record.coedges.len();
330                    let first = &loop_record.coedges[first_index];
331                    let second = &loop_record.coedges[second_index];
332                    let first_edge = edges[&first.edge_id];
333                    let second_edge = edges[&second.edge_id];
334                    let pair_key = if first.edge_id < second.edge_id {
335                        (first.edge_id, second.edge_id)
336                    } else {
337                        (second.edge_id, first.edge_id)
338                    };
339                    if rejected.contains(&pair_key) {
340                        continue;
341                    }
342                    if first_edge.degenerate || second_edge.degenerate {
343                        continue;
344                    }
345                    let (_, first_end) = traversal_vertex_ids(first_edge, first);
346                    let (second_start, _) = traversal_vertex_ids(second_edge, second);
347                    if first_end != second_start {
348                        continue;
349                    }
350                    let Some(first_uses) = uses.get(&first.edge_id) else {
351                        continue;
352                    };
353                    let Some(second_uses) = uses.get(&second.edge_id) else {
354                        continue;
355                    };
356                    if first_uses.len() != 2 || second_uses.len() != 2 {
357                        continue;
358                    }
359                    let first_mate = first_uses
360                        .iter()
361                        .find(|location| {
362                            location.face != face_index
363                                || location.loop_index != loop_index
364                                || location.coedge != first_index
365                        })
366                        .copied()
367                        .unwrap();
368                    let second_mate = second_uses
369                        .iter()
370                        .find(|location| {
371                            location.face != face_index
372                                || location.loop_index != loop_index
373                                || location.coedge != second_index
374                        })
375                        .copied()
376                        .unwrap();
377                    // This rewrite replaces one adjacent pair on each of two
378                    // incident faces. A seam can expose both uses on the same
379                    // face (or even the same loop); applying the two indexed
380                    // splices there shifts the second location and can leave
381                    // an old edge with only one use.
382                    if first_mate.face == face_index && first_mate.loop_index == loop_index {
383                        continue;
384                    }
385                    if first_mate.face != second_mate.face
386                        || first_mate.loop_index != second_mate.loop_index
387                        || (second_mate.coedge + 1)
388                            % faces[second_mate.face].loops[second_mate.loop_index]
389                                .coedges
390                                .len()
391                            != first_mate.coedge
392                    {
393                        continue;
394                    }
395                    let first_parameter = if first.forward {
396                        first_edge.t1
397                    } else {
398                        first_edge.t0
399                    };
400                    let second_parameter = if second.forward {
401                        second_edge.t0
402                    } else {
403                        second_edge.t1
404                    };
405                    let mut first_tangent = first_edge.curve.derivatives(first_parameter, 1)?[1];
406                    let mut second_tangent = second_edge.curve.derivatives(second_parameter, 1)?[1];
407                    if !first.forward {
408                        first_tangent = first_tangent.scale(-1.0);
409                    }
410                    if !second.forward {
411                        second_tangent = second_tangent.scale(-1.0);
412                    }
413                    let first_tangent = first_tangent.normalized()?;
414                    let second_tangent = second_tangent.normalized()?;
415                    if first_tangent.dot(second_tangent) < 1.0 - 1e-10 {
416                        continue;
417                    }
418                    let mut first_curve =
419                        trimmed_curve(&first_edge.curve, first_edge.t0, first_edge.t1)?;
420                    let mut second_curve =
421                        trimmed_curve(&second_edge.curve, second_edge.t0, second_edge.t1)?;
422                    if !first.forward {
423                        first_curve = first_curve.reversed()?;
424                    }
425                    if !second.forward {
426                        second_curve = second_curve.reversed()?;
427                    }
428                    let first_span = {
429                        let domain = first_curve.domain()?;
430                        domain[1] - domain[0]
431                    };
432                    let second_span = {
433                        let domain = second_curve.domain()?;
434                        domain[1] - domain[0]
435                    };
436                    let split = first_span / (first_span + second_span);
437                    let curve = if let Some(curve) = concatenate_exact_curve_pieces(
438                        &first_curve,
439                        &second_curve,
440                        split,
441                        tolerance,
442                    )? {
443                        curve
444                    } else if let Some(curve) = concatenate_compatible_fitted_pieces(
445                        &first_curve,
446                        &second_curve,
447                        split,
448                        tolerance,
449                    )? {
450                        curve
451                    } else {
452                        continue;
453                    };
454                    let (start_vertex_id, _) = traversal_vertex_ids(first_edge, first);
455                    let (_, end_vertex_id) = traversal_vertex_ids(second_edge, second);
456                    let Some(start_point) = vertices.get(&start_vertex_id).copied() else {
457                        continue;
458                    };
459                    let Some(end_point) = vertices.get(&end_vertex_id).copied() else {
460                        continue;
461                    };
462                    let curve = constrain_curve_endpoints(curve, start_point, end_point)?;
463                    let (mut face_pcurve, face_pcurve_rebuilt) = if let Some(pcurve) =
464                        concatenate_exact_curve_pieces(
465                            &first.pcurve,
466                            &second.pcurve,
467                            split,
468                            tolerance,
469                        )? {
470                        (pcurve, false)
471                    } else {
472                        (build_pcurve_on_surface(&face.surface, &curve)?, true)
473                    };
474                    let mate_loop = &faces[first_mate.face].loops[first_mate.loop_index];
475                    let second_mate_coedge = &mate_loop.coedges[second_mate.coedge];
476                    let first_mate_coedge = &mate_loop.coedges[first_mate.coedge];
477                    let (mut mate_pcurve, mate_pcurve_rebuilt) = if let Some(pcurve) =
478                        concatenate_exact_curve_pieces(
479                            &second_mate_coedge.pcurve,
480                            &first_mate_coedge.pcurve,
481                            1.0 - split,
482                            tolerance,
483                        )? {
484                        (pcurve, false)
485                    } else {
486                        (
487                            build_pcurve_on_surface(
488                                &faces[first_mate.face].surface,
489                                &curve.reversed()?,
490                            )?,
491                            true,
492                        )
493                    };
494                    let mut pcurve_rebuilt = face_pcurve_rebuilt || mate_pcurve_rebuilt;
495                    if !pcurve_matches_edge(&face.surface, &face_pcurve, &curve, false, tolerance)?
496                    {
497                        face_pcurve = concatenate_sampled_pieces(
498                            &first.pcurve,
499                            &second.pcurve,
500                            split,
501                            tolerance * 10.0,
502                        )?
503                        .unwrap_or(build_pcurve_on_surface(&face.surface, &curve)?);
504                        pcurve_rebuilt = true;
505                    }
506                    if !pcurve_matches_edge(
507                        &faces[first_mate.face].surface,
508                        &mate_pcurve,
509                        &curve,
510                        true,
511                        tolerance,
512                    )? {
513                        mate_pcurve = concatenate_sampled_pieces(
514                            &second_mate_coedge.pcurve,
515                            &first_mate_coedge.pcurve,
516                            1.0 - split,
517                            tolerance * 10.0,
518                        )?
519                        .unwrap_or(build_pcurve_on_surface(
520                            &faces[first_mate.face].surface,
521                            &curve.reversed()?,
522                        )?);
523                        pcurve_rebuilt = true;
524                    }
525                    if !pcurve_matches_edge(&face.surface, &face_pcurve, &curve, false, tolerance)?
526                        || !pcurve_matches_edge(
527                            &faces[first_mate.face].surface,
528                            &mate_pcurve,
529                            &curve,
530                            true,
531                            tolerance,
532                        )?
533                    {
534                        // Mixed parameterizations (one pcurve concatenated
535                        // exactly with a speed kink, the other rebuilt
536                        // uniformly) cannot both track the curve linearly.
537                        // Re-derive BOTH pcurves from the merged curve so all
538                        // three share one parameterization by construction.
539                        face_pcurve = build_pcurve_on_surface(&face.surface, &curve)?;
540                        mate_pcurve = build_pcurve_on_surface(
541                            &faces[first_mate.face].surface,
542                            &curve.reversed()?,
543                        )?;
544                        pcurve_rebuilt = true;
545                        if !pcurve_matches_edge(
546                            &face.surface,
547                            &face_pcurve,
548                            &curve,
549                            false,
550                            tolerance,
551                        )? || !pcurve_matches_edge(
552                            &faces[first_mate.face].surface,
553                            &mate_pcurve,
554                            &curve,
555                            true,
556                            tolerance,
557                        )? {
558                            continue;
559                        }
560                    }
561                    // The local 32-point guard above is a cheap branch check.
562                    // Before committing topology, enforce the same adaptive
563                    // contract as final validation so a narrow fitted-curve
564                    // error cannot be introduced by coalescing.
565                    let [candidate_t0, candidate_t1] = curve.domain()?;
566                    let candidate_edge = EdgeRecord {
567                        id: first.edge_id,
568                        curve: curve.clone(),
569                        t0: candidate_t0,
570                        t1: candidate_t1,
571                        start_vertex_id,
572                        end_vertex_id,
573                        degenerate: false,
574                        // A concatenated continuation keeps the first
575                        // constituent's persistent name.
576                        name: first_edge.name.clone(),
577                    };
578                    let pcurve_contract =
579                        KernelTolerances::for_solid(&result, 1e-7).pcurve_consistency;
580                    if adaptive_coedge_error(
581                        &face.surface,
582                        &face_pcurve,
583                        &curve,
584                        &candidate_edge,
585                        true,
586                        pcurve_contract,
587                    )? > pcurve_contract
588                        || adaptive_coedge_error(
589                            &faces[first_mate.face].surface,
590                            &mate_pcurve,
591                            &curve,
592                            &candidate_edge,
593                            false,
594                            pcurve_contract,
595                        )? > pcurve_contract
596                    {
597                        rejected.insert(pair_key);
598                        continue;
599                    }
600                    accepted = Some((
601                        face_index,
602                        loop_index,
603                        first_index,
604                        first_mate,
605                        second_mate,
606                        first.edge_id,
607                        second.edge_id,
608                        curve,
609                        face_pcurve,
610                        mate_pcurve,
611                        pcurve_rebuilt,
612                        start_vertex_id,
613                        end_vertex_id,
614                    ));
615                    break 'faces;
616                }
617            }
618        }
619        let Some((
620            face_index,
621            loop_index,
622            first_index,
623            first_mate,
624            second_mate,
625            first_edge_id,
626            second_edge_id,
627            curve,
628            face_pcurve,
629            mate_pcurve,
630            pcurve_rebuilt,
631            start_vertex_id,
632            end_vertex_id,
633        )) = accepted
634        else {
635            break;
636        };
637        let before_face = parameter_space_area(faces[face_index])?;
638        let before_mate = parameter_space_area(faces[first_mate.face])?;
639        drop(faces);
640        drop(edges);
641        let mut candidate = result.clone();
642        let new_edge_id = candidate
643            .edges
644            .iter()
645            .map(|edge| edge.id)
646            .max()
647            .unwrap_or(0)
648            + 1;
649        let domain = curve.domain()?;
650        let merged_name = candidate
651            .edges
652            .iter()
653            .find(|edge| edge.id == first_edge_id)
654            .and_then(|edge| edge.name.clone());
655        candidate.edges.push(EdgeRecord {
656            id: new_edge_id,
657            curve,
658            t0: domain[0],
659            t1: domain[1],
660            start_vertex_id,
661            end_vertex_id,
662            degenerate: false,
663            name: merged_name,
664        });
665        let mut next_coedge_id = candidate
666            .shells
667            .iter()
668            .flat_map(|shell| &shell.faces)
669            .flat_map(|face| &face.loops)
670            .flat_map(|loop_record| &loop_record.coedges)
671            .map(|coedge| coedge.id)
672            .max()
673            .unwrap_or(0)
674            + 1;
675        let face_replacement = CoedgeRecord {
676            id: next_coedge_id,
677            edge_id: new_edge_id,
678            forward: true,
679            pcurve: face_pcurve,
680        };
681        next_coedge_id += 1;
682        let mate_replacement = CoedgeRecord {
683            id: next_coedge_id,
684            edge_id: new_edge_id,
685            forward: false,
686            pcurve: mate_pcurve,
687        };
688        let mut candidate_faces = candidate
689            .shells
690            .iter_mut()
691            .flat_map(|shell| shell.faces.iter_mut())
692            .collect::<Vec<_>>();
693        replace_adjacent(
694            &mut candidate_faces[face_index].loops[loop_index].coedges,
695            first_index,
696            face_replacement,
697        );
698        replace_adjacent(
699            &mut candidate_faces[first_mate.face].loops[first_mate.loop_index].coedges,
700            second_mate.coedge,
701            mate_replacement,
702        );
703        // Even exact pcurve concatenation changes the quadrature partition at
704        // the removed knot. Allow its few-parts-per-million integration drift
705        // while still rejecting material trim changes and branch crossings.
706        let area_tolerance_ratio = if pcurve_rebuilt { 5e-3 } else { 3e-6 };
707        let preserves_area = |before: f64, after: f64| {
708            (after - before).abs() <= 1e-9f64.max(before.abs() * area_tolerance_ratio)
709                && (before.abs() <= 1e-12 || before.is_sign_positive() == after.is_sign_positive())
710        };
711        if !preserves_area(
712            before_face,
713            parameter_space_area(candidate_faces[face_index])?,
714        ) || !preserves_area(
715            before_mate,
716            parameter_space_area(candidate_faces[first_mate.face])?,
717        ) {
718            // This exact pair crosses a periodic p-curve branch. Leave it
719            // split and continue searching for other safe candidates.
720            rejected.insert(if first_edge_id < second_edge_id {
721                (first_edge_id, second_edge_id)
722            } else {
723                (second_edge_id, first_edge_id)
724            });
725            continue;
726        }
727        drop(candidate_faces);
728        candidate
729            .edges
730            .retain(|edge| edge.id != first_edge_id && edge.id != second_edge_id);
731        let used_vertices = candidate
732            .edges
733            .iter()
734            .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
735            .collect::<rustc_hash::FxHashSet<_>>();
736        candidate
737            .vertices
738            .retain(|vertex| used_vertices.contains(&vertex.id));
739        // Offset-shell coalescing also runs while completion boundaries are
740        // intentionally still open. Requiring closed-solid validation here
741        // misattributes those pre-existing one-use edges to this local
742        // rewrite. The completed result is validated at the kernel boundary.
743        result = candidate;
744    }
745    Ok(result)
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751    use crate::{apply_edge_splits, build_imprints, make_box_brep, ImprintOptions, Vec3};
752
753    #[test]
754    fn endpoint_constraint_is_exact_for_clamped_fitted_curve() {
755        let points = (0..=12)
756            .map(|index| {
757                let x = index as f64 / 12.0;
758                Vec3::new(x, x * x, 0.0)
759            })
760            .collect::<Vec<_>>();
761        let parameters = (0..=12)
762            .map(|index| index as f64 / 12.0)
763            .collect::<Vec<_>>();
764        let curve = interpolate_curve(&points, 8, &parameters).unwrap();
765        let start = Vec3::new(-2e-4, 3e-4, 0.0);
766        let end = Vec3::new(1.0002, 0.9997, 0.0);
767        let curve = constrain_curve_endpoints(curve, start, end).unwrap();
768        let [t0, t1] = curve.domain().unwrap();
769        assert!(curve.evaluate(t0).unwrap().sub(start).length() < 1e-12);
770        assert!(curve.evaluate(t1).unwrap().sub(end).length() < 1e-12);
771    }
772
773    #[test]
774    fn exact_line_pieces_concatenate_without_refitting() {
775        let line = crate::make_line(Vec3::default(), Vec3::new(10.0, 0.0, 0.0)).unwrap();
776        let (first, second) = line.split(0.4).unwrap();
777        let joined = concatenate_exact_curve_pieces(&first, &second, 0.4, 1e-9)
778            .unwrap()
779            .unwrap();
780        for index in 0..=20 {
781            let parameter = index as f64 / 20.0;
782            assert!(
783                joined
784                    .evaluate(parameter)
785                    .unwrap()
786                    .sub(line.evaluate(parameter).unwrap())
787                    .length()
788                    < 1e-10
789            );
790        }
791    }
792
793    #[test]
794    fn rational_arc_pieces_concatenate_on_the_same_exact_curve() {
795        let arc = crate::make_arc(
796            Vec3::new(1.0, -2.0, 3.0),
797            Vec3::new(1.0, 0.0, 0.0),
798            Vec3::new(0.0, 1.0, 0.0),
799            5.0,
800            -0.4,
801            4.7,
802        )
803        .unwrap();
804        let (first, second) = arc.split(0.37).unwrap();
805        let joined = concatenate_exact_curve_pieces(&first, &second, 0.37, 1e-9)
806            .unwrap()
807            .unwrap();
808        for index in 0..=50 {
809            let parameter = index as f64 / 50.0;
810            assert!(
811                joined
812                    .evaluate(parameter)
813                    .unwrap()
814                    .sub(arc.evaluate(parameter).unwrap())
815                    .length()
816                    < 1e-9
817            );
818        }
819    }
820
821    #[test]
822    fn fragmented_box_edges_collapse_on_both_incident_faces() {
823        let first = make_box_brep(Vec3::default(), 4.0, 4.0, 4.0).unwrap();
824        let second = make_box_brep(Vec3::new(2.0, 1.0, 1.0), 4.0, 4.0, 4.0).unwrap();
825        let imprint = build_imprints(&first, &second, &ImprintOptions::default()).unwrap();
826        let split = apply_edge_splits(&first, 0, &imprint).unwrap();
827        assert!(split.edges.len() > first.edges.len());
828        let merged = merge_curve_continuation_edges(&split, 1e-7).unwrap();
829        assert_eq!(merged.edges.len(), first.edges.len());
830        assert!(merged.validate().is_empty());
831    }
832}