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