Skip to main content

brep_kernel/offset/
thicken.rs

1//! §5.9 Sheet → solid THICKEN (Golovanov): turn an open surface patch —
2//! either its full parameter rectangle or a region TRIMMED by pcurve loops —
3//! into a slab solid bounded by the sheet offset on one or both sides plus
4//! ruled side walls along every boundary pcurve.
5//!
6//! The offset carriers come from `offset_surface` (§3.15 equidistant
7//! surface), which preserves the source degree / knots / weights — planes
8//! stay exact planes and cylinders / spheres / tori stay exact rational
9//! carriers.  Because the two sheets share one basis, each side wall is the
10//! HOMOGENEOUS ruling between corresponding boundary curves: with equal
11//! per-column weights the ruling evaluates to the pointwise segment
12//! (1−w)·B(s) + w·T(s), so wall pcurves are exact parameter lines, a planar
13//! rectangle thickens to an EXACT box, and a circular hole in a planar sheet
14//! grows an EXACT cylindrical tube.
15//!
16//! Trim loops follow the FaceRecord convention (`validate_uv_wire`): the
17//! outer loop runs counter-clockwise in (u, v) and hole loops run clockwise,
18//! exactly as they would appear on a `same_sense = true` face.  Each hole
19//! adds one handle: the result of thickening a sheet with h holes is a
20//! genus-h solid, and the Euler check V − E + F − H = 2(1 − genus) closes.
21//!
22//! Where the equidistant surface would degenerate over the SELECTED REGION —
23//! a concave principal curvature radius smaller than the offset distance folds
24//! the offset through its evolute — the builder refuses with an honest `Err`
25//! instead of assembling silent garbage.  The question is asked about the
26//! trimmed region and not about the whole carrier: a cone whose apex, or a
27//! torus whose inner equator, lies outside the selected trim offsets perfectly
28//! well over the sheet actually being thickened.
29
30use crate::offset_carve::{carve_folded_trim, dropped_sweep as carve_dropped_sweep};
31use crate::offset_regularity::{scan_offset_regularity, ScanBudget, TrimRegion};
32use crate::sweep_topology::parameter_line;
33use crate::topology::{
34    BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord, ShellRecord, VertexRecord,
35};
36use crate::image_curve::{affine_image_curve, image_curve_pair};
37use crate::{make_arc, make_line, NurbsCurve, NurbsSurface, Vec3};
38
39#[path = "thicken/band.rs"]
40pub(crate) mod band;
41
42/// The fold factor at or below which the equidistant sheet has collapsed.  The
43/// offset-shell carrier lane refuses at the same number on the same condition
44/// (`offset/offset_shell/carriers.rs`), which is why both read it through the
45/// shared scan rather than each keeping a copy of the arithmetic.  The free-form
46/// push reads this one (`edit/direct_edit/face_offset_freeform.rs`).
47pub(crate) const FOLD_FACTOR: f64 = 1e-6;
48
49/// Refuse offsets that fold through the sheet's evolute INSIDE THE SELECTED
50/// TRIM: over the trimmed region, and for every requested signed offset distance
51/// d, the per-direction area factor 1 − d·κ must stay positive, or the
52/// equidistant surface self-intersects there (concave curvature radius ≤ offset
53/// distance).
54///
55/// The region, not the rectangle.  A carrier that folds where the trim does not
56/// reach — a cone whose apex lies outside the selected window, a torus whose
57/// inner equator does — offsets perfectly well over the sheet actually being
58/// thickened, and used to be refused because the gate swept the whole parameter
59/// domain.  A fold that IS inside the trim must not be hidden by sparse
60/// sampling either, which is why the sampling density comes from the trim's own
61/// knot spans and the offset distance rather than from a fixed 33 × 33 grid; see
62/// [`crate::offset_regularity`].
63fn ensure_offsets_regular(
64    surface: &NurbsSurface,
65    loops: &[Vec<NurbsCurve>],
66    distances: &[f64],
67) -> Result<(), String> {
68    let region = TrimRegion::from_pcurve_loops(surface, loops)
69        .map_err(|error| format!("thickenSheet: {error}"))?;
70    let scan = scan_offset_regularity(surface, &region, distances, FOLD_FACTOR, ScanBudget::SHEET)
71        .map_err(|error| format!("thickenSheet: {error}"))?;
72    let Some(worst) = scan.worst.filter(|worst| worst.factor <= FOLD_FACTOR) else {
73        return Ok(());
74    };
75    let where_3d = match surface.evaluate(worst.u, worst.v) {
76        Ok(point) => format!(
77            " — 3D ({:.6}, {:.6}, {:.6})",
78            point.x, point.y, point.z
79        ),
80        Err(_) => String::new(),
81    };
82    Err(format!(
83        "thickenSheet: offset by {:.6} self-intersects — inside the selected trim the sheet's \
84         concave curvature radius {:.6} at (u={:.6}, v={:.6}){} is not larger than the offset \
85         distance (fold factor {:.3e})",
86        worst.displacement,
87        worst.radius(),
88        worst.u,
89        worst.v,
90        where_3d,
91        worst.factor
92    ))
93}
94
95/// Parameter-space validation of the trim loops: closure, degeneracy, pinches
96/// and winding.
97///
98/// Split out of the construction pass so it runs BEFORE the regularity gate,
99/// which now reads the loops as a REGION: a loop that does not close has no
100/// region to judge, and its own refusal is the one worth reporting.
101fn validate_trim_loops(
102    loops: &[Vec<NurbsCurve>],
103    uv_tolerance: f64,
104    minimum_area: f64,
105) -> Result<(), String> {
106    for (loop_index, loop_curves) in loops.iter().enumerate() {
107        let count = loop_curves.len();
108        let mut starts = Vec::with_capacity(count);
109        let mut ends = Vec::with_capacity(count);
110        for curve in loop_curves {
111            let [q0, q1] = curve.domain()?;
112            starts.push(curve.evaluate(q0)?);
113            ends.push(curve.evaluate(q1)?);
114        }
115        for index in 0..count {
116            let next_index = (index + 1) % count;
117            let gap = planar_gap(ends[index], starts[next_index]);
118            if gap > uv_tolerance {
119                return Err(format!(
120                    "thickenSheet: loop {loop_index} is open — pcurve {index} ends at \
121                     (u={:.6}, v={:.6}) but pcurve {next_index} starts at (u={:.6}, v={:.6}) \
122                     (parameter-space gap {gap:.3e})",
123                    ends[index].x, ends[index].y, starts[next_index].x, starts[next_index].y
124                ));
125            }
126        }
127        if count == 1 {
128            let [q0, q1] = loop_curves[0].domain()?;
129            let middle = loop_curves[0].evaluate((q0 + q1) * 0.5)?;
130            if planar_gap(middle, starts[0]) <= uv_tolerance {
131                return Err(format!(
132                    "thickenSheet: loop {loop_index} is degenerate (zero parameter-space extent)"
133                ));
134            }
135        } else {
136            for index in 0..count {
137                if planar_gap(ends[index], starts[index]) <= uv_tolerance {
138                    return Err(format!(
139                        "thickenSheet: pcurve {index} of loop {loop_index} closes on itself \
140                         inside a multi-curve loop (pinched loop)"
141                    ));
142                }
143            }
144        }
145        let mut area = 0.0;
146        for curve in loop_curves {
147            area += pcurve_signed_area(curve)?;
148        }
149        if loop_index == 0 {
150            if area <= minimum_area {
151                return Err(format!(
152                    "thickenSheet: outer loop must run counter-clockwise in (u, v) \
153                     (signed area {area:.3e})"
154                ));
155            }
156        } else if area >= -minimum_area {
157            return Err(format!(
158                "thickenSheet: hole loop {loop_index} must run clockwise in (u, v) \
159                 (signed area {area:.3e})"
160            ));
161        }
162    }
163    Ok(())
164}
165
166/// Equidistant sheet moved `distance` along the parametrization normal
167/// n = Su × Sv (positive = +n side).  `offset_surface`'s positive distance
168/// moves OPPOSITE the face normal, hence the negation.
169///
170/// A RE-PARAMETERIZED carrier is refused rather than used.  Everything this
171/// builder does downstream rests on one identity: the offset shares the sheet's
172/// basis, so the SAME `(u, v)` names the pointwise offset of the same source
173/// point and the trim pcurves transfer verbatim.  `offset_surface`'s apex-cone
174/// pinch retrim deliberately breaks that identity — it pulls an inverted sample
175/// row back to the offset's own apex so the cavity ends in a proper degenerate
176/// row — and with the extension inactive (as it always is here) that retrim is
177/// the only thing that can report [`OffsetSurfaceLane::Reparameterised`].
178///
179/// The sheet's own TRIM is what decides whether that retrim fires at all, and
180/// this is where it is handed over: the carrier is built as a face carrying the
181/// selected loops, so `offset_surface` can ask whether the trim reaches past the
182/// pinch before it re-parameterizes anything.  A sheet whose carrier folds
183/// OUTSIDE its trim used to be re-parameterized anyway and shipped a sheared
184/// slab that validated while being the wrong solid; it now keeps the pointwise
185/// offset, and the refusal below is left standing for the case the retrim really
186/// is about — a trim that DOES reach the pinch and would carry a sheared
187/// correspondence into the walls.
188fn offset_sheet(
189    surface: &NurbsSurface,
190    loops: &[Vec<NurbsCurve>],
191    distance: f64,
192) -> Result<NurbsSurface, String> {
193    if distance == 0.0 {
194        return Ok(surface.clone());
195    }
196    let carrier = FaceRecord {
197        id: 1,
198        surface: surface.clone(),
199        same_sense: true,
200        loops: loops
201            .iter()
202            .enumerate()
203            .map(|(loop_index, loop_curves)| LoopRecord {
204                id: loop_index as u64 + 1,
205                coedges: loop_curves
206                    .iter()
207                    .enumerate()
208                    .map(|(index, pcurve)| CoedgeRecord {
209                        // A carrier built only to be measured: nothing reads
210                        // these ids or this edge, only the pcurves, which are
211                        // the trim `offset_surface` has to see.
212                        id: index as u64 + 1,
213                        edge_id: index as u64 + 1,
214                        forward: true,
215                        pcurve: pcurve.clone(),
216                    })
217                    .collect(),
218            })
219            .collect(),
220        name: None,
221    };
222    let (offset, lane) = crate::offset::offset_surface_with_lane(
223        &carrier,
224        -distance,
225        &crate::CarrierExtension::uniform(0.0),
226    )?;
227    if matches!(lane, crate::OffsetSurfaceLane::Reparameterised) {
228        return Err(format!(
229            "thickenSheet: the offset carrier for distance {distance:.6} was re-parameterized \
230             (the apex-cone pinch retrim pulled a sample row back to the offset's own apex), so \
231             the trim's pcurves no longer name the pointwise offset of the selected region — the \
232             selected trim reaches past the pinch and offsetting only its own sub-domain is not \
233             yet supported"
234        ));
235    }
236    Ok(offset)
237}
238
239/// Ruled wall between corresponding boundary curves of the bottom and top
240/// sheets.  Requires the shared basis that `offset_surface` guarantees
241/// (same degree, knots, and per-column weights); with equal weights the
242/// homogeneous ruling evaluates to the exact pointwise segment
243/// (1−w)·bottom(s) + w·top(s).
244fn ruled_wall(bottom: &NurbsCurve, top: &NurbsCurve) -> Result<NurbsSurface, String> {
245    if bottom.degree != top.degree
246        || bottom.knots.len() != top.knots.len()
247        || bottom
248            .knots
249            .iter()
250            .zip(&top.knots)
251            .any(|(a, b)| (a - b).abs() > 1e-12)
252        || bottom
253            .control_points
254            .iter()
255            .zip(&top.control_points)
256            .any(|(a, b)| (a.w - b.w).abs() > 1e-9)
257    {
258        return Err("thickenSheet: internal error — offset sheet basis mismatch".into());
259    }
260    let rows = bottom
261        .control_points
262        .iter()
263        .zip(&top.control_points)
264        .map(|(b, t)| vec![*b, *t])
265        .collect();
266    NurbsSurface::new(
267        bottom.degree,
268        1,
269        bottom.knots.clone(),
270        vec![0.0, 0.0, 1.0, 1.0],
271        rows,
272    )
273}
274
275const GAUSS_X: [f64; 8] = [
276    -0.9602898564975363,
277    -0.7966664774136267,
278    -0.525532409916329,
279    -0.18343464249564978,
280    0.18343464249564978,
281    0.525532409916329,
282    0.7966664774136267,
283    0.9602898564975363,
284];
285const GAUSS_W: [f64; 8] = [
286    0.10122853629037669,
287    0.22238103445337445,
288    0.31370664587788727,
289    0.362683783378362,
290    0.362683783378362,
291    0.31370664587788727,
292    0.22238103445337445,
293    0.10122853629037669,
294];
295
296/// Green's-theorem signed-area contribution ∮ (x·y' − y·x')/2 of one pcurve,
297/// integrated per knot span with 8-point Gauss.  Summed over a closed loop
298/// this is the loop's signed (u, v) area — the orientation oracle for the
299/// outer-CCW / hole-CW convention.
300fn pcurve_signed_area(curve: &NurbsCurve) -> Result<f64, String> {
301    let [q0, q1] = curve.domain()?;
302    let mut breaks = vec![q0];
303    for &knot in &curve.knots {
304        if knot > q0 + 1e-12 && knot < q1 - 1e-12 && (knot - breaks[breaks.len() - 1]).abs() > 1e-12
305        {
306            breaks.push(knot);
307        }
308    }
309    breaks.push(q1);
310    let mut area = 0.0;
311    for pair in breaks.windows(2) {
312        let half = (pair[1] - pair[0]) * 0.5;
313        let middle = (pair[1] + pair[0]) * 0.5;
314        for index in 0..GAUSS_X.len() {
315            let derivatives = curve.derivatives(middle + half * GAUSS_X[index], 1)?;
316            let point = derivatives[0];
317            let tangent = derivatives[1];
318            area += GAUSS_W[index] * half * 0.5 * (point.x * tangent.y - point.y * tangent.x);
319        }
320    }
321    Ok(area)
322}
323
324/// Distance between two pcurve evaluations in the (u, v) plane (the z slot
325/// of a parameter-space curve is dead weight).
326fn planar_gap(first: Vec3, second: Vec3) -> f64 {
327    let du = first.x - second.x;
328    let dv = first.y - second.y;
329    (du * du + dv * dv).sqrt()
330}
331
332/// 3D images of one boundary pcurve on the bottom and top sheets, plus the
333/// edge parameter range they are represented over and whether the stored
334/// loop direction runs with increasing edge parameter.
335struct BoundaryImages {
336    bottom: NurbsCurve,
337    top: NurbsCurve,
338    t0: f64,
339    t1: f64,
340    /// Stored pcurve direction == increasing edge parameter.
341    dir: bool,
342}
343
344/// Build the bottom/top 3D images of one boundary pcurve.
345///
346/// * Affine sheets take ANY rational pcurve (exact homogeneous mapping).
347/// * Curved sheets take iso-parameter LINE segments (u = const or
348///   v = const): the image is the shared-basis isocurve of each sheet,
349///   trimmed to the segment's parameter range.  A degree-1 equal-weight
350///   pcurve maps its parameter linearly onto the iso parameter, so the
351///   validator's fraction-matched pcurve consistency check is exact.
352/// * Anything else on a curved sheet goes to the GENERAL image ladder
353///   ([`crate::image_curve::image_curve_pair`]): the composed curve-on-surface
354///   is sampled and fitted on both sheets over ONE parameter set, which is what
355///   makes the pair basis-identical for [`ruled_wall`].  The transfer is exact
356///   in the sense that matters here — unlike a push against a FIXED neighbour,
357///   both sheets move together, so the image of the shared trim IS the
358///   boundary, not an approximation of some other curve.  The ladder refuses
359///   with its measured deviation rather than returning an unvalidated fit.
360fn boundary_images(
361    base_affine: bool,
362    bottom: &NurbsSurface,
363    top: &NurbsSurface,
364    pcurve: &NurbsCurve,
365    eps_u: f64,
366    eps_v: f64,
367    fit_tolerance: f64,
368) -> Result<BoundaryImages, String> {
369    if base_affine {
370        let [q0, q1] = pcurve.domain()?;
371        return Ok(BoundaryImages {
372            bottom: affine_image_curve(bottom, pcurve)?,
373            top: affine_image_curve(top, pcurve)?,
374            t0: q0,
375            t1: q1,
376            dir: true,
377        });
378    }
379    if pcurve.degree == 1 && pcurve.control_points.len() == 2 {
380        let first = pcurve.control_points[0];
381        let second = pcurve.control_points[1];
382        if (first.w - second.w).abs() <= 1e-12 {
383            let (ua, va) = (first.x / first.w, first.y / first.w);
384            let (ub, vb) = (second.x / second.w, second.y / second.w);
385            if (ua - ub).abs() <= eps_u && (va - vb).abs() > eps_v {
386                let u_constant = (ua + ub) * 0.5;
387                return Ok(BoundaryImages {
388                    bottom: bottom.iso_curve_u(u_constant)?,
389                    top: top.iso_curve_u(u_constant)?,
390                    t0: va.min(vb),
391                    t1: va.max(vb),
392                    dir: vb > va,
393                });
394            }
395            if (va - vb).abs() <= eps_v && (ua - ub).abs() > eps_u {
396                let v_constant = (va + vb) * 0.5;
397                return Ok(BoundaryImages {
398                    bottom: bottom.iso_curve_v(v_constant)?,
399                    top: top.iso_curve_v(v_constant)?,
400                    t0: ua.min(ub),
401                    t1: ua.max(ub),
402                    dir: ub > ua,
403                });
404            }
405        }
406    }
407    // The general trim: no closed form, so fit the composed curve on both
408    // sheets and let the ladder measure itself.
409    let (bottom_image, top_image) =
410        image_curve_pair(bottom, top, pcurve, fit_tolerance, "thickenSheet")?;
411    let forward = bottom_image.t0 <= bottom_image.t1;
412    Ok(BoundaryImages {
413        bottom: bottom_image.curve,
414        top: top_image.curve,
415        t0: bottom_image.t0.min(bottom_image.t1),
416        t1: bottom_image.t0.max(bottom_image.t1),
417        dir: forward,
418    })
419}
420
421/// §5.9 THICKEN a TRIMMED sheet region into a closed solid.
422///
423/// `loops` are parameter-space curves on `surface` exactly as FaceRecord
424/// loops store them: the outer loop first, running counter-clockwise in
425/// (u, v), followed by optional hole loops running clockwise (the
426/// `same_sense = true` convention of `validate_uv_wire`).  Each loop's
427/// pcurves must chain tip-to-tail and close; a loop may also be a single
428/// closed pcurve (e.g. a rational circle).
429///
430/// * `symmetric = false`: the solid occupies the space between the sheet and
431///   its offset at signed `thickness` along the sheet normal n = Su × Sv
432///   (negative thickness grows the solid on the −n side).
433/// * `symmetric = true`: the material splits evenly, |thickness|/2 on each
434///   side of the sheet (the sheet becomes the mid-surface).
435///
436/// The caps are the bottom/top offset sheets trimmed by the SAME pcurve
437/// loops (the offset shares the sheet's basis, so pcurves transfer
438/// verbatim); every boundary pcurve contributes one ruled side wall between
439/// its bottom and top 3D images, ruled pointwise at equal pcurve parameter —
440/// for an offset pair that ruling runs along the surface normal, so walls
441/// are exact wherever the full-domain walls were.  Hole loops produce inner
442/// wall tubes and each adds one handle: `genus = loops.len() - 1`.
443///
444/// Every boundary edge is a single EdgeRecord shared by exactly two coedges
445/// (cap + wall); wall-to-wall junction edges are likewise shared.
446///
447/// A general (non-iso) pcurve on a CURVED sheet is no longer refused: its two
448/// 3D images come from [`crate::image_curve::image_curve_pair`], which fits the
449/// composed curve-on-surface on both sheets over ONE shared parameter set (the
450/// basis identity [`ruled_wall`] requires) and refuses only when the fit misses
451/// its measured tolerance.  Unlike a push against a FIXED neighbour, both
452/// sheets here move together, so the image of the shared trim IS the boundary.
453///
454/// Returns one BODY per regular piece of the selected trim.  That is one body
455/// for a trim the offset is regular over, and one for a trim the fold locus
456/// splits in two (the folded half is dropped) — but a fold BAND across the trim
457/// is bounded by TWO locus branches and leaves TWO regular pieces, and each of
458/// those is its own body: they share no edge, because the dropped band lies
459/// between them.
460///
461/// Refuses (Err) on: zero/non-finite thickness, closed sheets, open or
462/// misoriented loops, pinched loops, a general pcurve image that cannot be
463/// fitted to tolerance, and offsets that fold through the evolute INSIDE THE
464/// SELECTED TRIM (a fold the trim excludes is not this sheet's problem) that
465/// the carve cannot divide the trim along.
466pub fn thicken_trimmed_sheet(
467    surface: &NurbsSurface,
468    loops: &[Vec<NurbsCurve>],
469    thickness: f64,
470    symmetric: bool,
471) -> Result<Vec<BrepSolid>, String> {
472    if !thickness.is_finite() || thickness.abs() <= 1e-12 {
473        return Err("thickenSheet: thickness must be a nonzero finite value".into());
474    }
475    if loops.is_empty() || loops.iter().any(|loop_curves| loop_curves.is_empty()) {
476        return Err("thickenSheet: at least one non-empty pcurve loop is required".into());
477    }
478    let (distance_bottom, distance_top) = if symmetric {
479        (-thickness.abs() * 0.5, thickness.abs() * 0.5)
480    } else if thickness > 0.0 {
481        (0.0, thickness)
482    } else {
483        (thickness, 0.0)
484    };
485    // --- The fold BAND on a FULL revolution, before the closed-sheet refusal
486    //     below: this lane's sheet IS a seamed one, and the reason it can be
487    //     built where a general closed sheet cannot is that the whole question
488    //     is planar. A thicken is the UNION of the sheet's normal segments; over
489    //     a folded band those segments cross the axis and come out at the
490    //     azimuth half a turn away, which a FULL revolution covers itself. The
491    //     union is then the half-plane region (the swept sector truncated at the
492    //     axis, plus the reflected lemon) revolved — `offset/thicken/band.rs`.
493    //
494    //     `None` is "not this lane" and changes nothing for any other sheet: the
495    //     recognition wants a revolution with a circular generatrix whose offset
496    //     really does reach the axis, in a BAND strictly inside the trim.
497    if let Some(recognized) = band::recognize(surface, loops, distance_bottom, distance_top)? {
498        return Ok(vec![band::build(&recognized)?]);
499    }
500    let (closed_u, closed_v) = surface.closed_directions()?;
501    if closed_u || closed_v {
502        return Err(
503            "thickenSheet: closed sheets are not supported (split the patch at its seam first)"
504                .into(),
505        );
506    }
507    let [u0, u1] = surface.domain_u()?;
508    let [v0, v1] = surface.domain_v()?;
509    let uv_tolerance = 1e-7 * (u1 - u0).max(v1 - v0);
510    let minimum_area = 1e-10 * (u1 - u0) * (v1 - v0);
511    // Loop validation runs BEFORE the regularity gate: the gate now reads the
512    // trim as a REGION, and an open or misoriented loop is that loop's refusal
513    // to report, not a fold's.  The construction pass below re-derives the same
514    // endpoints it needs and no longer re-checks them.
515    validate_trim_loops(loops, uv_tolerance, minimum_area)?;
516    // A fold covering PART of the selected trim is CARVED out of it: the fold
517    // locus is traced, the trim split along it, and the folded side dropped.
518    // What is left is a region the offset is regular over, so the gate below
519    // has nothing to refuse — and the carve has already verified that with the
520    // same scan, judged a band off the level so the new boundary itself does
521    // not vote.  A region that folds EVERYWHERE, or one whose fold only the
522    // refinement pass found, is not a carve; the gate keeps its refusal.
523    let carved = match carve_folded_trim(
524        surface,
525        loops,
526        &[distance_bottom, distance_top],
527        FOLD_FACTOR,
528        ScanBudget::SHEET,
529    ) {
530        Ok(carved) => carved,
531        Err(why) => {
532            // A carve that cannot be made does NOT become this feature's
533            // refusal.  The sheet still folds inside its trim, and the message
534            // a caller has always got for that is the gate's — naming the
535            // curvature radius and the `(u, v)`.  The carve's own reason rides
536            // along so the cause is not lost, but it never replaces the
537            // refusal, and it never turns a fold into a build.
538            ensure_offsets_regular(surface, loops, &[distance_bottom, distance_top]).map_err(
539                |refusal| format!("{refusal}; the fold could not be carved out either: {why}"),
540            )?;
541            return Err(format!("thickenSheet: {why}"));
542        }
543    };
544    let regions: Vec<Vec<Vec<NurbsCurve>>> = match &carved {
545        Some(trim) => {
546            let mut regions = Vec::with_capacity(trim.kept.len());
547            for piece in &trim.kept {
548                let kept = piece
549                    .iter()
550                    .map(|carved_loop| carved_loop.pcurves.clone())
551                    .collect::<Vec<_>>();
552                // The carved loops go through the SAME loop validation the
553                // selected ones did: a division that leaves an open or pinched
554                // loop is this module's refusal to report, not a defect to
555                // build on.
556                validate_trim_loops(&kept, uv_tolerance, minimum_area)?;
557                regions.push(kept);
558            }
559            regions
560        }
561        None => {
562            ensure_offsets_regular(surface, loops, &[distance_bottom, distance_top])?;
563            vec![loops.to_vec()]
564        }
565    };
566    // One BODY per regular piece. They are bodies and not shells of one solid
567    // because they share no EDGE: the dropped band lies between them, and a
568    // shell is edge-connected by definition — `brep/soundness.rs` reads exactly
569    // that, and its vertex-fan detector already treats two cones meeting at
570    // their apex as two pieces. Where a division leaves ONE piece this is a
571    // one-element vector and nothing about the result changes.
572    let mut bodies = Vec::with_capacity(regions.len());
573    for region_loops in &regions {
574        bodies.push(thicken_one_region(
575            surface,
576            region_loops,
577            distance_bottom,
578            distance_top,
579        )?);
580    }
581
582    // THE RULE the carve rests on: a carve may drop a part of the trim only
583    // when that part's own normals sweep material the kept construction
584    // contains. Dropping the folded part is sound when the material it would
585    // have contributed is already inside what was built, and unsound when it is
586    // not — and that is a property of the geometry, not of how many pieces the
587    // division left.
588    //
589    // The shape it catches: a torus sheet grown OUTWARD past its tube radius.
590    // At the inner equator the outward normal points AT the axis, so the
591    // segment reaches the axis part way along and comes out the far side,
592    // sweeping material at the opposite azimuth that no kept piece holds. A
593    // volume measured against the kept pieces alone is then fitted to the
594    // construction rather than to the solid the sheet sweeps — which is the one
595    // outcome this family of code exists to prevent, and it validates and
596    // measures cleanly without this check.
597    //
598    // The shape it admits: an apex cone shelled through its base, where the
599    // dropped sliver sweeps INWARD into material the result keeps.
600    if let Some(trim) = &carved {
601        for swept in carve_dropped_sweep(
602            surface,
603            &trim.dropped,
604            &[distance_bottom, distance_top],
605            ScanBudget::SHEET,
606        )? {
607            if bodies.iter().any(|body| {
608                crate::classify_point(swept.point, body, uv_tolerance)
609                    .map(|classification| classification.class != crate::PointClass::Out)
610                    .unwrap_or(false)
611            }) {
612                continue;
613            }
614            return Err(format!(
615                "thickenSheet: the fold covers part of the selected trim, and the part that \
616                 would be dropped sweeps material the result does not contain — the normal at \
617                 (u={:.6}, v={:.6}) reaches ({:.6}, {:.6}, {:.6}) at {:.0}% of the {:.6} offset, \
618                 which is outside every body built from the {} kept piece(s). A carve may drop a \
619                 part of the trim only when that part's own normals sweep material the kept \
620                 construction contains; here they leave it, so the kept pieces bound less than \
621                 the sheet sweeps and their volume is a form fitted to the construction. \
622                 Refused rather than measured against it",
623                swept.uv[0],
624                swept.uv[1],
625                swept.point.x,
626                swept.point.y,
627                swept.point.z,
628                swept.fraction * 100.0,
629                swept.displacement,
630                bodies.len()
631            ));
632        }
633    }
634    Ok(bodies)
635}
636
637/// Build ONE slab body over one region's loops: both offset caps, the ruled
638/// side walls between them, and the junction edges that join them.
639///
640/// Split out of [`thicken_trimmed_sheet`] because a fold BAND across a trim
641/// leaves TWO regular pieces and each is its own body; everything above this
642/// point decides WHICH regions to build, everything below builds one.
643fn thicken_one_region(
644    surface: &NurbsSurface,
645    loops: &[Vec<NurbsCurve>],
646    distance_bottom: f64,
647    distance_top: f64,
648) -> Result<BrepSolid, String> {
649    let [u0, u1] = surface.domain_u()?;
650    let [v0, v1] = surface.domain_v()?;
651
652    let bottom = offset_sheet(surface, loops, distance_bottom)?;
653    let top = offset_sheet(surface, loops, distance_top)?;
654    // Each sheet's fit is refined by its own measured deviation, so the two can
655    // leave on different nets; the ruled walls pair their iso curves control
656    // point for control point.
657    let (bottom, top) = crate::offset::unify_offset_sheet_bases(&bottom, &top)?;
658    let eps_u = 1e-9 * (u1 - u0);
659    let eps_v = 1e-9 * (v1 - v0);
660    let base_affine = surface.is_affine()?;
661    // Fit-accuracy bar for the general image ladder.  `intersection_fit` is
662    // the kernel's single NAMED "maximum geometric error accepted while
663    // fitting" field (`geometry/tolerance.rs`), sized to this sheet — an
664    // accuracy target, deliberately not the validator's loose
665    // `pcurve_acceptance` identity band, which would admit a fit that misses
666    // the true boundary by 2.5% of the model.
667    let sheet_points = bottom
668        .control_points
669        .iter()
670        .flatten()
671        .map(|control| control.point())
672        .collect::<Result<Vec<_>, String>>()?;
673    let fit_tolerance =
674        crate::KernelTolerances::for_scale(crate::model_scale(sheet_points), 1e-7).intersection_fit;
675
676    let mut vertices: Vec<VertexRecord> = Vec::new();
677    let mut edges: Vec<EdgeRecord> = Vec::new();
678    let mut faces: Vec<FaceRecord> = Vec::new();
679    let mut top_cap_loops: Vec<LoopRecord> = Vec::new();
680    let mut bottom_cap_loops: Vec<LoopRecord> = Vec::new();
681    let mut bottom_junction_points: Vec<Vec3> = Vec::new();
682    let mut next_id = 1u64;
683
684    for loop_curves in loops.iter() {
685        let count = loop_curves.len();
686
687        // Junction parameters; the checks these used to feed now run in
688        // `validate_trim_loops`, ahead of the regularity gate.
689        let mut starts = Vec::with_capacity(count);
690        for curve in loop_curves {
691            let [q0, _] = curve.domain()?;
692            starts.push(curve.evaluate(q0)?);
693        }
694
695        // ---- Junction vertices: junction j = start of pcurve j. ----
696        let mut bottom_vertex_ids = Vec::with_capacity(count);
697        let mut top_vertex_ids = Vec::with_capacity(count);
698        let mut bottom_points = Vec::with_capacity(count);
699        let mut top_points = Vec::with_capacity(count);
700        for start in &starts {
701            let bottom_point = bottom.evaluate(start.x, start.y)?;
702            let top_point = top.evaluate(start.x, start.y)?;
703            vertices.push(VertexRecord {
704                id: next_id,
705                point: bottom_point,
706            });
707            bottom_vertex_ids.push(next_id);
708            next_id += 1;
709            vertices.push(VertexRecord {
710                id: next_id,
711                point: top_point,
712            });
713            top_vertex_ids.push(next_id);
714            next_id += 1;
715            bottom_points.push(bottom_point);
716            top_points.push(top_point);
717            bottom_junction_points.push(bottom_point);
718        }
719
720        // ---- Boundary edges on both sheets + vertical junction edges. ----
721        let mut images = Vec::with_capacity(count);
722        for curve in loop_curves {
723            images.push(boundary_images(
724                base_affine,
725                &bottom,
726                &top,
727                curve,
728                eps_u,
729                eps_v,
730                fit_tolerance,
731            )?);
732        }
733        let mut bottom_edge_ids = Vec::with_capacity(count);
734        let mut top_edge_ids = Vec::with_capacity(count);
735        for (index, image) in images.iter().enumerate() {
736            let next_index = (index + 1) % count;
737            let (start_j, end_j) = if image.dir {
738                (index, next_index)
739            } else {
740                (next_index, index)
741            };
742            edges.push(EdgeRecord {
743                id: next_id,
744                curve: image.bottom.clone(),
745                t0: image.t0,
746                t1: image.t1,
747                start_vertex_id: bottom_vertex_ids[start_j],
748                end_vertex_id: bottom_vertex_ids[end_j],
749                degenerate: false,
750                name: None,
751            });
752            bottom_edge_ids.push(next_id);
753            next_id += 1;
754            edges.push(EdgeRecord {
755                id: next_id,
756                curve: image.top.clone(),
757                t0: image.t0,
758                t1: image.t1,
759                start_vertex_id: top_vertex_ids[start_j],
760                end_vertex_id: top_vertex_ids[end_j],
761                degenerate: false,
762                name: None,
763            });
764            top_edge_ids.push(next_id);
765            next_id += 1;
766        }
767        let mut vertical_edge_ids = Vec::with_capacity(count);
768        for junction in 0..count {
769            edges.push(EdgeRecord {
770                id: next_id,
771                curve: make_line(bottom_points[junction], top_points[junction])?,
772                t0: 0.0,
773                t1: 1.0,
774                start_vertex_id: bottom_vertex_ids[junction],
775                end_vertex_id: top_vertex_ids[junction],
776                degenerate: false,
777                name: None,
778            });
779            vertical_edge_ids.push(next_id);
780            next_id += 1;
781        }
782
783        // ---- One ruled wall per boundary pcurve. ----
784        //
785        // The wall's s parameter is the edge parameter; its loop traverses
786        // the bottom edge ALONG the stored loop direction.  With the top
787        // sheet at the larger offset, W_w = Δd·n with Δd > 0, so the wall's
788        // natural normal W_s × W_w points to the RIGHT of the walk — which
789        // is outward for a CCW outer loop (material on the left) AND for a
790        // CW hole loop (material on the left, void on the right).  Hence
791        // same_sense = dir uniformly, with the loop winding to match.
792        for (index, image) in images.iter().enumerate() {
793            let next_index = (index + 1) % count;
794            let wall = ruled_wall(&image.bottom, &image.top)?;
795            let (s_start, s_end) = if image.dir {
796                (image.t0, image.t1)
797            } else {
798                (image.t1, image.t0)
799            };
800            let mut coedges = Vec::with_capacity(4);
801            for (edge_id, forward, pcurve) in [
802                (
803                    bottom_edge_ids[index],
804                    image.dir,
805                    parameter_line(s_start, 0.0, s_end, 0.0)?,
806                ),
807                (
808                    vertical_edge_ids[next_index],
809                    true,
810                    parameter_line(s_end, 0.0, s_end, 1.0)?,
811                ),
812                (
813                    top_edge_ids[index],
814                    !image.dir,
815                    parameter_line(s_end, 1.0, s_start, 1.0)?,
816                ),
817                (
818                    vertical_edge_ids[index],
819                    false,
820                    parameter_line(s_start, 1.0, s_start, 0.0)?,
821                ),
822            ] {
823                coedges.push(CoedgeRecord {
824                    id: next_id,
825                    edge_id,
826                    forward,
827                    pcurve,
828                });
829                next_id += 1;
830            }
831            let loop_id = next_id;
832            next_id += 1;
833            faces.push(FaceRecord {
834                id: next_id,
835                surface: wall,
836                same_sense: image.dir,
837                loops: vec![LoopRecord {
838                    id: loop_id,
839                    coedges,
840                }],
841                name: None,
842            });
843            next_id += 1;
844        }
845
846        // ---- Cap loops: verbatim pcurves on top, reversed on bottom. ----
847        let mut top_coedges = Vec::with_capacity(count);
848        for (index, image) in images.iter().enumerate() {
849            top_coedges.push(CoedgeRecord {
850                id: next_id,
851                edge_id: top_edge_ids[index],
852                forward: image.dir,
853                pcurve: loop_curves[index].clone(),
854            });
855            next_id += 1;
856        }
857        top_cap_loops.push(LoopRecord {
858            id: next_id,
859            coedges: top_coedges,
860        });
861        next_id += 1;
862        let mut bottom_coedges = Vec::with_capacity(count);
863        for index in (0..count).rev() {
864            bottom_coedges.push(CoedgeRecord {
865                id: next_id,
866                edge_id: bottom_edge_ids[index],
867                forward: !images[index].dir,
868                pcurve: loop_curves[index].reversed()?,
869            });
870            next_id += 1;
871        }
872        bottom_cap_loops.push(LoopRecord {
873            id: next_id,
874            coedges: bottom_coedges,
875        });
876        next_id += 1;
877    }
878
879    // Coincident junction vertices — v1's coincident-corner refusal
880    // generalized: a repeated junction point pinches the boundary into a
881    // non-manifold vertex (this also catches a hole touching the rim).
882    // The junction ring's own extent, not its distance from the world origin:
883    // the same sheet modelled 5 m out must be judged degenerate on the same
884    // evidence.  For a two-junction boundary this is the pair's separation, so
885    // the test still answers "coincident" only at a true collapse.
886    let scale = crate::model_scale(bottom_junction_points.iter().copied());
887    for first in 0..bottom_junction_points.len() {
888        for second in first + 1..bottom_junction_points.len() {
889            if bottom_junction_points[first]
890                .sub(bottom_junction_points[second])
891                .length()
892                <= 1e-7 * scale
893            {
894                return Err(
895                    "thickenSheet: sheet boundary is degenerate (coincident junction vertices)"
896                        .into(),
897                );
898            }
899        }
900    }
901
902    // Caps: outward is +n on the top sheet, −n on the bottom; the given
903    // loop orientation matches same_sense = true, its reversal the bottom.
904    faces.push(FaceRecord {
905        id: next_id,
906        surface: top,
907        same_sense: true,
908        loops: top_cap_loops,
909        name: None,
910    });
911    next_id += 1;
912    faces.push(FaceRecord {
913        id: next_id,
914        surface: bottom,
915        same_sense: false,
916        loops: bottom_cap_loops,
917        name: None,
918    });
919    next_id += 1;
920
921    let shell_id = next_id;
922    let solid = BrepSolid {
923        id: next_id + 1,
924        vertices,
925        edges,
926        shells: vec![ShellRecord {
927            id: shell_id,
928            faces,
929        }],
930        genus: loops.len() as i64 - 1,
931    };
932    let issues = solid.validate();
933    if !issues.is_empty() {
934        return Err(format!(
935            "thickenSheet: assembled solid failed validation: {issues:?}"
936        ));
937    }
938    let volume = crate::solid_signed_volume(&solid)?;
939    if volume <= 0.0 {
940        return Err(format!(
941            "thickenSheet: internal orientation error (signed volume {volume})"
942        ));
943    }
944    // The sheet's two offset faces can reach past the source's curvature
945    // radius and cross each other, or one of them can fold through itself,
946    // and the body still validates and still measures a volume. This is the
947    // lane's single exit and the one place that is asked — once per BODY,
948    // which is what a carved trim needs: two pieces that share no edge have to
949    // be sound each on its own, and a scan over their union would have to know
950    // they are separate to avoid reading the gap between them.
951    crate::accept_sound(solid, "thickenSheet")
952}
953
954/// §5.9 THICKEN a sheet (an open surface patch over its full parameter
955/// domain) into a closed solid.
956///
957/// Thin wrapper over [`thicken_trimmed_sheet`] passing the full-domain
958/// rectangle as the (counter-clockwise) outer loop.  The result is one
959/// genus-0 body with 8 vertices, 12 edges, and 6 faces (offset caps + four
960/// ruled walls), oriented outward and validated — or, where a fold BAND
961/// crosses the domain, one such body per regular piece.  Refuses (Err) on:
962/// zero/non-finite thickness, closed sheets (split at the seam first),
963/// degenerate boundaries, and offsets that would self-intersect because a
964/// concave curvature radius is smaller than the offset distance.
965pub fn thicken_face_sheet(
966    surface: &NurbsSurface,
967    thickness: f64,
968    symmetric: bool,
969) -> Result<Vec<BrepSolid>, String> {
970    if !thickness.is_finite() || thickness.abs() <= 1e-12 {
971        return Err("thickenSheet: thickness must be a nonzero finite value".into());
972    }
973    let [u0, u1] = surface.domain_u()?;
974    let [v0, v1] = surface.domain_v()?;
975    let rectangle = vec![
976        parameter_line(u0, v0, u1, v0)?,
977        parameter_line(u1, v0, u1, v1)?,
978        parameter_line(u1, v1, u0, v1)?,
979        parameter_line(u0, v1, u0, v0)?,
980    ];
981    thicken_trimmed_sheet(surface, &[rectangle], thickness, symmetric)
982}
983