Skip to main content

brep_kernel/construction/sweep_topology/
rib.rs

1use super::*;
2use crate::{classify_point, PointClass, SolidClassifier};
3use serde::{Deserialize, Serialize};
4
5/// Rational ruled surface between two curves with IDENTICAL degree, knots and
6/// weight pattern (the drafted-arc rows share one `make_arc` window, so this
7/// yields the EXACT cone patch: each ruling blends radially-corresponding
8/// points at equal weights).
9pub(super) fn ruled_between(bottom: &NurbsCurve, top: &NurbsCurve) -> Result<NurbsSurface, KernelRefusal> {
10    if bottom.degree != top.degree
11        || bottom.control_points.len() != top.control_points.len()
12        || bottom.knots.len() != top.knots.len()
13    {
14        return Err(KernelRefusal::internal(
15            KernelStage::Fragment,
16            "ruled_rows",
17            "ruled_between: rows are not representation-compatible",
18        ));
19    }
20    let grid = bottom
21        .control_points
22        .iter()
23        .zip(&top.control_points)
24        .map(|(a, b)| vec![*a, *b])
25        .collect();
26    NurbsSurface::new(
27        bottom.degree,
28        1,
29        bottom.knots.clone(),
30        vec![0.0, 0.0, 1.0, 1.0],
31        grid,
32    )
33    .or_refuse(KernelStage::Fragment, "surface_new")
34}
35
36/// Subrange of a wall ROW curve between the projections of two junction
37/// points.  Splitting (instead of rebuilding with `make_arc`) preserves the
38/// row's parameterization exactly, so the edge is the surface's own boundary
39/// restriction and its parameter-line pcurve is pointwise exact.
40pub(super) fn arc_window_subrange(row: &NurbsCurve, start: Vec3, end: Vec3) -> Result<NurbsCurve, KernelRefusal> {
41    let [d0, d1] = row.domain().or_refuse(KernelStage::Fragment, "domain")?;
42    let span = d1 - d0;
43    let u0 = project_point_to_curve(row, start).or_refuse(KernelStage::Fragment, "project_point")?.u;
44    let u1 = project_point_to_curve(row, end).or_refuse(KernelStage::Fragment, "project_point")?.u;
45    if u1 <= u0 + 1e-12 {
46        return Err(KernelRefusal::internal(
47            KernelStage::Fragment,
48            "arc_trim",
49            "draftExtrude: a drafted arc's boundary trim inverted",
50        ));
51    }
52    let epsilon = span * 1e-9;
53    let mut current = row.clone();
54    if u0 > d0 + epsilon {
55        current = current.split(u0).or_refuse(KernelStage::Fragment, "split")?.1;
56    }
57    let domain = current.domain().or_refuse(KernelStage::Fragment, "domain")?;
58    if u1 < domain[1] - epsilon && u1 > domain[0] + epsilon {
59        current = current.split(u1).or_refuse(KernelStage::Fragment, "split")?.0;
60    }
61    Ok(current)
62}
63
64/// Gradient (unnormalized surface normal direction) of a drafted WALL's
65/// implicit surface at a point on it: for a line wall the tilted plane's
66/// normal; for an arc wall the cone's ∇(ρ − r(z)) = ρ̂ + (d·turn/h)·ẑ.  Exact
67/// closed forms — the junction-conic end tangents come from their cross
68/// products.
69fn wall_gradient(
70    seg: &SegGeom,
71    point: Vec3,
72    zh: Vec3,
73    height: f64,
74    signed_d: f64,
75) -> Result<Vec3, KernelRefusal> {
76    match seg {
77        SegGeom::Line { dir, normal, .. } => dir
78            .cross(normal.scale(signed_d).add(zh.scale(height)))
79            .normalized()
80            .or_refuse(KernelStage::Fragment, "normalized"),
81        SegGeom::Arc {
82            center, turn, ..
83        } => {
84            let rel = point.sub(*center);
85            let radial = rel.sub(zh.scale(rel.dot(zh)));
86            let rho = radial.normalized().or_refuse(KernelStage::Fragment, "normalized")?;
87            rho.add(zh.scale(signed_d * turn / height))
88                .normalized()
89                .or_refuse(KernelStage::Fragment, "normalized")
90        }
91    }
92}
93
94/// The EXACT junction edge between two adjacent drafted walls, from bottom
95/// junction `a` to top junction `b` with mid-height witness `m` (all three are
96/// exact offset-primitive intersections).  Straight when `m` is collinear
97/// (plane∧plane miter edges, tangent-junction cone rulings); otherwise the
98/// exact CONIC through `a`/`b` with the walls' analytic gradient-cross end
99/// tangents, its rational-quadratic weight solved from `m` (a conic is
100/// uniquely determined by that data, and every drafted wall∧wall intersection
101/// IS a conic — both squared implicits shrink linearly at the same rate, so
102/// their difference is a plane).
103#[allow(clippy::too_many_arguments)]
104pub(super) fn junction_edge_curve(
105    prev: &SegGeom,
106    next: &SegGeom,
107    a: Vec3,
108    m: Vec3,
109    b: Vec3,
110    zh: Vec3,
111    height: f64,
112    signed_d: f64,
113) -> Result<NurbsCurve, KernelRefusal> {
114    let chord = b.sub(a);
115    let length = chord.length();
116    if length <= 1e-12 {
117        return Err(KernelRefusal::internal(
118            KernelStage::Fragment,
119            "junction_collapsed",
120            "draftExtrude: a junction edge collapsed to a point",
121        ));
122    }
123    let along = m.sub(a).dot(chord) / (length * length);
124    let deviation = m.sub(a).sub(chord.scale(along)).length();
125    if deviation <= length * 1e-9 {
126        return make_line(a, b).or_refuse(KernelStage::Fragment, "make_line");
127    }
128    // 2D frame in the conic's plane (it contains a, b, m by construction).
129    let e1 = chord.scale(1.0 / length);
130    let plane_normal = chord
131        .cross(m.sub(a))
132        .normalized()
133        .or_refuse(KernelStage::Fragment, "normalized")?;
134    let e2 = plane_normal
135        .cross(e1)
136        .normalized()
137        .or_refuse(KernelStage::Fragment, "normalized")?;
138    let orient = |tangent: Vec3| {
139        if tangent.dot(zh) < 0.0 {
140            tangent.scale(-1.0)
141        } else {
142            tangent
143        }
144    };
145    let t0 = orient(wall_gradient(prev, a, zh, height, signed_d)?
146        .cross(wall_gradient(next, a, zh, height, signed_d)?));
147    let t2 = orient(wall_gradient(prev, b, zh, height, signed_d)?
148        .cross(wall_gradient(next, b, zh, height, signed_d)?));
149    let d0 = (t0.dot(e1), t0.dot(e2));
150    let d2 = (t2.dot(e1), t2.dot(e2));
151    let denom = d0.0 * d2.1 - d0.1 * d2.0;
152    let scale0 = d0.0.hypot(d0.1);
153    let scale2 = d2.0.hypot(d2.1);
154    if denom.abs() <= 1e-14 * scale0 * scale2 {
155        return Err(KernelRefusal::internal(
156            KernelStage::Fragment,
157            "conic_apex",
158            "draftExtrude: junction end tangents are parallel — no conic apex",
159        ));
160    }
161    // Apex: a + s·t0 = b + r·t2 solved in 2D (a = origin, b = (length, 0)).
162    let s = length * d2.1 / denom;
163    let apex = (s * d0.0, s * d0.1);
164    if apex.1.abs() <= f64::EPSILON * length {
165        return Err(KernelRefusal::internal(
166            KernelStage::Fragment,
167            "conic_apex_degenerate",
168            "draftExtrude: junction conic apex is degenerate",
169        ));
170    }
171    // Barycentric coordinates of m over (a, apex, b): m = α·a + β·apex + γ·b.
172    let mq = (m.sub(a).dot(e1), m.sub(a).dot(e2));
173    let beta = mq.1 / apex.1;
174    let gamma = (mq.0 - beta * apex.0) / length;
175    let alpha = 1.0 - beta - gamma;
176    if !(alpha > 0.0 && beta > 0.0 && gamma > 0.0) {
177        return Err(KernelRefusal::internal(
178            KernelStage::Fragment,
179            "conic_witness",
180            format!(
181            "draftExtrude: junction conic witness fell outside its control triangle \
182             (α={alpha:.3e} β={beta:.3e} γ={gamma:.3e})"
183            ),
184        ));
185    }
186    let weight = beta / (2.0 * (alpha * gamma).sqrt());
187    let apex_3d = a.add(e1.scale(apex.0)).add(e2.scale(apex.1));
188    NurbsCurve::new(
189        2,
190        vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
191        vec![
192            Vec4::from_point(a, 1.0),
193            Vec4::from_point(apex_3d, weight),
194            Vec4::from_point(b, 1.0),
195        ],
196    )
197    .or_refuse(KernelStage::Fragment, "curve_new")
198}
199
200/// In-plane circumcircle of three coplanar points (projected onto `ex`/`ey`,
201/// `np = ex×ey`).  Returns `None` when the three points are collinear.
202fn circumcircle(a: Vec3, b: Vec3, c: Vec3, ex: Vec3, ey: Vec3, np: Vec3) -> Option<(Vec3, f64)> {
203    let (ax, ay) = (a.dot(ex), a.dot(ey));
204    let (bx, by) = (b.dot(ex), b.dot(ey));
205    let (cx, cy) = (c.dot(ex), c.dot(ey));
206    let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
207    if d.abs() < 1e-12 {
208        return None;
209    }
210    let a2 = ax * ax + ay * ay;
211    let b2 = bx * bx + by * by;
212    let c2 = cx * cx + cy * cy;
213    let ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d;
214    let uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d;
215    let plane_off = a.dot(np);
216    let center = ex.scale(ux).add(ey.scale(uy)).add(np.scale(plane_off));
217    let radius = a.sub(center).length();
218    Some((center, radius))
219}
220
221/// Intersect an offset LINE (through `line_point`, direction `line_dir`) with an
222/// offset CIRCLE (`center`, `radius`), returning the root nearest `near` (the
223/// original junction).  A clear Err when they no longer meet (offset too large).
224fn intersect_offset_line_circle(
225    line_point: Vec3,
226    line_dir: Vec3,
227    center: Vec3,
228    radius: f64,
229    near: Vec3,
230) -> Result<Vec3, KernelRefusal> {
231    let dir = line_dir.normalized().or_refuse(KernelStage::Classify, "normalized")?;
232    let f = line_point.sub(center);
233    let b = f.dot(dir);
234    let c = f.dot(f) - radius * radius;
235    let disc = b * b - c;
236    if disc < -1e-9 {
237        return Err(KernelRefusal::input(
238            KernelStage::Classify,
239            "offset_too_large",
240            "an offset line and arc no longer meet (offset too large)",
241        ));
242    }
243    let root = disc.max(0.0).sqrt();
244    let p1 = line_point.add(dir.scale(-b + root));
245    let p2 = line_point.add(dir.scale(-b - root));
246    Ok(if p1.sub(near).length() <= p2.sub(near).length() {
247        p1
248    } else {
249        p2
250    })
251}
252
253/// Intersect two offset CIRCLES (in the plane whose normal is `plane_normal`),
254/// returning the root nearest `near`.  A clear Err when they are concentric or no
255/// longer meet.
256fn intersect_offset_circles(
257    c1: Vec3,
258    r1: f64,
259    c2: Vec3,
260    r2: f64,
261    plane_normal: Vec3,
262    near: Vec3,
263) -> Result<Vec3, KernelRefusal> {
264    let between = c2.sub(c1);
265    let d = between.length();
266    if d < 1e-9 {
267        return Err(KernelRefusal::internal(
268            KernelStage::Classify,
269            "concentric_arcs",
270            "concentric offset arcs do not meet",
271        ));
272    }
273    let axis = between.scale(1.0 / d);
274    let a = (d * d + r1 * r1 - r2 * r2) / (2.0 * d);
275    let h2 = r1 * r1 - a * a;
276    if h2 < -1e-9 {
277        return Err(KernelRefusal::input(
278            KernelStage::Classify,
279            "offset_arcs_too_large",
280            "offset arcs no longer meet (offset too large)",
281        ));
282    }
283    let h = h2.max(0.0).sqrt();
284    let base = c1.add(axis.scale(a));
285    let perp = plane_normal.cross(axis).normalized().or_refuse(KernelStage::Classify, "normalized")?;
286    let p1 = base.add(perp.scale(h));
287    let p2 = base.sub(perp.scale(h));
288    Ok(if p1.sub(near).length() <= p2.sub(near).length() {
289        p1
290    } else {
291        p2
292    })
293}
294
295/// Geometry class of one profile segment, shared by the draft-extrude builder
296/// and the in-plane offset engine: a straight LINE or a circular ARC in the
297/// plane with normal `plane_normal`.
298pub(super) enum SegGeom {
299    Line {
300        start: Vec3,
301        end: Vec3,
302        /// Unit chord direction.
303        dir: Vec3,
304        /// In-plane offset normal `plane_normal × dir` (inward on a CCW loop).
305        normal: Vec3,
306    },
307    Arc {
308        center: Vec3,
309        radius: f64,
310        /// +1 when the arc bends CCW about the plane normal (a convex arc on a
311        /// CCW loop, which SHRINKS under a positive inward offset), −1 when CW.
312        turn: f64,
313        /// ±plane_normal — the axis the arc sweeps CCW about.
314        arc_normal: Vec3,
315        start: Vec3,
316        end: Vec3,
317    },
318}
319
320impl SegGeom {
321    fn start(&self) -> Vec3 {
322        match self {
323            SegGeom::Line { start, .. } | SegGeom::Arc { start, .. } => *start,
324        }
325    }
326
327    fn end(&self) -> Vec3 {
328        match self {
329            SegGeom::Line { end, .. } | SegGeom::Arc { end, .. } => *end,
330        }
331    }
332
333    /// Naive offset image of a point ON this segment's primitive: lines
334    /// translate along their normal; arc points scale radially onto the
335    /// concentric offset circle (Err when a concave arc collapses).
336    fn offset_point(&self, point: Vec3, signed_d: f64) -> Result<Vec3, KernelRefusal> {
337        match self {
338            SegGeom::Line { normal, .. } => Ok(point.add(normal.scale(signed_d))),
339            SegGeom::Arc {
340                center,
341                radius,
342                turn,
343                ..
344            } => {
345                let r_offset = radius - signed_d * turn;
346                if r_offset <= 1e-6 {
347                    return Err(KernelRefusal::input(
348                        KernelStage::Classify,
349                        "arc_collapse",
350                        "offset: distance is too large — a concave arc collapses",
351                    ));
352                }
353                Ok(center.add(point.sub(*center).scale(r_offset / radius)))
354            }
355        }
356    }
357}
358
359/// Classify every profile segment as a LINE (all interior samples on the
360/// chord) or a circular ARC (circumcircle through start/mid/end confirmed by
361/// on-circle samples), with its turning direction about `plane_normal`.
362/// Anything else is a clear Err — the offset/draft machinery is exact for
363/// lines and circles only.
364pub(super) fn classify_profile_segments(
365    profile: &[NurbsCurve],
366    plane_normal: Vec3,
367) -> Result<Vec<SegGeom>, KernelRefusal> {
368    let tol = 1e-6;
369    if profile.is_empty() {
370        return Err(KernelRefusal::input(
371            KernelStage::Collect,
372            "profile_empty",
373            "offset: profile has no segments",
374        ));
375    }
376    let np = plane_normal.normalized().or_refuse(KernelStage::Classify, "normalized")?;
377    let ex = np.perpendicular().or_refuse(KernelStage::Classify, "perpendicular")?;
378    let ey = np.cross(ex).normalized().or_refuse(KernelStage::Classify, "normalized")?;
379    let mut segs = Vec::with_capacity(profile.len());
380    for curve in profile {
381        let [t0, t1] = curve.domain().or_refuse(KernelStage::Classify, "domain")?;
382        let start = curve.evaluate(t0).or_refuse(KernelStage::Classify, "evaluate")?;
383        let end = curve.evaluate(t1).or_refuse(KernelStage::Classify, "evaluate")?;
384        let chord = end.sub(start);
385        let chord_len = chord.length();
386        if chord_len <= tol {
387            return Err(KernelRefusal::input(
388                KernelStage::Collect,
389                "segment_length",
390                "offset: profile has a degenerate (zero-length) segment",
391            ));
392        }
393        let dir = chord.scale(1.0 / chord_len);
394        // A straight LINE if every interior sample lies on the chord.
395        let mut is_line = true;
396        for k in 1..8 {
397            let point = curve.evaluate(t0 + (t1 - t0) * k as f64 / 8.0).or_refuse(KernelStage::Classify, "evaluate")?;
398            let rel = point.sub(start);
399            let perpendicular = rel.sub(dir.scale(rel.dot(dir))).length();
400            if perpendicular > tol * 10.0 {
401                is_line = false;
402                break;
403            }
404        }
405        if is_line {
406            segs.push(SegGeom::Line {
407                start,
408                end,
409                dir,
410                normal: np.cross(dir).normalized().or_refuse(KernelStage::Classify, "normalized")?,
411            });
412            continue;
413        }
414        // Otherwise it must be a circular ARC: fit a circle through start/mid/end.
415        let mid = curve.evaluate((t0 + t1) * 0.5).or_refuse(KernelStage::Classify, "evaluate")?;
416        let (center, radius) = circumcircle(start, mid, end, ex, ey, np).ok_or_else(|| {
417            KernelRefusal::unsupported(
418                KernelStage::Classify,
419                "segment_kind",
420                "offset: only straight lines and circular arcs are supported",
421            )
422        })?;
423        for k in 0..=8 {
424            let point = curve.evaluate(t0 + (t1 - t0) * k as f64 / 8.0).or_refuse(KernelStage::Classify, "evaluate")?;
425            if (point.sub(center).length() - radius).abs() > tol * 10.0 {
426                return Err(KernelRefusal::unsupported(
427                    KernelStage::Classify,
428                    "segment_kind",
429                    "offset: only straight lines and circular arcs are supported",
430                ));
431            }
432        }
433        // Turning direction about the plane normal (CCW ⇒ +1 ⇒ shrink inward).
434        let bend = np.dot(mid.sub(start).cross(end.sub(mid)));
435        let (arc_normal, turn) = if bend >= 0.0 {
436            (np, 1.0)
437        } else {
438            (np.scale(-1.0), -1.0)
439        };
440        segs.push(SegGeom::Arc {
441            center,
442            radius,
443            turn,
444            arc_normal,
445            start,
446            end,
447        });
448    }
449    Ok(segs)
450}
451
452/// The junction between two consecutive segments' OFFSET primitives at signed
453/// in-plane distance `signed_d`:
454///   • offset 0 → the original shared vertex, exactly;
455///   • a TANGENT (G1) junction degenerates to the shared offset point
456///     (coincident-naive-offsets fast path);
457///   • line∧line → the exact miter V' = V + signed_d/(1+nₐ·n_b)·(nₐ+n_b);
458///   • line∧arc  → the offset line ∩ the offset circle, root nearest V;
459///   • arc∧arc   → the two offset circles ∩, root nearest V.
460/// Offsets that no longer meet (too large for the local feature) are a clear
461/// Err.
462pub(super) fn offset_junction(
463    prev: &SegGeom,
464    next: &SegGeom,
465    plane_normal: Vec3,
466    signed_d: f64,
467) -> Result<Vec3, KernelRefusal> {
468    let vertex = prev.end();
469    if signed_d == 0.0 {
470        return Ok(vertex);
471    }
472    let prev_offset = prev.offset_point(prev.end(), signed_d)?;
473    let next_offset = next.offset_point(next.start(), signed_d)?;
474    if prev_offset.sub(next_offset).length() <= 1e-6 {
475        return Ok(prev_offset.add(next_offset).scale(0.5));
476    }
477    match (prev, next) {
478        (SegGeom::Line { normal: na, .. }, SegGeom::Line { normal: nb, .. }) => {
479            let denom = 1.0 + na.dot(*nb);
480            if denom.abs() < 1e-6 {
481                return Err(KernelRefusal::input(
482                    KernelStage::Classify,
483                    "reversal_corner",
484                    "offset: degenerate (near-reversal) polyline corner",
485                ));
486            }
487            Ok(vertex.add(na.add(*nb).scale(signed_d / denom)))
488        }
489        (
490            SegGeom::Line { dir, .. },
491            SegGeom::Arc {
492                center,
493                radius,
494                turn,
495                ..
496            },
497        ) => intersect_offset_line_circle(
498            prev_offset,
499            *dir,
500            *center,
501            radius - signed_d * turn,
502            vertex,
503        ),
504        (
505            SegGeom::Arc {
506                center,
507                radius,
508                turn,
509                ..
510            },
511            SegGeom::Line { dir, .. },
512        ) => intersect_offset_line_circle(
513            next_offset,
514            *dir,
515            *center,
516            radius - signed_d * turn,
517            vertex,
518        ),
519        (
520            SegGeom::Arc {
521                center: c1,
522                radius: r1,
523                turn: turn1,
524                ..
525            },
526            SegGeom::Arc {
527                center: c2,
528                radius: r2,
529                turn: turn2,
530                ..
531            },
532        ) => intersect_offset_circles(
533            *c1,
534            r1 - signed_d * turn1,
535            *c2,
536            r2 - signed_d * turn2,
537            plane_normal,
538            vertex,
539        ),
540    }
541}
542
543/// Offset a planar profile CHAIN (a sequence of LINE and circular-ARC segments)
544/// IN-PLANE by the SIGNED distance `signed_d` along the per-segment offset normal
545/// n = (plane_normal × tangent).normalized().  Lines move to a parallel line;
546/// circular arcs move to a CONCENTRIC arc (radius r' = r − signed_d·turn — a
547/// convex-outward arc shrinks, a convex-inward arc grows).  Consecutive offset
548/// segments are re-joined at their [`offset_junction`].  `closed` treats the
549/// chain as a loop (every junction re-joined); an OPEN chain leaves its two end
550/// offsets un-joined.  Returns one reconstructed NurbsCurve per input segment
551/// (make_line / make_arc).  A self-intersecting offset (signed_d too large for
552/// a concave corner or arc) surfaces as a clear Err.
553fn offset_profile_segments(
554    profile: &[NurbsCurve],
555    plane_normal: Vec3,
556    signed_d: f64,
557    closed: bool,
558) -> Result<Vec<NurbsCurve>, KernelRefusal> {
559    let tol = 1e-6;
560    let np = plane_normal.normalized().or_refuse(KernelStage::Fragment, "normalized")?;
561    let segs = classify_profile_segments(profile, np)?;
562    let n = segs.len();
563
564    // Naive per-segment offsets, then re-join consecutive ones exactly.
565    let mut offsets: Vec<(Vec3, Vec3)> = segs
566        .iter()
567        .map(|seg| {
568            Ok((
569                seg.offset_point(seg.start(), signed_d)?,
570                seg.offset_point(seg.end(), signed_d)?,
571            ))
572        })
573        .collect::<Result<_, KernelRefusal>>()?;
574    let junctions = if closed { n } else { n.saturating_sub(1) };
575    for i in 0..junctions {
576        let j = (i + 1) % n;
577        let point = offset_junction(&segs[i], &segs[j], np, signed_d)?;
578        offsets[i].1 = point;
579        offsets[j].0 = point;
580    }
581
582    // --- Reconstruct each offset segment as a NurbsCurve.
583    let mut out = Vec::with_capacity(n);
584    for (seg, (off_start, off_end)) in segs.iter().zip(&offsets) {
585        match seg {
586            SegGeom::Line { .. } => out.push(make_line(*off_start, *off_end).or_refuse(KernelStage::Fragment, "make_line")?),
587            SegGeom::Arc {
588                center, arc_normal, ..
589            } => {
590                let radial = off_start.sub(*center);
591                let r2 = radial.length();
592                if r2 <= tol {
593                    return Err(KernelRefusal::internal(
594                        KernelStage::Fragment,
595                        "arc_radius",
596                        "offset: reconstructed arc has a zero radius",
597                    ));
598                }
599                let ax = radial.scale(1.0 / r2);
600                let ay = arc_normal.cross(ax).normalized().or_refuse(KernelStage::Fragment, "normalized")?;
601                let ve = off_end.sub(*center);
602                let mut angle = ve.dot(ay).atan2(ve.dot(ax));
603                if angle <= 1e-9 {
604                    angle += std::f64::consts::TAU;
605                }
606                out.push(make_arc(*center, ax, ay, r2, 0.0, angle).or_refuse(KernelStage::Fragment, "make_arc")?);
607            }
608        }
609    }
610    Ok(out)
611}
612
613/// Which way a rib grows out of its sketch — SolidWorks' **Extrusion Direction**
614/// control, and the same two choices it offers.
615///
616/// The two are not variants of one construction: they SWAP which axis carries the
617/// thickness and which carries the growth, which is why a rib built in the wrong
618/// one lies down where it should stand up.
619#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
620#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
621pub enum RibExtrusion {
622    /// **Parallel to Sketch** (SolidWorks' default, and what everyone means by a
623    /// rib or a gusset): the material grows PARALLEL to the sketch plane and the
624    /// thickness is applied NORMAL to it. A line drawn between two walls becomes a
625    /// thin fin standing ON the sketch plane, growing across it until it lands on
626    /// the part.
627    #[default]
628    ParallelToSketch,
629    /// **Normal to Sketch**: the material grows NORMAL to the sketch plane and the
630    /// thickness is applied IN it. The chain is thickened inside its own plane and
631    /// that ribbon is driven off the plane — walls hanging under a sketch.
632    NormalToSketch,
633}
634
635/// Rib / stiffener (§6.6) — SolidWorks' Rib, both extrusion directions, with its
636/// **Up To Next** end condition.
637///
638/// The chain is thickened by `thickness` and grown along `extrude_dir` until it
639/// LANDS ON THE PART. Which axis carries which is [`RibExtrusion`]'s whole
640/// purpose: `ParallelToSketch` offsets ±thickness/2 along the plane NORMAL and
641/// sweeps the chain IN the plane; `NormalToSketch` offsets ±thickness/2 INSIDE
642/// the plane (miter-joined, straight caps across the open ends) and sweeps that
643/// ribbon along the plane normal.
644///
645/// # Up To Next
646///
647/// There is no depth. SolidWorks' rib has exactly one end condition — the rib
648/// develops until it meets the next faces and the feature FAILS if any part of it
649/// meets nothing — and this reproduces it exactly rather than approximating it:
650/// the chain is swept a generous `reach` (twice the part's bounding diagonal, so
651/// it certainly crosses the part), the sweep is CUT BY THE PART
652/// (`slab − solid`), and the piece that grew out of the sketch is kept. Its
653/// termination surface is therefore the part's own faces, whatever shape they are.
654/// A piece still running at `reach` never landed, and that is the documented
655/// failure — not a silently truncated rib.
656///
657/// `plane_normal` is the profile's own plane when the caller knows it (a sketch
658/// publishes the plane it was drawn on). `None` falls back to deriving the plane
659/// from the chain's bends, which no single straight segment can supply.
660///
661/// # Face names
662///
663/// Every face of the result is named, and every name is a pure function of
664/// `names` and the geometry — never of the order the booleans met the faces in.
665/// See [`RibNames`] for the table.
666///
667/// V1 SCOPE: POLYLINE profiles only — arcs/curves return a clear Err.
668pub fn rib_from_profile(
669    solid: &BrepSolid,
670    profile: &[NurbsCurve],
671    thickness: f64,
672    extrude_dir: Vec3,
673    plane_normal: Option<Vec3>,
674    extrusion: RibExtrusion,
675    names: &RibNames,
676) -> Result<BrepSolid, KernelRefusal> {
677    let tolerance = 1e-6;
678    if profile.is_empty() {
679        return Err(KernelRefusal::input(
680            KernelStage::Collect,
681            "profile_count",
682            "rib: profile needs at least 1 curve forming an open chain",
683        ));
684    }
685    if names.segments.len() != profile.len() {
686        return Err(KernelRefusal::input(
687            KernelStage::Collect,
688            "segment_names",
689            format!(
690                "rib: {} segment names for {} profile curves",
691                names.segments.len(),
692                profile.len()
693            ),
694        ));
695    }
696    if !(thickness > 0.0) {
697        return Err(KernelRefusal::input(
698            KernelStage::Collect,
699            "thickness",
700            "rib: thickness must be positive",
701        ));
702    }
703
704    // --- 1. Extract the ordered chain vertices (segment endpoints) and verify the
705    //        chain is connected end→start.  Segments may be LINES or circular ARCS.
706    let mut vertices = Vec::with_capacity(profile.len() + 1);
707    let mut samples = Vec::new();
708    for (index, curve) in profile.iter().enumerate() {
709        let [start, end] = curve.domain().or_refuse(KernelStage::Collect, "domain")?;
710        let v_start = curve.evaluate(start).or_refuse(KernelStage::Collect, "evaluate")?;
711        let v_end = curve.evaluate(end).or_refuse(KernelStage::Collect, "evaluate")?;
712        if v_end.sub(v_start).length() <= tolerance {
713            return Err(KernelRefusal::input(
714                KernelStage::Collect,
715                "segment_length",
716                "rib: profile has a degenerate (zero-length) segment",
717            ));
718        }
719        if index == 0 {
720            vertices.push(v_start);
721        } else if v_start.sub(*vertices.last().unwrap()).length() > tolerance {
722            return Err(KernelRefusal::input(
723                KernelStage::Collect,
724                "chain_connected",
725                format!("rib: profile chain is not connected at curve {index}"),
726            ));
727        }
728        vertices.push(v_end);
729        // Skip k = 0 after the first segment: it duplicates the previous
730        // segment's endpoint, which would otherwise inject a zero-length step
731        // and cancel the corner bend used to derive the plane normal.
732        let first_k = if index == 0 { 0 } else { 1 };
733        for k in first_k..=8 {
734            samples.push(curve.evaluate(start + (end - start) * k as f64 / 8.0).or_refuse(KernelStage::Collect, "evaluate")?);
735        }
736    }
737    let count = vertices.len();
738    if count < 2 {
739        return Err(KernelRefusal::input(
740            KernelStage::Collect,
741            "vertex_count",
742            "rib: profile needs at least 2 distinct vertices",
743        ));
744    }
745
746    // --- 2. A rib thickens an OPEN profile; a closed loop is an ordinary
747    //        extrude, not a rib.
748    if vertices[count - 1].sub(vertices[0]).length() <= tolerance {
749        return Err(KernelRefusal::input(
750            KernelStage::Collect,
751            "chain_closed",
752            "rib: profile chain is closed; rib expects an open chain",
753        ));
754    }
755
756    // --- 3. The profile plane normal np.  A caller-supplied plane wins: it is the
757    //        plane the profile was AUTHORED on (a sketch publishes it), so it is
758    //        both unambiguous in sign and defined for a chain with no bend at all.
759    //        Otherwise derive it from the sampled chain's bends (robust for arc
760    //        segments), which a fully collinear chain cannot yield.  Either way
761    //        the chain must lie in the plane.
762    let np = match plane_normal {
763        Some(supplied) => supplied.normalized().map_err(|_| {
764            KernelRefusal::input(
765                KernelStage::Collect,
766                "plane_normal",
767                "rib: the supplied profile plane normal is degenerate",
768            )
769        })?,
770        None => {
771            let mut normal = Vec3::default();
772            for i in 1..samples.len() - 1 {
773                let a = samples[i].sub(samples[i - 1]);
774                let b = samples[i + 1].sub(samples[i]);
775                normal = normal.add(a.cross(b));
776            }
777            // A collinear chain lies in every plane through its line: the
778            // input does not pick one, so the rib refuses rather than choosing.
779            normal.normalized().map_err(|_| {
780                KernelRefusal::ill_posed(
781                    KernelStage::Classify,
782                    "plane_undetermined",
783                    "rib: profile is collinear and no profile plane was supplied; cannot determine \
784                 its plane",
785                )
786            })?
787        }
788    };
789    let origin = vertices[0];
790    if samples
791        .iter()
792        .any(|point| point.sub(origin).dot(np).abs() > tolerance * 100.0)
793    {
794        return Err(KernelRefusal::input(
795            KernelStage::Collect,
796            "profile_planar",
797            if plane_normal.is_some() {
798                "rib: profile does not lie in the supplied plane"
799            } else {
800                "rib: profile is not planar"
801            },
802        ));
803    }
804
805    // --- 4. How far to sweep before the part cuts the rib back: twice the part's
806    //        bounding diagonal certainly crosses it from anywhere on the chain, so
807    //        the CUT decides the rib's extent, never this number.
808    let reach = sweep_reach(solid, &vertices)?;
809
810    // --- 5. Build the over-long slab for the requested extrusion direction.  The
811    //        two arms differ only in which axis carries the thickness.
812    let mut slab = match extrusion {
813        RibExtrusion::ParallelToSketch => {
814            // The growth direction lies IN the plane; anything out of plane is a
815            // caller error, not something to silently project away.
816            let along = extrude_dir.sub(np.scale(extrude_dir.dot(np)));
817            let along = along.normalized().map_err(|_| {
818                KernelRefusal::input(
819                    KernelStage::Collect,
820                    "direction_in_plane",
821                    "rib: a Parallel-to-Sketch rib grows INSIDE its sketch plane, but the requested \
822                 direction is perpendicular to it",
823                )
824            })?;
825            parallel_slab(profile, &vertices, np, along, thickness, reach)?
826        }
827        RibExtrusion::NormalToSketch => {
828            let along = extrude_dir.normalized().map_err(|_| {
829                KernelRefusal::input(
830                    KernelStage::Collect,
831                    "direction",
832                    "rib: extrude direction is degenerate",
833                )
834            })?;
835            normal_slab(profile, np, along, thickness, reach)?
836        }
837    };
838
839    // --- 6. Up To Next: cut the over-long slab by the part and keep the piece the
840    //        sketch grew.  That piece is the rib; its far end IS the part's faces.
841    let along = match extrusion {
842        RibExtrusion::ParallelToSketch => {
843            let along = extrude_dir.sub(np.scale(extrude_dir.dot(np)));
844            along.normalized().or_refuse(KernelStage::Collect, "normalized")?
845        }
846        RibExtrusion::NormalToSketch => extrude_dir.normalized().or_refuse(KernelStage::Collect, "normalized")?,
847    };
848
849    // Both operands go through the cut and the fuse carrying SOURCE TOKENS, not
850    // names: the booleans number split fragments in the order they meet them,
851    // and a part may already hold a `Floor` and a `Floor_1`, so a propagated name
852    // cannot say which face a fragment came from. The tokens can, and
853    // `restamp_rib_faces` turns them into names once the result exists.
854    let roles = slab_face_names(names, extrusion, profile.len());
855    let slab_faces: usize = slab.shells.iter().map(|shell| shell.faces.len()).sum();
856    if slab_faces != roles.len() {
857        return Err(KernelRefusal::internal(
858            KernelStage::Fragment,
859            "slab_faces",
860            format!(
861                "rib: the slab builder produced {slab_faces} faces, expected {}",
862                roles.len()
863            ),
864        ));
865    }
866    let (part, part_names) = tokenised(solid, TARGET_TOKEN);
867    for (index, face) in slab.shells.iter_mut().flat_map(|shell| shell.faces.iter_mut()).enumerate() {
868        face.name = Some(format!("{SOURCE_TOKEN}{RIB_TOKEN}{index}"));
869    }
870    let sources = FaceSources {
871        part: part_names,
872        rib: roles,
873    };
874
875    let seeds = chain_probe_seeds(profile)?;
876    let rib = up_to_next(&part, &slab, &seeds, along, reach)
877        .map_err(|error| error.with_message(|error| sources.detokenise(error)))?;
878    let Some(rib) = rib else {
879        // Every bit of the sweep was already material: the rib adds nothing, and
880        // the part is its own answer.  Not an error — the same document with a
881        // thicker wall would build exactly this.
882        return Ok(solid.clone());
883    };
884
885    let mut fused = boolean_operation(
886        &part,
887        &rib,
888        BooleanOperation::Union,
889        &BooleanOptions::default(),
890    )
891    .map_err(|error| {
892        error.with_message(|error| {
893            format!(
894                "rib: union of the rib into the part failed: {}",
895                sources.detokenise(error)
896            )
897        })
898    })?;
899    let chord = vertices[count - 1].sub(vertices[0]).normalized().or_refuse(KernelStage::Collect, "normalized")?;
900    let frame = RibFrame {
901        origin: vertices[0],
902        thickness: match extrusion {
903            RibExtrusion::ParallelToSketch => np,
904            RibExtrusion::NormalToSketch => np.cross(chord).normalized().or_refuse(KernelStage::Collect, "normalized")?,
905        },
906        chord,
907        growth: along,
908        quantum: 1e-7 * reach.max(1.0),
909    };
910    restamp_rib_faces(&mut fused, &sources, &names.feature, &frame);
911    crate::feature_pipeline::features::common::stamp_derived_edge_names(&mut fused);
912    Ok(fused)
913}
914
915/// Twice the part's bounding diagonal, measured from the chain too so a sketch
916/// standing off the part still sweeps across it.  The rib's real extent is decided
917/// by the cut in [`up_to_next`]; this only has to be generous.
918fn sweep_reach(solid: &BrepSolid, chain: &[Vec3]) -> Result<f64, KernelRefusal> {
919    let mut min = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
920    let mut max = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
921    let mut extend = |point: Vec3| {
922        min = Vec3::new(min.x.min(point.x), min.y.min(point.y), min.z.min(point.z));
923        max = Vec3::new(max.x.max(point.x), max.y.max(point.y), max.z.max(point.z));
924    };
925    for vertex in &solid.vertices {
926        extend(vertex.point);
927    }
928    for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
929        for row in &face.surface.control_points {
930            for point in row {
931                extend(point.point().or_refuse(KernelStage::Collect, "control_point")?);
932            }
933        }
934    }
935    for point in chain {
936        extend(*point);
937    }
938    let diagonal = max.sub(min).length();
939    if !(diagonal > 0.0) || !diagonal.is_finite() {
940        return Err(KernelRefusal::input(
941            KernelStage::Collect,
942            "target_extent",
943            "rib: the target solid has no extent to grow the rib against",
944        ));
945    }
946    Ok(diagonal * 2.0)
947}
948
949/// Points ON the chain, one per segment — where the rib starts, and so where the
950/// search for its free-space piece begins.
951///
952/// It has to be the chain ITSELF, not the average of its vertices: for a bent
953/// chain that average is off the chain entirely (an L's vertex centroid lands
954/// exactly on the thickened ribbon's inner corner, a knife-edge the classifier
955/// can only answer "on"), and a probe that starts on a boundary finds no piece.
956fn chain_probe_seeds(profile: &[NurbsCurve]) -> Result<Vec<Vec3>, KernelRefusal> {
957    let mut seeds = Vec::with_capacity(profile.len());
958    for curve in profile {
959        let [start, end] = curve.domain().or_refuse(KernelStage::Collect, "domain")?;
960        seeds.push(curve.evaluate(start + (end - start) * 0.5).or_refuse(KernelStage::Collect, "evaluate")?);
961    }
962    if seeds.is_empty() {
963        return Err(KernelRefusal::input(
964            KernelStage::Collect,
965            "profile_empty",
966            "rib: profile has no points to grow from",
967        ));
968    }
969    Ok(seeds)
970}
971
972/// **Parallel to Sketch**: the chain swept `reach` along the IN-PLANE direction
973/// `along` gives a closed region inside the sketch plane; that region, offset to
974/// −thickness/2 and extruded `thickness` along the plane normal, is the fin.
975///
976/// The loop must be simple, so a chain that doubles back across its own sweep
977/// self-intersects here and the extrude refuses — the documented V1 limit.
978fn parallel_slab(
979    profile: &[NurbsCurve],
980    vertices: &[Vec3],
981    np: Vec3,
982    along: Vec3,
983    thickness: f64,
984    reach: f64,
985) -> Result<BrepSolid, KernelRefusal> {
986    let offset = along.scale(reach);
987    let chain_start = vertices[0];
988    let chain_end = *vertices.last().expect("chain has vertices");
989    let mut region: Vec<NurbsCurve> = Vec::with_capacity(profile.len() * 2 + 2);
990    for curve in profile {
991        region.push(curve.clone());
992    }
993    region.push(make_line(chain_end, chain_end.add(offset)).or_refuse(KernelStage::Fragment, "make_line")?);
994    for curve in profile.iter().rev() {
995        region.push(super::extrude::translated_curve(&curve.reversed().or_refuse(KernelStage::Fragment, "reversed")?, offset)?);
996    }
997    region.push(make_line(chain_start.add(offset), chain_start).or_refuse(KernelStage::Fragment, "make_line")?);
998
999    // Centre the thickness on the sketch plane: start half a thickness under it
1000    // and extrude a full thickness back through.
1001    let base = region
1002        .iter()
1003        .map(|curve| super::extrude::translated_curve(curve, np.scale(-thickness * 0.5)))
1004        .collect::<Result<Vec<_>, KernelRefusal>>()?;
1005    extrude_profile_brep(&base, np, thickness).map_err(|error| {
1006        error.with_message(|error| {
1007            format!("rib: sweeping the profile inside its plane failed: {error}")
1008        })
1009    })
1010}
1011
1012/// **Normal to Sketch**: the chain thickened INSIDE its own plane (miter-offset
1013/// ±thickness/2, straight caps across the two open ends → a closed thin loop),
1014/// extruded `reach` along `along` (the plane normal).
1015fn normal_slab(
1016    profile: &[NurbsCurve],
1017    np: Vec3,
1018    along: Vec3,
1019    thickness: f64,
1020    reach: f64,
1021) -> Result<BrepSolid, KernelRefusal> {
1022    let half = thickness * 0.5;
1023    let left = offset_profile_segments(profile, np, half, false)
1024        .map_err(|error| error.with_message(|error| format!("rib: {error}")))?;
1025    let right = offset_profile_segments(profile, np, -half, false)
1026        .map_err(|error| error.with_message(|error| format!("rib: {error}")))?;
1027    let left_first = &left[0];
1028    let left_last = &left[left.len() - 1];
1029    let right_first = &right[0];
1030    let right_last = &right[right.len() - 1];
1031    let left_start = left_first.evaluate(left_first.domain().or_refuse(KernelStage::Fragment, "domain")?[0]).or_refuse(KernelStage::Fragment, "evaluate")?;
1032    let left_end = left_last.evaluate(left_last.domain().or_refuse(KernelStage::Fragment, "domain")?[1]).or_refuse(KernelStage::Fragment, "evaluate")?;
1033    let right_start = right_first.evaluate(right_first.domain().or_refuse(KernelStage::Fragment, "domain")?[0]).or_refuse(KernelStage::Fragment, "evaluate")?;
1034    let right_end = right_last.evaluate(right_last.domain().or_refuse(KernelStage::Fragment, "domain")?[1]).or_refuse(KernelStage::Fragment, "evaluate")?;
1035    let mut thin_loop: Vec<NurbsCurve> = Vec::with_capacity(left.len() + right.len() + 2);
1036    for curve in &left {
1037        thin_loop.push(curve.clone());
1038    }
1039    thin_loop.push(make_line(left_end, right_end).or_refuse(KernelStage::Fragment, "make_line")?);
1040    for curve in right.iter().rev() {
1041        thin_loop.push(curve.reversed().or_refuse(KernelStage::Fragment, "reversed")?);
1042    }
1043    thin_loop.push(make_line(right_start, left_start).or_refuse(KernelStage::Fragment, "make_line")?);
1044    extrude_profile_brep(&thin_loop, along, reach).map_err(|error| {
1045        error.with_message(|error| {
1046            format!("rib: extrude of the thickened profile failed: {error}")
1047        })
1048    })
1049}
1050
1051/// SolidWorks' **Up To Next**, exactly: cut the over-long `slab` by the part and
1052/// keep the piece the sketch grew into.
1053///
1054/// `slab − solid` leaves the sweep's free-space pieces, and the boolean assembler
1055/// already groups a disconnected result into ONE SHELL PER PIECE (it unions faces
1056/// by shared edges), so the pieces are the result's shells. The piece containing
1057/// the sketch is the rib; anything past the part is a different piece and is
1058/// dropped — that, not a bounding box, is what makes the rib stop at the part's
1059/// own faces whatever shape they are.
1060///
1061/// `Ok(None)` means the sweep was entirely inside existing material: there is
1062/// nothing to add. A piece still running at `reach` never landed on anything, and
1063/// that is SolidWorks' documented failure ("if any portion of the solid feature
1064/// generated does not hit a Next face it fails").
1065fn up_to_next(
1066    solid: &BrepSolid,
1067    slab: &BrepSolid,
1068    seeds: &[Vec3],
1069    along: Vec3,
1070    reach: f64,
1071) -> Result<Option<BrepSolid>, KernelRefusal> {
1072    let free = match boolean_operation(
1073        slab,
1074        solid,
1075        BooleanOperation::Subtract,
1076        &BooleanOptions::default(),
1077    ) {
1078        Ok(free) => free,
1079        // A subtract that refuses because the operands are disjoint means the
1080        // sweep never reached the part at all — the Up To Next failure, reported
1081        // as itself rather than as a boolean's internal complaint.
1082        Err(error) => {
1083            // ESSENTIAL REFUSAL (see the sibling below): the cut refusing because
1084            // the operands are disjoint IS "the rib met nothing". The cut's own
1085            // class rides through under the rib's text.
1086            return Err(error.with_message(|error| {
1087                format!(
1088                "rib: RIB_UP_TO_NEXT_UNBOUNDED — the rib never reaches the part, so it has \
1089                 nothing to stop against (SolidWorks' Up To Next requires every part of a rib \
1090                 to meet a face); check the rib's direction — the cut reported: {error}"
1091                )
1092            }))
1093        }
1094    };
1095    if free.shells.is_empty() {
1096        return Ok(None);
1097    }
1098
1099    // Where the rib actually begins: the first point along the sweep from each
1100    // seed that is NOT already material. The chain can be drawn inside a wall, and
1101    // the rib is then the free space just beyond it.
1102    let classifier = SolidClassifier::new(solid, 1e-6).or_refuse(KernelStage::Select, "classifier")?;
1103    let steps = 64;
1104    let mut probes = Vec::new();
1105    for seed in seeds {
1106        for step in 1..=steps {
1107            let point = seed.add(along.scale(reach * step as f64 / steps as f64 * 0.5));
1108            if classifier.classify(point).or_refuse(KernelStage::Select, "classify")?.class == PointClass::Out {
1109                probes.push(point);
1110                break;
1111            }
1112        }
1113    }
1114    if probes.is_empty() {
1115        // Every sample along the sweep sits inside the part: nothing to add.
1116        return Ok(None);
1117    }
1118
1119    let mut kept: Option<BrepSolid> = None;
1120    for shell in &free.shells {
1121        let piece = solid_from_shell(&free, shell);
1122        let grown_here = probes
1123            .iter()
1124            .map(|probe| classify_point(*probe, &piece, 1e-6))
1125            .collect::<Result<Vec<_>, String>>()
1126            .or_refuse(KernelStage::Select, "classify_point")?
1127            .into_iter()
1128            .any(|classification| classification.class == PointClass::In);
1129        if !grown_here {
1130            continue;
1131        }
1132        // SolidWorks' cardinal rule for Up To Next: a piece that is still going at
1133        // the end of the sweep never met a face.
1134        let overrun = piece
1135            .vertices
1136            .iter()
1137            .map(|vertex| vertex.point.sub(seeds[0]).dot(along))
1138            .fold(f64::NEG_INFINITY, f64::max);
1139        if overrun >= reach * 0.99 {
1140            // ESSENTIAL REFUSAL — do not "open" this one. Unlike a gate that
1141            // refuses data the machinery could already answer, this is the
1142            // feature's DEFINITION: SolidWorks' rib has one end condition, and
1143            // "if any portion of the solid feature generated does not hit a Next
1144            // face it fails" is the rule, not a limitation of ours. Accepting it
1145            // would ship a rib hanging in space with a far end at an arbitrary
1146            // sweep distance, which is exactly the bug this feature was reported
1147            // for. The right repair is always the rib's DIRECTION, never this
1148            // check.
1149            return Err(KernelRefusal::input(
1150                KernelStage::Select,
1151                "up_to_next_unbounded",
1152                "rib: RIB_UP_TO_NEXT_UNBOUNDED — part of the rib never lands on the part, so \
1153                 it has no face to stop against (SolidWorks' Up To Next requires the whole rib \
1154                 to terminate on a face). Turn the rib around with `direction`, or move the \
1155                 profile so its sweep meets the part",
1156            ));
1157        }
1158        kept = Some(match kept {
1159            None => piece,
1160            Some(previous) => boolean_operation(
1161                &previous,
1162                &piece,
1163                BooleanOperation::Union,
1164                &BooleanOptions::default(),
1165            )
1166            .map_err(|error| {
1167                error.with_message(|error| {
1168                    format!("rib: joining the rib's own pieces failed: {error}")
1169                })
1170            })?,
1171        });
1172    }
1173    Ok(kept)
1174}
1175
1176/// One shell of `source` as a solid in its own right, carrying only the edges and
1177/// vertices its faces use — how a disconnected boolean result is taken apart into
1178/// the pieces the assembler already separated.
1179fn solid_from_shell(source: &BrepSolid, shell: &ShellRecord) -> BrepSolid {
1180    let edge_ids: std::collections::HashSet<u64> = shell
1181        .faces
1182        .iter()
1183        .flat_map(|face| &face.loops)
1184        .flat_map(|loop_record| &loop_record.coedges)
1185        .map(|coedge| coedge.edge_id)
1186        .collect();
1187    let edges: Vec<EdgeRecord> = source
1188        .edges
1189        .iter()
1190        .filter(|edge| edge_ids.contains(&edge.id))
1191        .cloned()
1192        .collect();
1193    let vertex_ids: std::collections::HashSet<u64> = edges
1194        .iter()
1195        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
1196        .collect();
1197    BrepSolid {
1198        mass_properties_cache: Default::default(),
1199        id: source.id,
1200        vertices: source
1201            .vertices
1202            .iter()
1203            .filter(|vertex| vertex_ids.contains(&vertex.id))
1204            .cloned()
1205            .collect(),
1206        edges,
1207        shells: vec![shell.clone()],
1208        genus: 0,
1209        kind: crate::BodyKind::Solid,
1210    }
1211}
1212
1213// ===========================================================================
1214// Face names
1215// ===========================================================================
1216
1217/// What a rib calls its faces: the feature id every name starts with, and the
1218/// source name of each profile curve (`{sketchId}:G{gid}` for a sketch segment),
1219/// index-aligned with the profile.
1220///
1221/// | face | Parallel to Sketch | Normal to Sketch |
1222/// |---|---|---|
1223/// | the rib's two walls | `{id}:A` (−thickness axis), `{id}:B` (+) | `{id}:{segment}_A`, `{id}:{segment}_B` per segment |
1224/// | the rib's top (on the sketch) | `{id}:{segment}_TOP` per segment | `{id}:TOP` |
1225/// | its ends, at the chain's start and end | `{id}:START`, `{id}:END` | `{id}:START`, `{id}:END` |
1226/// | the far end (cut away by Up To Next) | `{id}:{segment}_FAR` | `{id}:FAR` |
1227///
1228/// The thickness axis is the sketch plane's authored normal for a Parallel rib,
1229/// and for a Normal rib the in-plane normal `plane normal × segment direction` —
1230/// the left of the chain seen from the side the normal points to.
1231///
1232/// A face the rib SPLITS is named by [`restamp_rib_faces`]: one fragment keeps
1233/// the name and the others are named after the rib and the side they are on.
1234#[derive(Clone, Debug, PartialEq, Eq)]
1235pub struct RibNames {
1236    pub feature: String,
1237    pub segments: Vec<String>,
1238}
1239
1240impl RibNames {
1241    /// Names for a caller with no segment identity to offer: `SEG{i}`.
1242    pub fn positional(feature: &str, count: usize) -> Self {
1243        Self {
1244            feature: feature.to_string(),
1245            segments: (0..count).map(|index| format!("SEG{index}")).collect(),
1246        }
1247    }
1248}
1249
1250/// The slab's face names in `extrude_profile_brep`'s face order: the region's
1251/// sides in input order, then the base cap, then the far cap.
1252///
1253/// Parallel region: `[segments…, end line, far segments reversed…, start line]`,
1254/// base cap at −thickness/2 along the normal. Normal region: `[+offset
1255/// segments…, end cap, −offset segments reversed…, start cap]`, base cap on the
1256/// sketch plane.
1257fn slab_face_names(names: &RibNames, extrusion: RibExtrusion, count: usize) -> Vec<String> {
1258    let id = &names.feature;
1259    let segment = |index: usize, role: &str| format!("{id}:{}_{role}", names.segments[index]);
1260    let mut faces = Vec::with_capacity(2 * count + 4);
1261    let (near, far, base, top) = match extrusion {
1262        RibExtrusion::ParallelToSketch => ("TOP", "FAR", format!("{id}:A"), format!("{id}:B")),
1263        RibExtrusion::NormalToSketch => ("B", "A", format!("{id}:TOP"), format!("{id}:FAR")),
1264    };
1265    faces.extend((0..count).map(|index| segment(index, near)));
1266    faces.push(format!("{id}:END"));
1267    faces.extend((0..count).rev().map(|index| segment(index, far)));
1268    faces.push(format!("{id}:START"));
1269    faces.push(base);
1270    faces.push(top);
1271    faces
1272}
1273
1274/// Leads every source token, so no authored or derived name can spell one.
1275const SOURCE_TOKEN: char = '\u{1}';
1276const TARGET_TOKEN: char = 'T';
1277const RIB_TOKEN: char = 'R';
1278
1279/// A copy of `solid` whose faces carry `{SOURCE_TOKEN}{kind}{index}` instead of
1280/// their names, plus the names, index-aligned.
1281fn tokenised(solid: &BrepSolid, kind: char) -> (BrepSolid, Vec<Option<String>>) {
1282    let mut copy = solid.clone();
1283    let mut names = Vec::new();
1284    for face in copy.shells.iter_mut().flat_map(|shell| shell.faces.iter_mut()) {
1285        names.push(face.name.take());
1286        face.name = Some(format!("{SOURCE_TOKEN}{kind}{}", names.len() - 1));
1287    }
1288    (copy, names)
1289}
1290
1291/// Where each token points: the part's own face names and the slab's roles.
1292struct FaceSources {
1293    part: Vec<Option<String>>,
1294    rib: Vec<String>,
1295}
1296
1297impl FaceSources {
1298    /// `(kind, index)` of a token-named face. The booleans append `_n` to the
1299    /// later fragments of a split face; the digits after the kind end the index.
1300    fn parse(name: &str) -> Option<(char, usize)> {
1301        let mut chars = name.strip_prefix(SOURCE_TOKEN)?.chars();
1302        let kind = chars.next()?;
1303        let digits: String = chars.take_while(|c| c.is_ascii_digit()).collect();
1304        Some((kind, digits.parse().ok()?))
1305    }
1306
1307    fn name(&self, kind: char, index: usize) -> Option<&str> {
1308        match kind {
1309            TARGET_TOKEN => self.part.get(index)?.as_deref(),
1310            RIB_TOKEN => self.rib.get(index).map(String::as_str),
1311            _ => None,
1312        }
1313    }
1314
1315    /// A boolean's refusal text with every token put back as the name it stands for.
1316    fn detokenise(&self, text: &str) -> String {
1317        let mut out = String::with_capacity(text.len());
1318        let mut chars = text.chars().peekable();
1319        while let Some(c) = chars.next() {
1320            if c != SOURCE_TOKEN {
1321                out.push(c);
1322                continue;
1323            }
1324            let kind = chars.next();
1325            let mut digits = String::new();
1326            while let Some(digit) = chars.peek().copied().filter(char::is_ascii_digit) {
1327                digits.push(digit);
1328                chars.next();
1329            }
1330            match (kind, digits.parse::<usize>()) {
1331                (Some(kind), Ok(index)) => out.push_str(self.name(kind, index).unwrap_or("<unnamed>")),
1332                _ => {
1333                    out.extend(kind);
1334                    out.push_str(&digits);
1335                }
1336            }
1337        }
1338        out
1339    }
1340}
1341
1342/// The rib's own frame, which orders the fragments of a split face: the
1343/// thickness axis first (which SIDE of the rib a fragment is on), then along
1344/// the chord from the chain's start, then along the growth direction.
1345struct RibFrame {
1346    origin: Vec3,
1347    thickness: Vec3,
1348    chord: Vec3,
1349    growth: Vec3,
1350    /// Coordinates closer than this are equal (a fragment's position is
1351    /// recomputed by every rebuild; its last bits are not its identity).
1352    quantum: f64,
1353}
1354
1355/// Replace every source token on `fused` with the face's final name.
1356///
1357/// A source face that reaches the result as ONE face keeps its name. A face
1358/// that arrives in several fragments keeps its name on ONE of them and names
1359/// the rest after the rib, by position in the rib's own frame rather than by
1360/// the order the boolean met them or by their sizes (both change with edits
1361/// that have nothing to do with the rib — moving a hole from one half of a
1362/// floor to the other swaps which half is larger):
1363///
1364/// - a PART face split by the rib: its fragments are sorted by side (the rib's
1365///   A side, straddling, then its B side), then along the chord, then along the
1366///   growth direction. The first keeps the part's name; every other is
1367///   `{id}:{name}_{side}{k}`, `k` counting that side's fragments from 1 in the
1368///   same order — so a floor a rib crosses stays `Floor` on the A side and
1369///   becomes `{id}:Floor_B1` on the B side.
1370/// - one of the rib's OWN faces in several pieces (the part interrupts the rib):
1371///   along the chord, then the growth direction; the first keeps the name and
1372///   the others are `{name}_2`, `{name}_3`, …
1373///
1374/// A part face that had no name keeps none.
1375fn restamp_rib_faces(fused: &mut BrepSolid, sources: &FaceSources, feature: &str, frame: &RibFrame) {
1376    use std::collections::{BTreeMap, HashMap};
1377    let points: HashMap<u64, Vec3> = fused.vertices.iter().map(|vertex| (vertex.id, vertex.point)).collect();
1378    let ends: HashMap<u64, (u64, u64)> = fused
1379        .edges
1380        .iter()
1381        .map(|edge| (edge.id, (edge.start_vertex_id, edge.end_vertex_id)))
1382        .collect();
1383    let quantise = |value: f64| (value / frame.quantum).round() as i64;
1384    // (shell, face) positions per source, each with its sort key.
1385    let mut groups: BTreeMap<(char, usize), Vec<((i64, i64, i64), (usize, usize))>> = BTreeMap::new();
1386    for (shell_index, shell) in fused.shells.iter().enumerate() {
1387        for (face_index, face) in shell.faces.iter().enumerate() {
1388            let Some(source) = face.name.as_deref().and_then(FaceSources::parse) else {
1389                continue;
1390            };
1391            let mut ids: Vec<u64> = face
1392                .loops
1393                .iter()
1394                .flat_map(|loop_record| &loop_record.coedges)
1395                .filter_map(|coedge| ends.get(&coedge.edge_id))
1396                .flat_map(|(start, end)| [*start, *end])
1397                .collect();
1398            ids.sort_unstable();
1399            ids.dedup();
1400            let mut centre = Vec3::default();
1401            for id in &ids {
1402                if let Some(point) = points.get(id) {
1403                    centre = centre.add(*point);
1404                }
1405            }
1406            let centre = centre.scale(1.0 / ids.len().max(1) as f64).sub(frame.origin);
1407            let key = (
1408                quantise(centre.dot(frame.thickness)),
1409                quantise(centre.dot(frame.chord)),
1410                quantise(centre.dot(frame.growth)),
1411            );
1412            groups.entry(source).or_default().push((key, (shell_index, face_index)));
1413        }
1414    }
1415    for ((kind, index), mut fragments) in groups {
1416        let original = sources.name(kind, index).map(str::to_string);
1417        let side = |key: &(i64, i64, i64)| match key.0.signum() {
1418            -1 => ('A', 0),
1419            0 => ('M', 1),
1420            _ => ('B', 2),
1421        };
1422        let named: Vec<((usize, usize), Option<String>)> = match &original {
1423            None => fragments.into_iter().map(|(_, at)| (at, None)).collect(),
1424            Some(name) if fragments.len() == 1 => vec![(fragments[0].1, Some(name.clone()))],
1425            Some(name) if kind == RIB_TOKEN => {
1426                fragments.sort_by_key(|(key, _)| (key.1, key.2, key.0));
1427                fragments
1428                    .into_iter()
1429                    .enumerate()
1430                    .map(|(rank, (_, at))| {
1431                        (at, Some(if rank == 0 { name.clone() } else { format!("{name}_{}", rank + 1) }))
1432                    })
1433                    .collect()
1434            }
1435            Some(name) => {
1436                fragments.sort_by_key(|(key, _)| (side(key).1, key.1, key.2, key.0));
1437                let mut per_side: BTreeMap<char, usize> = BTreeMap::new();
1438                fragments
1439                    .into_iter()
1440                    .enumerate()
1441                    .map(|(rank, (key, at))| {
1442                        let letter = side(&key).0;
1443                        let ordinal = per_side.entry(letter).or_insert(0);
1444                        *ordinal += 1;
1445                        let spelled = if rank == 0 {
1446                            name.clone()
1447                        } else {
1448                            format!("{feature}:{name}_{letter}{ordinal}")
1449                        };
1450                        (at, Some(spelled))
1451                    })
1452                    .collect()
1453            }
1454        };
1455        for ((shell_index, face_index), name) in named {
1456            fused.shells[shell_index].faces[face_index].name = name;
1457        }
1458    }
1459}