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