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