Skip to main content

brep_kernel/construction/sweep_topology/
rib.rs

1use super::*;
2
3/// Rational ruled surface between two curves with IDENTICAL degree, knots and
4/// weight pattern (the drafted-arc rows share one `make_arc` window, so this
5/// yields the EXACT cone patch: each ruling blends radially-corresponding
6/// points at equal weights).
7pub(super) fn ruled_between(bottom: &NurbsCurve, top: &NurbsCurve) -> Result<NurbsSurface, String> {
8    if bottom.degree != top.degree
9        || bottom.control_points.len() != top.control_points.len()
10        || bottom.knots.len() != top.knots.len()
11    {
12        return Err("ruled_between: rows are not representation-compatible".into());
13    }
14    let grid = bottom
15        .control_points
16        .iter()
17        .zip(&top.control_points)
18        .map(|(a, b)| vec![*a, *b])
19        .collect();
20    NurbsSurface::new(
21        bottom.degree,
22        1,
23        bottom.knots.clone(),
24        vec![0.0, 0.0, 1.0, 1.0],
25        grid,
26    )
27}
28
29/// Subrange of a wall ROW curve between the projections of two junction
30/// points.  Splitting (instead of rebuilding with `make_arc`) preserves the
31/// row's parameterization exactly, so the edge is the surface's own boundary
32/// restriction and its parameter-line pcurve is pointwise exact.
33pub(super) fn arc_window_subrange(row: &NurbsCurve, start: Vec3, end: Vec3) -> Result<NurbsCurve, String> {
34    let [d0, d1] = row.domain()?;
35    let span = d1 - d0;
36    let u0 = project_point_to_curve(row, start)?.u;
37    let u1 = project_point_to_curve(row, end)?.u;
38    if u1 <= u0 + 1e-12 {
39        return Err("draftExtrude: a drafted arc's boundary trim inverted".into());
40    }
41    let epsilon = span * 1e-9;
42    let mut current = row.clone();
43    if u0 > d0 + epsilon {
44        current = current.split(u0)?.1;
45    }
46    let domain = current.domain()?;
47    if u1 < domain[1] - epsilon && u1 > domain[0] + epsilon {
48        current = current.split(u1)?.0;
49    }
50    Ok(current)
51}
52
53/// Gradient (unnormalized surface normal direction) of a drafted WALL's
54/// implicit surface at a point on it: for a line wall the tilted plane's
55/// normal; for an arc wall the cone's ∇(ρ − r(z)) = ρ̂ + (d·turn/h)·ẑ.  Exact
56/// closed forms — the junction-conic end tangents come from their cross
57/// products.
58fn wall_gradient(
59    seg: &SegGeom,
60    point: Vec3,
61    zh: Vec3,
62    height: f64,
63    signed_d: f64,
64) -> Result<Vec3, String> {
65    match seg {
66        SegGeom::Line { dir, normal, .. } => dir
67            .cross(normal.scale(signed_d).add(zh.scale(height)))
68            .normalized(),
69        SegGeom::Arc {
70            center, turn, ..
71        } => {
72            let rel = point.sub(*center);
73            let radial = rel.sub(zh.scale(rel.dot(zh)));
74            let rho = radial.normalized()?;
75            rho.add(zh.scale(signed_d * turn / height)).normalized()
76        }
77    }
78}
79
80/// The EXACT junction edge between two adjacent drafted walls, from bottom
81/// junction `a` to top junction `b` with mid-height witness `m` (all three are
82/// exact offset-primitive intersections).  Straight when `m` is collinear
83/// (plane∧plane miter edges, tangent-junction cone rulings); otherwise the
84/// exact CONIC through `a`/`b` with the walls' analytic gradient-cross end
85/// tangents, its rational-quadratic weight solved from `m` (a conic is
86/// uniquely determined by that data, and every drafted wall∧wall intersection
87/// IS a conic — both squared implicits shrink linearly at the same rate, so
88/// their difference is a plane).
89#[allow(clippy::too_many_arguments)]
90pub(super) fn junction_edge_curve(
91    prev: &SegGeom,
92    next: &SegGeom,
93    a: Vec3,
94    m: Vec3,
95    b: Vec3,
96    zh: Vec3,
97    height: f64,
98    signed_d: f64,
99) -> Result<NurbsCurve, String> {
100    let chord = b.sub(a);
101    let length = chord.length();
102    if length <= 1e-12 {
103        return Err("draftExtrude: a junction edge collapsed to a point".into());
104    }
105    let along = m.sub(a).dot(chord) / (length * length);
106    let deviation = m.sub(a).sub(chord.scale(along)).length();
107    if deviation <= length * 1e-9 {
108        return make_line(a, b);
109    }
110    // 2D frame in the conic's plane (it contains a, b, m by construction).
111    let e1 = chord.scale(1.0 / length);
112    let plane_normal = chord.cross(m.sub(a)).normalized()?;
113    let e2 = plane_normal.cross(e1).normalized()?;
114    let orient = |tangent: Vec3| {
115        if tangent.dot(zh) < 0.0 {
116            tangent.scale(-1.0)
117        } else {
118            tangent
119        }
120    };
121    let t0 = orient(wall_gradient(prev, a, zh, height, signed_d)?
122        .cross(wall_gradient(next, a, zh, height, signed_d)?));
123    let t2 = orient(wall_gradient(prev, b, zh, height, signed_d)?
124        .cross(wall_gradient(next, b, zh, height, signed_d)?));
125    let d0 = (t0.dot(e1), t0.dot(e2));
126    let d2 = (t2.dot(e1), t2.dot(e2));
127    let denom = d0.0 * d2.1 - d0.1 * d2.0;
128    let scale0 = d0.0.hypot(d0.1);
129    let scale2 = d2.0.hypot(d2.1);
130    if denom.abs() <= 1e-14 * scale0 * scale2 {
131        return Err("draftExtrude: junction end tangents are parallel — no conic apex".into());
132    }
133    // Apex: a + s·t0 = b + r·t2 solved in 2D (a = origin, b = (length, 0)).
134    let s = length * d2.1 / denom;
135    let apex = (s * d0.0, s * d0.1);
136    if apex.1.abs() <= f64::EPSILON * length {
137        return Err("draftExtrude: junction conic apex is degenerate".into());
138    }
139    // Barycentric coordinates of m over (a, apex, b): m = α·a + β·apex + γ·b.
140    let mq = (m.sub(a).dot(e1), m.sub(a).dot(e2));
141    let beta = mq.1 / apex.1;
142    let gamma = (mq.0 - beta * apex.0) / length;
143    let alpha = 1.0 - beta - gamma;
144    if !(alpha > 0.0 && beta > 0.0 && gamma > 0.0) {
145        return Err(format!(
146            "draftExtrude: junction conic witness fell outside its control triangle \
147             (α={alpha:.3e} β={beta:.3e} γ={gamma:.3e})"
148        ));
149    }
150    let weight = beta / (2.0 * (alpha * gamma).sqrt());
151    let apex_3d = a.add(e1.scale(apex.0)).add(e2.scale(apex.1));
152    NurbsCurve::new(
153        2,
154        vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
155        vec![
156            Vec4::from_point(a, 1.0),
157            Vec4::from_point(apex_3d, weight),
158            Vec4::from_point(b, 1.0),
159        ],
160    )
161}
162
163/// In-plane circumcircle of three coplanar points (projected onto `ex`/`ey`,
164/// `np = ex×ey`).  Returns `None` when the three points are collinear.
165fn circumcircle(a: Vec3, b: Vec3, c: Vec3, ex: Vec3, ey: Vec3, np: Vec3) -> Option<(Vec3, f64)> {
166    let (ax, ay) = (a.dot(ex), a.dot(ey));
167    let (bx, by) = (b.dot(ex), b.dot(ey));
168    let (cx, cy) = (c.dot(ex), c.dot(ey));
169    let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
170    if d.abs() < 1e-12 {
171        return None;
172    }
173    let a2 = ax * ax + ay * ay;
174    let b2 = bx * bx + by * by;
175    let c2 = cx * cx + cy * cy;
176    let ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d;
177    let uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d;
178    let plane_off = a.dot(np);
179    let center = ex.scale(ux).add(ey.scale(uy)).add(np.scale(plane_off));
180    let radius = a.sub(center).length();
181    Some((center, radius))
182}
183
184/// Intersect an offset LINE (through `line_point`, direction `line_dir`) with an
185/// offset CIRCLE (`center`, `radius`), returning the root nearest `near` (the
186/// original junction).  A clear Err when they no longer meet (offset too large).
187fn intersect_offset_line_circle(
188    line_point: Vec3,
189    line_dir: Vec3,
190    center: Vec3,
191    radius: f64,
192    near: Vec3,
193) -> Result<Vec3, String> {
194    let dir = line_dir.normalized()?;
195    let f = line_point.sub(center);
196    let b = f.dot(dir);
197    let c = f.dot(f) - radius * radius;
198    let disc = b * b - c;
199    if disc < -1e-9 {
200        return Err("an offset line and arc no longer meet (offset too large)".into());
201    }
202    let root = disc.max(0.0).sqrt();
203    let p1 = line_point.add(dir.scale(-b + root));
204    let p2 = line_point.add(dir.scale(-b - root));
205    Ok(if p1.sub(near).length() <= p2.sub(near).length() {
206        p1
207    } else {
208        p2
209    })
210}
211
212/// Intersect two offset CIRCLES (in the plane whose normal is `plane_normal`),
213/// returning the root nearest `near`.  A clear Err when they are concentric or no
214/// longer meet.
215fn intersect_offset_circles(
216    c1: Vec3,
217    r1: f64,
218    c2: Vec3,
219    r2: f64,
220    plane_normal: Vec3,
221    near: Vec3,
222) -> Result<Vec3, String> {
223    let between = c2.sub(c1);
224    let d = between.length();
225    if d < 1e-9 {
226        return Err("concentric offset arcs do not meet".into());
227    }
228    let axis = between.scale(1.0 / d);
229    let a = (d * d + r1 * r1 - r2 * r2) / (2.0 * d);
230    let h2 = r1 * r1 - a * a;
231    if h2 < -1e-9 {
232        return Err("offset arcs no longer meet (offset too large)".into());
233    }
234    let h = h2.max(0.0).sqrt();
235    let base = c1.add(axis.scale(a));
236    let perp = plane_normal.cross(axis).normalized()?;
237    let p1 = base.add(perp.scale(h));
238    let p2 = base.sub(perp.scale(h));
239    Ok(if p1.sub(near).length() <= p2.sub(near).length() {
240        p1
241    } else {
242        p2
243    })
244}
245
246/// Geometry class of one profile segment, shared by the draft-extrude builder
247/// and the in-plane offset engine: a straight LINE or a circular ARC in the
248/// plane with normal `plane_normal`.
249pub(super) enum SegGeom {
250    Line {
251        start: Vec3,
252        end: Vec3,
253        /// Unit chord direction.
254        dir: Vec3,
255        /// In-plane offset normal `plane_normal × dir` (inward on a CCW loop).
256        normal: Vec3,
257    },
258    Arc {
259        center: Vec3,
260        radius: f64,
261        /// +1 when the arc bends CCW about the plane normal (a convex arc on a
262        /// CCW loop, which SHRINKS under a positive inward offset), −1 when CW.
263        turn: f64,
264        /// ±plane_normal — the axis the arc sweeps CCW about.
265        arc_normal: Vec3,
266        start: Vec3,
267        end: Vec3,
268    },
269}
270
271impl SegGeom {
272    fn start(&self) -> Vec3 {
273        match self {
274            SegGeom::Line { start, .. } | SegGeom::Arc { start, .. } => *start,
275        }
276    }
277
278    fn end(&self) -> Vec3 {
279        match self {
280            SegGeom::Line { end, .. } | SegGeom::Arc { end, .. } => *end,
281        }
282    }
283
284    /// Naive offset image of a point ON this segment's primitive: lines
285    /// translate along their normal; arc points scale radially onto the
286    /// concentric offset circle (Err when a concave arc collapses).
287    fn offset_point(&self, point: Vec3, signed_d: f64) -> Result<Vec3, String> {
288        match self {
289            SegGeom::Line { normal, .. } => Ok(point.add(normal.scale(signed_d))),
290            SegGeom::Arc {
291                center,
292                radius,
293                turn,
294                ..
295            } => {
296                let r_offset = radius - signed_d * turn;
297                if r_offset <= 1e-6 {
298                    return Err("offset: distance is too large — a concave arc collapses".into());
299                }
300                Ok(center.add(point.sub(*center).scale(r_offset / radius)))
301            }
302        }
303    }
304}
305
306/// Classify every profile segment as a LINE (all interior samples on the
307/// chord) or a circular ARC (circumcircle through start/mid/end confirmed by
308/// on-circle samples), with its turning direction about `plane_normal`.
309/// Anything else is a clear Err — the offset/draft machinery is exact for
310/// lines and circles only.
311pub(super) fn classify_profile_segments(
312    profile: &[NurbsCurve],
313    plane_normal: Vec3,
314) -> Result<Vec<SegGeom>, String> {
315    let tol = 1e-6;
316    if profile.is_empty() {
317        return Err("offset: profile has no segments".into());
318    }
319    let np = plane_normal.normalized()?;
320    let ex = np.perpendicular()?;
321    let ey = np.cross(ex).normalized()?;
322    let mut segs = Vec::with_capacity(profile.len());
323    for curve in profile {
324        let [t0, t1] = curve.domain()?;
325        let start = curve.evaluate(t0)?;
326        let end = curve.evaluate(t1)?;
327        let chord = end.sub(start);
328        let chord_len = chord.length();
329        if chord_len <= tol {
330            return Err("offset: profile has a degenerate (zero-length) segment".into());
331        }
332        let dir = chord.scale(1.0 / chord_len);
333        // A straight LINE if every interior sample lies on the chord.
334        let mut is_line = true;
335        for k in 1..8 {
336            let point = curve.evaluate(t0 + (t1 - t0) * k as f64 / 8.0)?;
337            let rel = point.sub(start);
338            let perpendicular = rel.sub(dir.scale(rel.dot(dir))).length();
339            if perpendicular > tol * 10.0 {
340                is_line = false;
341                break;
342            }
343        }
344        if is_line {
345            segs.push(SegGeom::Line {
346                start,
347                end,
348                dir,
349                normal: np.cross(dir).normalized()?,
350            });
351            continue;
352        }
353        // Otherwise it must be a circular ARC: fit a circle through start/mid/end.
354        let mid = curve.evaluate((t0 + t1) * 0.5)?;
355        let (center, radius) = circumcircle(start, mid, end, ex, ey, np).ok_or_else(|| {
356            "offset: only straight lines and circular arcs are supported".to_string()
357        })?;
358        for k in 0..=8 {
359            let point = curve.evaluate(t0 + (t1 - t0) * k as f64 / 8.0)?;
360            if (point.sub(center).length() - radius).abs() > tol * 10.0 {
361                return Err("offset: only straight lines and circular arcs are supported".into());
362            }
363        }
364        // Turning direction about the plane normal (CCW ⇒ +1 ⇒ shrink inward).
365        let bend = np.dot(mid.sub(start).cross(end.sub(mid)));
366        let (arc_normal, turn) = if bend >= 0.0 {
367            (np, 1.0)
368        } else {
369            (np.scale(-1.0), -1.0)
370        };
371        segs.push(SegGeom::Arc {
372            center,
373            radius,
374            turn,
375            arc_normal,
376            start,
377            end,
378        });
379    }
380    Ok(segs)
381}
382
383/// The junction between two consecutive segments' OFFSET primitives at signed
384/// in-plane distance `signed_d`:
385///   • offset 0 → the original shared vertex, exactly;
386///   • a TANGENT (G1) junction degenerates to the shared offset point
387///     (coincident-naive-offsets fast path);
388///   • line∧line → the exact miter V' = V + signed_d/(1+nₐ·n_b)·(nₐ+n_b);
389///   • line∧arc  → the offset line ∩ the offset circle, root nearest V;
390///   • arc∧arc   → the two offset circles ∩, root nearest V.
391/// Offsets that no longer meet (too large for the local feature) are a clear
392/// Err.
393pub(super) fn offset_junction(
394    prev: &SegGeom,
395    next: &SegGeom,
396    plane_normal: Vec3,
397    signed_d: f64,
398) -> Result<Vec3, String> {
399    let vertex = prev.end();
400    if signed_d == 0.0 {
401        return Ok(vertex);
402    }
403    let prev_offset = prev.offset_point(prev.end(), signed_d)?;
404    let next_offset = next.offset_point(next.start(), signed_d)?;
405    if prev_offset.sub(next_offset).length() <= 1e-6 {
406        return Ok(prev_offset.add(next_offset).scale(0.5));
407    }
408    match (prev, next) {
409        (SegGeom::Line { normal: na, .. }, SegGeom::Line { normal: nb, .. }) => {
410            let denom = 1.0 + na.dot(*nb);
411            if denom.abs() < 1e-6 {
412                return Err("offset: degenerate (near-reversal) polyline corner".into());
413            }
414            Ok(vertex.add(na.add(*nb).scale(signed_d / denom)))
415        }
416        (
417            SegGeom::Line { dir, .. },
418            SegGeom::Arc {
419                center,
420                radius,
421                turn,
422                ..
423            },
424        ) => intersect_offset_line_circle(
425            prev_offset,
426            *dir,
427            *center,
428            radius - signed_d * turn,
429            vertex,
430        ),
431        (
432            SegGeom::Arc {
433                center,
434                radius,
435                turn,
436                ..
437            },
438            SegGeom::Line { dir, .. },
439        ) => intersect_offset_line_circle(
440            next_offset,
441            *dir,
442            *center,
443            radius - signed_d * turn,
444            vertex,
445        ),
446        (
447            SegGeom::Arc {
448                center: c1,
449                radius: r1,
450                turn: turn1,
451                ..
452            },
453            SegGeom::Arc {
454                center: c2,
455                radius: r2,
456                turn: turn2,
457                ..
458            },
459        ) => intersect_offset_circles(
460            *c1,
461            r1 - signed_d * turn1,
462            *c2,
463            r2 - signed_d * turn2,
464            plane_normal,
465            vertex,
466        ),
467    }
468}
469
470/// Offset a planar profile CHAIN (a sequence of LINE and circular-ARC segments)
471/// IN-PLANE by the SIGNED distance `signed_d` along the per-segment offset normal
472/// n = (plane_normal × tangent).normalized().  Lines move to a parallel line;
473/// circular arcs move to a CONCENTRIC arc (radius r' = r − signed_d·turn — a
474/// convex-outward arc shrinks, a convex-inward arc grows).  Consecutive offset
475/// segments are re-joined at their [`offset_junction`].  `closed` treats the
476/// chain as a loop (every junction re-joined); an OPEN chain leaves its two end
477/// offsets un-joined.  Returns one reconstructed NurbsCurve per input segment
478/// (make_line / make_arc).  A self-intersecting offset (signed_d too large for
479/// a concave corner or arc) surfaces as a clear Err.
480fn offset_profile_segments(
481    profile: &[NurbsCurve],
482    plane_normal: Vec3,
483    signed_d: f64,
484    closed: bool,
485) -> Result<Vec<NurbsCurve>, String> {
486    let tol = 1e-6;
487    let np = plane_normal.normalized()?;
488    let segs = classify_profile_segments(profile, np)?;
489    let n = segs.len();
490
491    // Naive per-segment offsets, then re-join consecutive ones exactly.
492    let mut offsets: Vec<(Vec3, Vec3)> = segs
493        .iter()
494        .map(|seg| {
495            Ok((
496                seg.offset_point(seg.start(), signed_d)?,
497                seg.offset_point(seg.end(), signed_d)?,
498            ))
499        })
500        .collect::<Result<_, String>>()?;
501    let junctions = if closed { n } else { n.saturating_sub(1) };
502    for i in 0..junctions {
503        let j = (i + 1) % n;
504        let point = offset_junction(&segs[i], &segs[j], np, signed_d)?;
505        offsets[i].1 = point;
506        offsets[j].0 = point;
507    }
508
509    // --- Reconstruct each offset segment as a NurbsCurve.
510    let mut out = Vec::with_capacity(n);
511    for (seg, (off_start, off_end)) in segs.iter().zip(&offsets) {
512        match seg {
513            SegGeom::Line { .. } => out.push(make_line(*off_start, *off_end)?),
514            SegGeom::Arc {
515                center, arc_normal, ..
516            } => {
517                let radial = off_start.sub(*center);
518                let r2 = radial.length();
519                if r2 <= tol {
520                    return Err("offset: reconstructed arc has a zero radius".into());
521                }
522                let ax = radial.scale(1.0 / r2);
523                let ay = arc_normal.cross(ax).normalized()?;
524                let ve = off_end.sub(*center);
525                let mut angle = ve.dot(ay).atan2(ve.dot(ax));
526                if angle <= 1e-9 {
527                    angle += std::f64::consts::TAU;
528                }
529                out.push(make_arc(*center, ax, ay, r2, 0.0, angle)?);
530            }
531        }
532    }
533    Ok(out)
534}
535
536/// Rib / stiffener (§6.6).  Auto-THICKENs an OPEN planar polyline `profile`
537/// (miter-offset each side by ±thickness/2 + straight caps across the two open
538/// ENDS → a CLOSED thin loop), extrudes that loop by `depth` along
539/// `extrude_dir`, then UNIONs the thin slab into `solid` — the union trims the
540/// rib against the part walls automatically.  `extrude_dir` is typically −np
541/// (down into the part) or as given.  V1 SCOPE: POLYLINE profiles only —
542/// arcs/curves and fully collinear chains return a clear Err (documented
543/// follow-ups).  If the rib misses the solid entirely the boolean's Err is
544/// returned.
545pub fn rib_from_profile(
546    solid: &BrepSolid,
547    profile: &[NurbsCurve],
548    thickness: f64,
549    extrude_dir: Vec3,
550    depth: f64,
551    name: Option<&str>,
552) -> Result<BrepSolid, String> {
553    // The union carries face names from its operands; accept `name` for ABI
554    // symmetry with the other builders (the app stamps names post-hoc).
555    let _ = name;
556    let tolerance = 1e-6;
557    if profile.is_empty() {
558        return Err("rib: profile needs at least 1 curve forming an open chain".into());
559    }
560    if !(thickness > 0.0) {
561        return Err("rib: thickness must be positive".into());
562    }
563    if !(depth > 0.0) {
564        return Err("rib: depth must be positive".into());
565    }
566    let extrude_axis = extrude_dir
567        .normalized()
568        .map_err(|_| "rib: extrude direction is degenerate".to_string())?;
569
570    // --- 1. Extract the ordered chain vertices (segment endpoints) and verify the
571    //        chain is connected end→start.  Segments may be LINES or circular ARCS.
572    let mut vertices = Vec::with_capacity(profile.len() + 1);
573    let mut samples = Vec::new();
574    for (index, curve) in profile.iter().enumerate() {
575        let [start, end] = curve.domain()?;
576        let v_start = curve.evaluate(start)?;
577        let v_end = curve.evaluate(end)?;
578        if v_end.sub(v_start).length() <= tolerance {
579            return Err("rib: profile has a degenerate (zero-length) segment".into());
580        }
581        if index == 0 {
582            vertices.push(v_start);
583        } else if v_start.sub(*vertices.last().unwrap()).length() > tolerance {
584            return Err(format!(
585                "rib: profile chain is not connected at curve {index}"
586            ));
587        }
588        vertices.push(v_end);
589        // Skip k = 0 after the first segment: it duplicates the previous
590        // segment's endpoint, which would otherwise inject a zero-length step
591        // and cancel the corner bend used to derive the plane normal.
592        let first_k = if index == 0 { 0 } else { 1 };
593        for k in first_k..=8 {
594            samples.push(curve.evaluate(start + (end - start) * k as f64 / 8.0)?);
595        }
596    }
597    let count = vertices.len();
598    if count < 2 {
599        return Err("rib: profile needs at least 2 distinct vertices".into());
600    }
601
602    // --- 2. A rib thickens an OPEN profile; a closed loop is an ordinary
603    //        extrude, not a rib.
604    if vertices[count - 1].sub(vertices[0]).length() <= tolerance {
605        return Err("rib: profile chain is closed; rib expects an open chain".into());
606    }
607
608    // --- 3. Derive the profile plane normal np from the sampled chain's bends
609    //        (robust for arc segments) and verify planarity.  A fully collinear
610    //        chain has no unique plane (documented follow-up).
611    let mut normal = Vec3::default();
612    for i in 1..samples.len() - 1 {
613        let a = samples[i].sub(samples[i - 1]);
614        let b = samples[i + 1].sub(samples[i]);
615        normal = normal.add(a.cross(b));
616    }
617    let np = normal.normalized().map_err(|_| {
618        "rib: profile is collinear; cannot determine its plane (documented follow-up)".to_string()
619    })?;
620    let origin = vertices[0];
621    if samples
622        .iter()
623        .any(|point| point.sub(origin).dot(np).abs() > tolerance * 100.0)
624    {
625        return Err("rib: profile is not planar".into());
626    }
627
628    // --- 4. THICKEN via the shared segment offset: offset each side by
629    //        ±thickness/2 (lines parallel, arcs concentric), then close the two
630    //        open ENDS with straight caps → a CLOSED thin loop.  A pure polyline
631    //        reproduces the previous miter thickening unchanged.
632    let half = thickness * 0.5;
633    let left = offset_profile_segments(profile, np, half, false)
634        .map_err(|error| format!("rib: {error}"))?;
635    let right = offset_profile_segments(profile, np, -half, false)
636        .map_err(|error| format!("rib: {error}"))?;
637    let left_first = &left[0];
638    let left_last = &left[left.len() - 1];
639    let right_first = &right[0];
640    let right_last = &right[right.len() - 1];
641    let left_start = left_first.evaluate(left_first.domain()?[0])?;
642    let left_end = left_last.evaluate(left_last.domain()?[1])?;
643    let right_start = right_first.evaluate(right_first.domain()?[0])?;
644    let right_end = right_last.evaluate(right_last.domain()?[1])?;
645    let mut thin_loop: Vec<NurbsCurve> = Vec::with_capacity(left.len() + right.len() + 2);
646    for curve in &left {
647        thin_loop.push(curve.clone());
648    }
649    thin_loop.push(make_line(left_end, right_end)?);
650    for curve in right.iter().rev() {
651        thin_loop.push(curve.reversed()?);
652    }
653    thin_loop.push(make_line(right_start, left_start)?);
654
655    // --- 5. Extrude the thin loop into the rib slab, then UNION into the part.
656    let rib_body = extrude_profile_brep(&thin_loop, extrude_axis, depth)
657        .map_err(|error| format!("rib: extrude of the thickened profile failed: {error}"))?;
658    boolean_operation(
659        solid,
660        &rib_body,
661        BooleanOperation::Union,
662        &BooleanOptions::default(),
663    )
664    .map_err(|error| format!("rib: union of the rib into the part failed: {error}"))
665}