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 — a concave principal
23//! curvature radius smaller than the offset distance folds the offset
24//! through its evolute — the builder refuses with an honest `Err` instead
25//! of assembling silent garbage.
26
27use crate::offset::offset_surface;
28use crate::sweep_topology::parameter_line;
29use crate::topology::{
30    BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord, ShellRecord, VertexRecord,
31};
32use crate::image_curve::{affine_image_curve, image_curve_pair};
33use crate::{make_line, NurbsCurve, NurbsSurface, Vec3};
34
35/// Principal curvatures (κ_min, κ_max) of the sheet at (u, v), signed with
36/// respect to the parametrization normal n = Su × Sv / |Su × Sv| via the
37/// shape operator I⁻¹·II.  Convention check: a cylinder of radius R whose
38/// normal points AWAY from the axis has κ = −1/R along the circular
39/// direction, so the offset regularity factor 1 − d·κ vanishes exactly when
40/// an inward offset (d = −R) reaches the axis.
41fn principal_curvatures(surface: &NurbsSurface, u: f64, v: f64) -> Result<(f64, f64), String> {
42    let derivatives = surface.derivatives(u, v, 2)?;
43    let su = derivatives[1][0];
44    let sv = derivatives[0][1];
45    let cross = su.cross(sv);
46    let cross_length = cross.length();
47    if cross_length <= 1e-12 {
48        return Err(format!(
49            "thickenSheet: degenerate parametrization at (u={u:.4}, v={v:.4})"
50        ));
51    }
52    let normal = cross.scale(1.0 / cross_length);
53    let e1 = su.dot(su);
54    let f1 = su.dot(sv);
55    let g1 = sv.dot(sv);
56    let l2 = derivatives[2][0].dot(normal);
57    let m2 = derivatives[1][1].dot(normal);
58    let n2 = derivatives[0][2].dot(normal);
59    let denominator = e1 * g1 - f1 * f1;
60    let mean_double = (l2 * g1 - 2.0 * m2 * f1 + n2 * e1) / denominator; // 2H
61    let gauss = (l2 * n2 - m2 * m2) / denominator; // K
62    let discriminant = (mean_double * mean_double * 0.25 - gauss).max(0.0).sqrt();
63    Ok((
64        mean_double * 0.5 - discriminant,
65        mean_double * 0.5 + discriminant,
66    ))
67}
68
69/// Refuse offsets that fold through the sheet's evolute: at every sampled
70/// (u, v) and for every requested signed offset distance d the per-direction
71/// area factor 1 − d·κ must stay positive, or the equidistant surface
72/// self-intersects (concave curvature radius ≤ offset distance).
73fn ensure_offsets_regular(surface: &NurbsSurface, distances: &[f64]) -> Result<(), String> {
74    let [u0, u1] = surface.domain_u()?;
75    let [v0, v1] = surface.domain_v()?;
76    const SAMPLES: usize = 33;
77    for i in 0..SAMPLES {
78        let u = u0 + (u1 - u0) * i as f64 / (SAMPLES - 1) as f64;
79        for j in 0..SAMPLES {
80            let v = v0 + (v1 - v0) * j as f64 / (SAMPLES - 1) as f64;
81            let (kappa_min, kappa_max) = principal_curvatures(surface, u, v)?;
82            for &distance in distances {
83                if distance == 0.0 {
84                    continue;
85                }
86                for kappa in [kappa_min, kappa_max] {
87                    let factor = 1.0 - distance * kappa;
88                    if factor <= 1e-6 {
89                        let radius = 1.0 / kappa.abs().max(1e-300);
90                        return Err(format!(
91                            "thickenSheet: offset by {distance:.6} self-intersects — the \
92                             sheet's concave curvature radius {radius:.6} at (u={u:.4}, \
93                             v={v:.4}) is not larger than the offset distance"
94                        ));
95                    }
96                }
97            }
98        }
99    }
100    Ok(())
101}
102
103/// Equidistant sheet moved `distance` along the parametrization normal
104/// n = Su × Sv (positive = +n side).  `offset_surface`'s positive distance
105/// moves OPPOSITE the face normal, hence the negation.
106fn offset_sheet(surface: &NurbsSurface, distance: f64) -> Result<NurbsSurface, String> {
107    if distance == 0.0 {
108        return Ok(surface.clone());
109    }
110    let carrier = FaceRecord {
111        id: 1,
112        surface: surface.clone(),
113        same_sense: true,
114        loops: vec![],
115        name: None,
116    };
117    offset_surface(&carrier, -distance, 0.0)
118}
119
120/// Ruled wall between corresponding boundary curves of the bottom and top
121/// sheets.  Requires the shared basis that `offset_surface` guarantees
122/// (same degree, knots, and per-column weights); with equal weights the
123/// homogeneous ruling evaluates to the exact pointwise segment
124/// (1−w)·bottom(s) + w·top(s).
125fn ruled_wall(bottom: &NurbsCurve, top: &NurbsCurve) -> Result<NurbsSurface, String> {
126    if bottom.degree != top.degree
127        || bottom.knots.len() != top.knots.len()
128        || bottom
129            .knots
130            .iter()
131            .zip(&top.knots)
132            .any(|(a, b)| (a - b).abs() > 1e-12)
133        || bottom
134            .control_points
135            .iter()
136            .zip(&top.control_points)
137            .any(|(a, b)| (a.w - b.w).abs() > 1e-9)
138    {
139        return Err("thickenSheet: internal error — offset sheet basis mismatch".into());
140    }
141    let rows = bottom
142        .control_points
143        .iter()
144        .zip(&top.control_points)
145        .map(|(b, t)| vec![*b, *t])
146        .collect();
147    NurbsSurface::new(
148        bottom.degree,
149        1,
150        bottom.knots.clone(),
151        vec![0.0, 0.0, 1.0, 1.0],
152        rows,
153    )
154}
155
156const GAUSS_X: [f64; 8] = [
157    -0.9602898564975363,
158    -0.7966664774136267,
159    -0.525532409916329,
160    -0.18343464249564978,
161    0.18343464249564978,
162    0.525532409916329,
163    0.7966664774136267,
164    0.9602898564975363,
165];
166const GAUSS_W: [f64; 8] = [
167    0.10122853629037669,
168    0.22238103445337445,
169    0.31370664587788727,
170    0.362683783378362,
171    0.362683783378362,
172    0.31370664587788727,
173    0.22238103445337445,
174    0.10122853629037669,
175];
176
177/// Green's-theorem signed-area contribution ∮ (x·y' − y·x')/2 of one pcurve,
178/// integrated per knot span with 8-point Gauss.  Summed over a closed loop
179/// this is the loop's signed (u, v) area — the orientation oracle for the
180/// outer-CCW / hole-CW convention.
181fn pcurve_signed_area(curve: &NurbsCurve) -> Result<f64, String> {
182    let [q0, q1] = curve.domain()?;
183    let mut breaks = vec![q0];
184    for &knot in &curve.knots {
185        if knot > q0 + 1e-12 && knot < q1 - 1e-12 && (knot - breaks[breaks.len() - 1]).abs() > 1e-12
186        {
187            breaks.push(knot);
188        }
189    }
190    breaks.push(q1);
191    let mut area = 0.0;
192    for pair in breaks.windows(2) {
193        let half = (pair[1] - pair[0]) * 0.5;
194        let middle = (pair[1] + pair[0]) * 0.5;
195        for index in 0..GAUSS_X.len() {
196            let derivatives = curve.derivatives(middle + half * GAUSS_X[index], 1)?;
197            let point = derivatives[0];
198            let tangent = derivatives[1];
199            area += GAUSS_W[index] * half * 0.5 * (point.x * tangent.y - point.y * tangent.x);
200        }
201    }
202    Ok(area)
203}
204
205/// Distance between two pcurve evaluations in the (u, v) plane (the z slot
206/// of a parameter-space curve is dead weight).
207fn planar_gap(first: Vec3, second: Vec3) -> f64 {
208    let du = first.x - second.x;
209    let dv = first.y - second.y;
210    (du * du + dv * dv).sqrt()
211}
212
213/// 3D images of one boundary pcurve on the bottom and top sheets, plus the
214/// edge parameter range they are represented over and whether the stored
215/// loop direction runs with increasing edge parameter.
216struct BoundaryImages {
217    bottom: NurbsCurve,
218    top: NurbsCurve,
219    t0: f64,
220    t1: f64,
221    /// Stored pcurve direction == increasing edge parameter.
222    dir: bool,
223}
224
225/// Build the bottom/top 3D images of one boundary pcurve.
226///
227/// * Affine sheets take ANY rational pcurve (exact homogeneous mapping).
228/// * Curved sheets take iso-parameter LINE segments (u = const or
229///   v = const): the image is the shared-basis isocurve of each sheet,
230///   trimmed to the segment's parameter range.  A degree-1 equal-weight
231///   pcurve maps its parameter linearly onto the iso parameter, so the
232///   validator's fraction-matched pcurve consistency check is exact.
233/// * Anything else on a curved sheet goes to the GENERAL image ladder
234///   ([`crate::image_curve::image_curve_pair`]): the composed curve-on-surface
235///   is sampled and fitted on both sheets over ONE parameter set, which is what
236///   makes the pair basis-identical for [`ruled_wall`].  The transfer is exact
237///   in the sense that matters here — unlike a push against a FIXED neighbour,
238///   both sheets move together, so the image of the shared trim IS the
239///   boundary, not an approximation of some other curve.  The ladder refuses
240///   with its measured deviation rather than returning an unvalidated fit.
241fn boundary_images(
242    base_affine: bool,
243    bottom: &NurbsSurface,
244    top: &NurbsSurface,
245    pcurve: &NurbsCurve,
246    eps_u: f64,
247    eps_v: f64,
248    fit_tolerance: f64,
249) -> Result<BoundaryImages, String> {
250    if base_affine {
251        let [q0, q1] = pcurve.domain()?;
252        return Ok(BoundaryImages {
253            bottom: affine_image_curve(bottom, pcurve)?,
254            top: affine_image_curve(top, pcurve)?,
255            t0: q0,
256            t1: q1,
257            dir: true,
258        });
259    }
260    if pcurve.degree == 1 && pcurve.control_points.len() == 2 {
261        let first = pcurve.control_points[0];
262        let second = pcurve.control_points[1];
263        if (first.w - second.w).abs() <= 1e-12 {
264            let (ua, va) = (first.x / first.w, first.y / first.w);
265            let (ub, vb) = (second.x / second.w, second.y / second.w);
266            if (ua - ub).abs() <= eps_u && (va - vb).abs() > eps_v {
267                let u_constant = (ua + ub) * 0.5;
268                return Ok(BoundaryImages {
269                    bottom: bottom.iso_curve_u(u_constant)?,
270                    top: top.iso_curve_u(u_constant)?,
271                    t0: va.min(vb),
272                    t1: va.max(vb),
273                    dir: vb > va,
274                });
275            }
276            if (va - vb).abs() <= eps_v && (ua - ub).abs() > eps_u {
277                let v_constant = (va + vb) * 0.5;
278                return Ok(BoundaryImages {
279                    bottom: bottom.iso_curve_v(v_constant)?,
280                    top: top.iso_curve_v(v_constant)?,
281                    t0: ua.min(ub),
282                    t1: ua.max(ub),
283                    dir: ub > ua,
284                });
285            }
286        }
287    }
288    // The general trim: no closed form, so fit the composed curve on both
289    // sheets and let the ladder measure itself.
290    let (bottom_image, top_image) =
291        image_curve_pair(bottom, top, pcurve, fit_tolerance, "thickenSheet")?;
292    let forward = bottom_image.t0 <= bottom_image.t1;
293    Ok(BoundaryImages {
294        bottom: bottom_image.curve,
295        top: top_image.curve,
296        t0: bottom_image.t0.min(bottom_image.t1),
297        t1: bottom_image.t0.max(bottom_image.t1),
298        dir: forward,
299    })
300}
301
302/// §5.9 THICKEN a TRIMMED sheet region into a closed solid.
303///
304/// `loops` are parameter-space curves on `surface` exactly as FaceRecord
305/// loops store them: the outer loop first, running counter-clockwise in
306/// (u, v), followed by optional hole loops running clockwise (the
307/// `same_sense = true` convention of `validate_uv_wire`).  Each loop's
308/// pcurves must chain tip-to-tail and close; a loop may also be a single
309/// closed pcurve (e.g. a rational circle).
310///
311/// * `symmetric = false`: the solid occupies the space between the sheet and
312///   its offset at signed `thickness` along the sheet normal n = Su × Sv
313///   (negative thickness grows the solid on the −n side).
314/// * `symmetric = true`: the material splits evenly, |thickness|/2 on each
315///   side of the sheet (the sheet becomes the mid-surface).
316///
317/// The caps are the bottom/top offset sheets trimmed by the SAME pcurve
318/// loops (the offset shares the sheet's basis, so pcurves transfer
319/// verbatim); every boundary pcurve contributes one ruled side wall between
320/// its bottom and top 3D images, ruled pointwise at equal pcurve parameter —
321/// for an offset pair that ruling runs along the surface normal, so walls
322/// are exact wherever the full-domain walls were.  Hole loops produce inner
323/// wall tubes and each adds one handle: `genus = loops.len() - 1`.
324///
325/// Every boundary edge is a single EdgeRecord shared by exactly two coedges
326/// (cap + wall); wall-to-wall junction edges are likewise shared.
327///
328/// A general (non-iso) pcurve on a CURVED sheet is no longer refused: its two
329/// 3D images come from [`crate::image_curve::image_curve_pair`], which fits the
330/// composed curve-on-surface on both sheets over ONE shared parameter set (the
331/// basis identity [`ruled_wall`] requires) and refuses only when the fit misses
332/// its measured tolerance.  Unlike a push against a FIXED neighbour, both
333/// sheets here move together, so the image of the shared trim IS the boundary.
334///
335/// Refuses (Err) on: zero/non-finite thickness, closed sheets, open or
336/// misoriented loops, pinched loops, a general pcurve image that cannot be
337/// fitted to tolerance, and offsets through the evolute.
338pub fn thicken_trimmed_sheet(
339    surface: &NurbsSurface,
340    loops: &[Vec<NurbsCurve>],
341    thickness: f64,
342    symmetric: bool,
343) -> Result<BrepSolid, String> {
344    if !thickness.is_finite() || thickness.abs() <= 1e-12 {
345        return Err("thickenSheet: thickness must be a nonzero finite value".into());
346    }
347    if loops.is_empty() || loops.iter().any(|loop_curves| loop_curves.is_empty()) {
348        return Err("thickenSheet: at least one non-empty pcurve loop is required".into());
349    }
350    let (closed_u, closed_v) = surface.closed_directions()?;
351    if closed_u || closed_v {
352        return Err(
353            "thickenSheet: closed sheets are not supported (split the patch at its seam first)"
354                .into(),
355        );
356    }
357    let (distance_bottom, distance_top) = if symmetric {
358        (-thickness.abs() * 0.5, thickness.abs() * 0.5)
359    } else if thickness > 0.0 {
360        (0.0, thickness)
361    } else {
362        (thickness, 0.0)
363    };
364    ensure_offsets_regular(surface, &[distance_bottom, distance_top])?;
365
366    let bottom = offset_sheet(surface, distance_bottom)?;
367    let top = offset_sheet(surface, distance_top)?;
368    let [u0, u1] = surface.domain_u()?;
369    let [v0, v1] = surface.domain_v()?;
370    let uv_tolerance = 1e-7 * (u1 - u0).max(v1 - v0);
371    let minimum_area = 1e-10 * (u1 - u0) * (v1 - v0);
372    let eps_u = 1e-9 * (u1 - u0);
373    let eps_v = 1e-9 * (v1 - v0);
374    let base_affine = surface.is_affine()?;
375    // Fit-accuracy bar for the general image ladder.  `intersection_fit` is
376    // the kernel's single NAMED "maximum geometric error accepted while
377    // fitting" field (`geometry/tolerance.rs`), sized to this sheet — an
378    // accuracy target, deliberately not the validator's loose
379    // `pcurve_acceptance` identity band, which would admit a fit that misses
380    // the true boundary by 2.5% of the model.
381    let sheet_points = bottom
382        .control_points
383        .iter()
384        .flatten()
385        .map(|control| control.point())
386        .collect::<Result<Vec<_>, String>>()?;
387    let fit_tolerance =
388        crate::KernelTolerances::for_scale(crate::model_scale(sheet_points), 1e-7).intersection_fit;
389
390    let mut vertices: Vec<VertexRecord> = Vec::new();
391    let mut edges: Vec<EdgeRecord> = Vec::new();
392    let mut faces: Vec<FaceRecord> = Vec::new();
393    let mut top_cap_loops: Vec<LoopRecord> = Vec::new();
394    let mut bottom_cap_loops: Vec<LoopRecord> = Vec::new();
395    let mut bottom_junction_points: Vec<Vec3> = Vec::new();
396    let mut next_id = 1u64;
397
398    for (loop_index, loop_curves) in loops.iter().enumerate() {
399        let count = loop_curves.len();
400
401        // ---- Parameter-space checks: closure, pinches, orientation. ----
402        let mut starts = Vec::with_capacity(count);
403        let mut ends = Vec::with_capacity(count);
404        for curve in loop_curves {
405            let [q0, q1] = curve.domain()?;
406            starts.push(curve.evaluate(q0)?);
407            ends.push(curve.evaluate(q1)?);
408        }
409        for index in 0..count {
410            let next_index = (index + 1) % count;
411            let gap = planar_gap(ends[index], starts[next_index]);
412            if gap > uv_tolerance {
413                return Err(format!(
414                    "thickenSheet: loop {loop_index} is open — pcurve {index} ends at \
415                     (u={:.6}, v={:.6}) but pcurve {next_index} starts at (u={:.6}, v={:.6}) \
416                     (parameter-space gap {gap:.3e})",
417                    ends[index].x, ends[index].y, starts[next_index].x, starts[next_index].y
418                ));
419            }
420        }
421        if count == 1 {
422            let [q0, q1] = loop_curves[0].domain()?;
423            let middle = loop_curves[0].evaluate((q0 + q1) * 0.5)?;
424            if planar_gap(middle, starts[0]) <= uv_tolerance {
425                return Err(format!(
426                    "thickenSheet: loop {loop_index} is degenerate (zero parameter-space extent)"
427                ));
428            }
429        } else {
430            for index in 0..count {
431                if planar_gap(ends[index], starts[index]) <= uv_tolerance {
432                    return Err(format!(
433                        "thickenSheet: pcurve {index} of loop {loop_index} closes on itself \
434                         inside a multi-curve loop (pinched loop)"
435                    ));
436                }
437            }
438        }
439        let mut area = 0.0;
440        for curve in loop_curves {
441            area += pcurve_signed_area(curve)?;
442        }
443        if loop_index == 0 {
444            if area <= minimum_area {
445                return Err(format!(
446                    "thickenSheet: outer loop must run counter-clockwise in (u, v) \
447                     (signed area {area:.3e})"
448                ));
449            }
450        } else if area >= -minimum_area {
451            return Err(format!(
452                "thickenSheet: hole loop {loop_index} must run clockwise in (u, v) \
453                 (signed area {area:.3e})"
454            ));
455        }
456
457        // ---- Junction vertices: junction j = start of pcurve j. ----
458        let mut bottom_vertex_ids = Vec::with_capacity(count);
459        let mut top_vertex_ids = Vec::with_capacity(count);
460        let mut bottom_points = Vec::with_capacity(count);
461        let mut top_points = Vec::with_capacity(count);
462        for start in &starts {
463            let bottom_point = bottom.evaluate(start.x, start.y)?;
464            let top_point = top.evaluate(start.x, start.y)?;
465            vertices.push(VertexRecord {
466                id: next_id,
467                point: bottom_point,
468            });
469            bottom_vertex_ids.push(next_id);
470            next_id += 1;
471            vertices.push(VertexRecord {
472                id: next_id,
473                point: top_point,
474            });
475            top_vertex_ids.push(next_id);
476            next_id += 1;
477            bottom_points.push(bottom_point);
478            top_points.push(top_point);
479            bottom_junction_points.push(bottom_point);
480        }
481
482        // ---- Boundary edges on both sheets + vertical junction edges. ----
483        let mut images = Vec::with_capacity(count);
484        for curve in loop_curves {
485            images.push(boundary_images(
486                base_affine,
487                &bottom,
488                &top,
489                curve,
490                eps_u,
491                eps_v,
492                fit_tolerance,
493            )?);
494        }
495        let mut bottom_edge_ids = Vec::with_capacity(count);
496        let mut top_edge_ids = Vec::with_capacity(count);
497        for (index, image) in images.iter().enumerate() {
498            let next_index = (index + 1) % count;
499            let (start_j, end_j) = if image.dir {
500                (index, next_index)
501            } else {
502                (next_index, index)
503            };
504            edges.push(EdgeRecord {
505                id: next_id,
506                curve: image.bottom.clone(),
507                t0: image.t0,
508                t1: image.t1,
509                start_vertex_id: bottom_vertex_ids[start_j],
510                end_vertex_id: bottom_vertex_ids[end_j],
511                degenerate: false,
512                name: None,
513            });
514            bottom_edge_ids.push(next_id);
515            next_id += 1;
516            edges.push(EdgeRecord {
517                id: next_id,
518                curve: image.top.clone(),
519                t0: image.t0,
520                t1: image.t1,
521                start_vertex_id: top_vertex_ids[start_j],
522                end_vertex_id: top_vertex_ids[end_j],
523                degenerate: false,
524                name: None,
525            });
526            top_edge_ids.push(next_id);
527            next_id += 1;
528        }
529        let mut vertical_edge_ids = Vec::with_capacity(count);
530        for junction in 0..count {
531            edges.push(EdgeRecord {
532                id: next_id,
533                curve: make_line(bottom_points[junction], top_points[junction])?,
534                t0: 0.0,
535                t1: 1.0,
536                start_vertex_id: bottom_vertex_ids[junction],
537                end_vertex_id: top_vertex_ids[junction],
538                degenerate: false,
539                name: None,
540            });
541            vertical_edge_ids.push(next_id);
542            next_id += 1;
543        }
544
545        // ---- One ruled wall per boundary pcurve. ----
546        //
547        // The wall's s parameter is the edge parameter; its loop traverses
548        // the bottom edge ALONG the stored loop direction.  With the top
549        // sheet at the larger offset, W_w = Δd·n with Δd > 0, so the wall's
550        // natural normal W_s × W_w points to the RIGHT of the walk — which
551        // is outward for a CCW outer loop (material on the left) AND for a
552        // CW hole loop (material on the left, void on the right).  Hence
553        // same_sense = dir uniformly, with the loop winding to match.
554        for (index, image) in images.iter().enumerate() {
555            let next_index = (index + 1) % count;
556            let wall = ruled_wall(&image.bottom, &image.top)?;
557            let (s_start, s_end) = if image.dir {
558                (image.t0, image.t1)
559            } else {
560                (image.t1, image.t0)
561            };
562            let mut coedges = Vec::with_capacity(4);
563            for (edge_id, forward, pcurve) in [
564                (
565                    bottom_edge_ids[index],
566                    image.dir,
567                    parameter_line(s_start, 0.0, s_end, 0.0)?,
568                ),
569                (
570                    vertical_edge_ids[next_index],
571                    true,
572                    parameter_line(s_end, 0.0, s_end, 1.0)?,
573                ),
574                (
575                    top_edge_ids[index],
576                    !image.dir,
577                    parameter_line(s_end, 1.0, s_start, 1.0)?,
578                ),
579                (
580                    vertical_edge_ids[index],
581                    false,
582                    parameter_line(s_start, 1.0, s_start, 0.0)?,
583                ),
584            ] {
585                coedges.push(CoedgeRecord {
586                    id: next_id,
587                    edge_id,
588                    forward,
589                    pcurve,
590                });
591                next_id += 1;
592            }
593            let loop_id = next_id;
594            next_id += 1;
595            faces.push(FaceRecord {
596                id: next_id,
597                surface: wall,
598                same_sense: image.dir,
599                loops: vec![LoopRecord {
600                    id: loop_id,
601                    coedges,
602                }],
603                name: None,
604            });
605            next_id += 1;
606        }
607
608        // ---- Cap loops: verbatim pcurves on top, reversed on bottom. ----
609        let mut top_coedges = Vec::with_capacity(count);
610        for (index, image) in images.iter().enumerate() {
611            top_coedges.push(CoedgeRecord {
612                id: next_id,
613                edge_id: top_edge_ids[index],
614                forward: image.dir,
615                pcurve: loop_curves[index].clone(),
616            });
617            next_id += 1;
618        }
619        top_cap_loops.push(LoopRecord {
620            id: next_id,
621            coedges: top_coedges,
622        });
623        next_id += 1;
624        let mut bottom_coedges = Vec::with_capacity(count);
625        for index in (0..count).rev() {
626            bottom_coedges.push(CoedgeRecord {
627                id: next_id,
628                edge_id: bottom_edge_ids[index],
629                forward: !images[index].dir,
630                pcurve: loop_curves[index].reversed()?,
631            });
632            next_id += 1;
633        }
634        bottom_cap_loops.push(LoopRecord {
635            id: next_id,
636            coedges: bottom_coedges,
637        });
638        next_id += 1;
639    }
640
641    // Coincident junction vertices — v1's coincident-corner refusal
642    // generalized: a repeated junction point pinches the boundary into a
643    // non-manifold vertex (this also catches a hole touching the rim).
644    // The junction ring's own extent, not its distance from the world origin:
645    // the same sheet modelled 5 m out must be judged degenerate on the same
646    // evidence.  For a two-junction boundary this is the pair's separation, so
647    // the test still answers "coincident" only at a true collapse.
648    let scale = crate::model_scale(bottom_junction_points.iter().copied());
649    for first in 0..bottom_junction_points.len() {
650        for second in first + 1..bottom_junction_points.len() {
651            if bottom_junction_points[first]
652                .sub(bottom_junction_points[second])
653                .length()
654                <= 1e-7 * scale
655            {
656                return Err(
657                    "thickenSheet: sheet boundary is degenerate (coincident junction vertices)"
658                        .into(),
659                );
660            }
661        }
662    }
663
664    // Caps: outward is +n on the top sheet, −n on the bottom; the given
665    // loop orientation matches same_sense = true, its reversal the bottom.
666    faces.push(FaceRecord {
667        id: next_id,
668        surface: top,
669        same_sense: true,
670        loops: top_cap_loops,
671        name: None,
672    });
673    next_id += 1;
674    faces.push(FaceRecord {
675        id: next_id,
676        surface: bottom,
677        same_sense: false,
678        loops: bottom_cap_loops,
679        name: None,
680    });
681    next_id += 1;
682
683    let shell_id = next_id;
684    let solid = BrepSolid {
685        id: next_id + 1,
686        vertices,
687        edges,
688        shells: vec![ShellRecord {
689            id: shell_id,
690            faces,
691        }],
692        genus: loops.len() as i64 - 1,
693    };
694    let issues = solid.validate();
695    if !issues.is_empty() {
696        return Err(format!(
697            "thickenSheet: assembled solid failed validation: {issues:?}"
698        ));
699    }
700    let volume = crate::solid_signed_volume(&solid)?;
701    if volume <= 0.0 {
702        return Err(format!(
703            "thickenSheet: internal orientation error (signed volume {volume})"
704        ));
705    }
706    Ok(solid)
707}
708
709/// §5.9 THICKEN a sheet (an open surface patch over its full parameter
710/// domain) into a closed solid.
711///
712/// Thin wrapper over [`thicken_trimmed_sheet`] passing the full-domain
713/// rectangle as the (counter-clockwise) outer loop.  The result is a
714/// genus-0 solid with 8 vertices, 12 edges, and 6 faces (offset caps + four
715/// ruled walls), oriented outward and validated.  Refuses (Err) on:
716/// zero/non-finite thickness, closed sheets (split at the seam first),
717/// degenerate boundaries, and offsets that would self-intersect because a
718/// concave curvature radius is smaller than the offset distance.
719pub fn thicken_face_sheet(
720    surface: &NurbsSurface,
721    thickness: f64,
722    symmetric: bool,
723) -> Result<BrepSolid, String> {
724    if !thickness.is_finite() || thickness.abs() <= 1e-12 {
725        return Err("thickenSheet: thickness must be a nonzero finite value".into());
726    }
727    let [u0, u1] = surface.domain_u()?;
728    let [v0, v1] = surface.domain_v()?;
729    let rectangle = vec![
730        parameter_line(u0, v0, u1, v0)?,
731        parameter_line(u1, v0, u1, v1)?,
732        parameter_line(u1, v1, u0, v1)?,
733        parameter_line(u0, v1, u0, v0)?,
734    ];
735    thicken_trimmed_sheet(surface, &[rectangle], thickness, symmetric)
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use crate::{
742        make_circle, make_cylinder_surface, make_plane, make_revolution, solid_mass_properties,
743        Vec3,
744    };
745    use std::f64::consts::{FRAC_PI_2, PI};
746
747    /// Quarter-cylinder patch: radius `radius` about the +z axis through the
748    /// origin, height `height`, sweeping θ ∈ [0, π/2].  Parametrization
749    /// normal points AWAY from the axis (outward), so positive thickness
750    /// grows the shell outward.
751    fn quarter_cylinder(radius: f64, height: f64) -> NurbsSurface {
752        let generatrix =
753            crate::make_line(Vec3::new(radius, 0.0, 0.0), Vec3::new(radius, 0.0, height)).unwrap();
754        make_revolution(
755            Vec3::default(),
756            Vec3::new(0.0, 0.0, 1.0),
757            &generatrix,
758            FRAC_PI_2,
759        )
760        .unwrap()
761    }
762
763    fn z_range(solid: &BrepSolid) -> (f64, f64) {
764        solid
765            .vertices
766            .iter()
767            .fold((f64::INFINITY, f64::NEG_INFINITY), |(low, high), vertex| {
768                (low.min(vertex.point.z), high.max(vertex.point.z))
769            })
770    }
771
772    /// (1) A planar rectangle sheet thickens to an EXACT box: volume a·b·t
773    /// to 1e-9, full validation, box-count topology.
774    #[test]
775    fn planar_rectangle_thickens_to_exact_box() {
776        let sheet = make_plane(
777            Vec3::new(1.0, 2.0, 3.0),
778            Vec3::new(1.0, 0.0, 0.0),
779            Vec3::new(0.0, 1.0, 0.0),
780            4.0,
781            3.0,
782        )
783        .unwrap();
784        let solid = thicken_face_sheet(&sheet, 0.5, false).unwrap();
785        assert!(solid.validate().is_empty(), "{:?}", solid.validate());
786        assert_eq!(solid.vertices.len(), 8);
787        assert_eq!(solid.edges.len(), 12);
788        assert_eq!(solid.shells[0].faces.len(), 6);
789        assert_eq!(solid.genus, 0);
790        let volume = solid_mass_properties(&solid).unwrap().volume;
791        assert!(
792            (volume - 4.0 * 3.0 * 0.5).abs() < 1e-9,
793            "volume {volume} vs exact 6"
794        );
795        // Sheet normal is +z: the asymmetric slab sits ON the sheet.
796        let (low, high) = z_range(&solid);
797        assert!((low - 3.0).abs() < 1e-12 && (high - 3.5).abs() < 1e-12);
798    }
799
800    /// (2) A quarter-cylinder patch thickens to the exact shell segment:
801    /// V = h · Δθ/2 · (R_out² − R_in²) with Δθ = π/2.
802    #[test]
803    fn quarter_cylinder_patch_thickens_to_exact_shell_segment() {
804        let (radius, height, thickness) = (2.0, 5.0, 0.4);
805        let sheet = quarter_cylinder(radius, height);
806        let solid = thicken_face_sheet(&sheet, thickness, false).unwrap();
807        assert!(solid.validate().is_empty(), "{:?}", solid.validate());
808        assert_eq!(solid.vertices.len(), 8);
809        assert_eq!(solid.edges.len(), 12);
810        assert_eq!(solid.shells[0].faces.len(), 6);
811        let volume = solid_mass_properties(&solid).unwrap().volume;
812        let r_out = radius + thickness;
813        let expected = height * (FRAC_PI_2 / 2.0) * (r_out * r_out - radius * radius);
814        assert!(
815            (volume - expected).abs() < 1e-6 * expected,
816            "volume {volume} vs shell segment {expected}"
817        );
818    }
819
820    /// (3) Symmetric mode splits the thickness across both sides: same
821    /// volume as the one-sided slab, mid-surface = the sheet (z-range
822    /// shifted by t/2), and the cylindrical shell straddles R ± t/2.
823    #[test]
824    fn symmetric_mode_splits_the_thickness_across_both_sides() {
825        let sheet = make_plane(
826            Vec3::new(1.0, 2.0, 3.0),
827            Vec3::new(1.0, 0.0, 0.0),
828            Vec3::new(0.0, 1.0, 0.0),
829            4.0,
830            3.0,
831        )
832        .unwrap();
833        let one_sided = thicken_face_sheet(&sheet, 0.5, false).unwrap();
834        let symmetric = thicken_face_sheet(&sheet, 0.5, true).unwrap();
835        assert!(
836            symmetric.validate().is_empty(),
837            "{:?}",
838            symmetric.validate()
839        );
840        let one_sided_volume = solid_mass_properties(&one_sided).unwrap().volume;
841        let symmetric_volume = solid_mass_properties(&symmetric).unwrap().volume;
842        assert!(
843            (one_sided_volume - symmetric_volume).abs() < 1e-9,
844            "{one_sided_volume} vs {symmetric_volume}"
845        );
846        // The sheet (z = 3) is the MID-surface: material z ∈ [2.75, 3.25].
847        let (low, high) = z_range(&symmetric);
848        assert!((low - 2.75).abs() < 1e-12 && (high - 3.25).abs() < 1e-12);
849
850        // Curved carrier: shell straddles R ± t/2 with the exact volume.
851        let (radius, height, thickness) = (2.0, 5.0, 0.4);
852        let shell = thicken_face_sheet(&quarter_cylinder(radius, height), thickness, true).unwrap();
853        assert!(shell.validate().is_empty(), "{:?}", shell.validate());
854        let volume = solid_mass_properties(&shell).unwrap().volume;
855        let r_in = radius - thickness / 2.0;
856        let r_out = radius + thickness / 2.0;
857        let expected = height * (FRAC_PI_2 / 2.0) * (r_out * r_out - r_in * r_in);
858        assert!(
859            (volume - expected).abs() < 1e-6 * expected,
860            "volume {volume} vs symmetric shell {expected}"
861        );
862        // Radial extent check at a mid-height sample of every vertex ring:
863        // bottom corners at R−t/2, top corners at R+t/2 from the axis.
864        let radial = |point: Vec3| (point.x * point.x + point.y * point.y).sqrt();
865        for vertex in &shell.vertices {
866            let r = radial(vertex.point);
867            assert!(
868                (r - r_in).abs() < 1e-9 || (r - r_out).abs() < 1e-9,
869                "corner radius {r} is neither {r_in} nor {r_out}"
870            );
871        }
872    }
873
874    /// (4) HONEST refusal: thickening a concave sheet past its curvature
875    /// radius (offset through the evolute) errs instead of assembling
876    /// garbage — at the radius exactly, beyond it, and in symmetric mode
877    /// where only the concave-side half-thickness violates.  Closed sheets
878    /// and zero thickness also refuse.
879    #[test]
880    fn refuses_thickness_beyond_the_concave_curvature_radius() {
881        let sheet = quarter_cylinder(2.0, 5.0);
882        // Inward (−n is toward the axis): past the axis.
883        let error = thicken_face_sheet(&sheet, -2.5, false).unwrap_err();
884        assert!(
885            error.contains("self-intersects"),
886            "unexpected refusal message: {error}"
887        );
888        // Exactly the concave radius: the offset degenerates onto the axis.
889        assert!(thicken_face_sheet(&sheet, -2.0, false).is_err());
890        // Symmetric: the −n half-thickness (2.1) exceeds the radius.
891        assert!(thicken_face_sheet(&sheet, 4.2, true).is_err());
892        // A fat but legal symmetric shell still builds (half-thickness 1.5 < 2).
893        let fat = thicken_face_sheet(&sheet, 3.0, true).unwrap();
894        let volume = solid_mass_properties(&fat).unwrap().volume;
895        let expected = 5.0 * (FRAC_PI_2 / 2.0) * (3.5f64 * 3.5 - 0.5 * 0.5);
896        assert!(
897            (volume - expected).abs() < 1e-6 * expected,
898            "volume {volume} vs fat shell {expected}"
899        );
900        // Closed sheets are refused loudly (v1: split at the seam first).
901        let closed =
902            make_cylinder_surface(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap();
903        let error = thicken_face_sheet(&closed, 0.5, false).unwrap_err();
904        assert!(error.contains("closed"), "unexpected message: {error}");
905        // Zero thickness is not a solid.
906        assert!(thicken_face_sheet(&sheet, 0.0, false).is_err());
907    }
908
909    /// Counter-clockwise rectangle loop over [0, width] × [0, height] in
910    /// parameter space (the outer-loop convention for same_sense = true).
911    fn rectangle_loop(width: f64, height: f64) -> Vec<NurbsCurve> {
912        vec![
913            parameter_line(0.0, 0.0, width, 0.0).unwrap(),
914            parameter_line(width, 0.0, width, height).unwrap(),
915            parameter_line(width, height, 0.0, height).unwrap(),
916            parameter_line(0.0, height, 0.0, 0.0).unwrap(),
917        ]
918    }
919
920    /// (5) Trimmed sheet with a hole: a 4×3 planar rectangle with an exact
921    /// rational circle pcurve hole thickens to a washer-like slab —
922    /// genus-1, Euler-clean, with the exact volume (A_rect − π·r²)·t.
923    #[test]
924    fn planar_rectangle_with_circular_hole_thickens_to_washer_slab() {
925        let sheet = make_plane(
926            Vec3::new(1.0, 2.0, 3.0),
927            Vec3::new(1.0, 0.0, 0.0),
928            Vec3::new(0.0, 1.0, 0.0),
929            4.0,
930            3.0,
931        )
932        .unwrap();
933        // Hole loops run CLOCKWISE in (u, v): a circle about −z.
934        let hole = make_circle(Vec3::new(2.0, 1.5, 0.0), Vec3::new(0.0, 0.0, -1.0), 0.8).unwrap();
935        let solid =
936            thicken_trimmed_sheet(&sheet, &[rectangle_loop(4.0, 3.0), vec![hole]], 0.5, false)
937                .unwrap();
938        assert!(solid.validate().is_empty(), "{:?}", solid.validate());
939        // Outer ring: 8 vertices, 12 edges, 4 walls.  Hole: 2 seam
940        // vertices, 3 edges (two circles + one vertical seam), 1 tube.
941        assert_eq!(solid.vertices.len(), 10);
942        assert_eq!(solid.edges.len(), 15);
943        assert_eq!(solid.shells[0].faces.len(), 7);
944        assert_eq!(solid.genus, 1, "one hole = one handle");
945        let volume = solid_mass_properties(&solid).unwrap().volume;
946        let expected = (4.0 * 3.0 - PI * 0.8 * 0.8) * 0.5;
947        assert!(
948            (volume - expected).abs() < 1e-6,
949            "volume {volume} vs washer slab {expected}"
950        );
951        // Asymmetric slab sits ON the sheet (z ∈ [3, 3.5]) — hole rim too.
952        let (low, high) = z_range(&solid);
953        assert!((low - 3.0).abs() < 1e-12 && (high - 3.5).abs() < 1e-12);
954    }
955
956    /// (6) A planar disk (single closed rational circle outer loop)
957    /// thickens to the exact cylinder π·r²·t with the minimal seam
958    /// topology: 2 vertices, 3 edges, 3 faces.
959    #[test]
960    fn planar_disk_thickens_to_exact_cylinder() {
961        let sheet = make_plane(
962            Vec3::new(-1.0, -2.0, 1.0),
963            Vec3::new(1.0, 0.0, 0.0),
964            Vec3::new(0.0, 1.0, 0.0),
965            4.0,
966            4.0,
967        )
968        .unwrap();
969        // The outer loop runs COUNTER-clockwise: a circle about +z.
970        let disk = make_circle(Vec3::new(2.0, 2.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.5).unwrap();
971        let solid = thicken_trimmed_sheet(&sheet, &[vec![disk]], 0.7, false).unwrap();
972        assert!(solid.validate().is_empty(), "{:?}", solid.validate());
973        assert_eq!(solid.vertices.len(), 2);
974        assert_eq!(solid.edges.len(), 3);
975        assert_eq!(solid.shells[0].faces.len(), 3);
976        assert_eq!(solid.genus, 0);
977        let volume = solid_mass_properties(&solid).unwrap().volume;
978        let expected = PI * 1.5 * 1.5 * 0.7;
979        assert!(
980            (volume - expected).abs() < 1e-6 * expected,
981            "volume {volume} vs cylinder {expected}"
982        );
983    }
984
985    /// (7) A rectangular sub-window pcurve loop on a CURVED sheet (quarter
986    /// cylinder) thickens to the exact shell sub-segment
987    /// V = Δz · Δθ/2 · (R_out² − R_in²), where Δθ comes from the rational
988    /// arc's (non-linear) angle parametrization.
989    #[test]
990    fn curved_sheet_sub_window_thickens_to_exact_shell_segment() {
991        let (radius, height, thickness) = (2.0, 5.0, 0.4);
992        let sheet = quarter_cylinder(radius, height);
993        let window = vec![
994            parameter_line(0.25, 0.2, 0.75, 0.2).unwrap(),
995            parameter_line(0.75, 0.2, 0.75, 0.9).unwrap(),
996            parameter_line(0.75, 0.9, 0.25, 0.9).unwrap(),
997            parameter_line(0.25, 0.9, 0.25, 0.2).unwrap(),
998        ];
999        let solid = thicken_trimmed_sheet(&sheet, &[window], thickness, false).unwrap();
1000        assert!(solid.validate().is_empty(), "{:?}", solid.validate());
1001        assert_eq!(solid.vertices.len(), 8);
1002        assert_eq!(solid.edges.len(), 12);
1003        assert_eq!(solid.shells[0].faces.len(), 6);
1004        assert_eq!(solid.genus, 0);
1005        // The rational quadratic arc is NOT linear in angle, so the window's
1006        // sweep is θ(0.75) − θ(0.25) from the actual parametrization.
1007        let at = |u: f64| sheet.evaluate(u, 0.0).unwrap();
1008        let sweep = at(0.75).y.atan2(at(0.75).x) - at(0.25).y.atan2(at(0.25).x);
1009        let r_out = radius + thickness;
1010        let expected = (0.9 - 0.2) * height * (sweep / 2.0) * (r_out * r_out - radius * radius);
1011        let volume = solid_mass_properties(&solid).unwrap().volume;
1012        assert!(
1013            (volume - expected).abs() < 1e-6 * expected,
1014            "volume {volume} vs shell sub-segment {expected}"
1015        );
1016        // Every junction vertex sits on one of the two shell radii.
1017        for vertex in &solid.vertices {
1018            let r = (vertex.point.x * vertex.point.x + vertex.point.y * vertex.point.y).sqrt();
1019            assert!(
1020                (r - radius).abs() < 1e-9 || (r - r_out).abs() < 1e-9,
1021                "vertex radius {r} is neither {radius} nor {r_out}"
1022            );
1023        }
1024    }
1025
1026    /// (8) HONEST refusals for trim loops: open chains, wrong windings, and
1027    /// general (non-iso) pcurves on curved sheets all err with a message
1028    /// naming the offence instead of assembling garbage.
1029    #[test]
1030    fn refuses_open_and_misoriented_trim_loops() {
1031        let sheet = make_plane(
1032            Vec3::new(0.0, 0.0, 0.0),
1033            Vec3::new(1.0, 0.0, 0.0),
1034            Vec3::new(0.0, 1.0, 0.0),
1035            4.0,
1036            3.0,
1037        )
1038        .unwrap();
1039        // An open chain: the last pcurve does not return to the start.
1040        let open_chain = vec![
1041            parameter_line(0.0, 0.0, 4.0, 0.0).unwrap(),
1042            parameter_line(4.0, 0.0, 4.0, 3.0).unwrap(),
1043            parameter_line(4.0, 3.0, 1.0, 1.0).unwrap(),
1044        ];
1045        let error = thicken_trimmed_sheet(&sheet, &[open_chain], 0.5, false).unwrap_err();
1046        assert!(error.contains("open"), "unexpected message: {error}");
1047        // A clockwise OUTER loop violates the stored-loop convention.
1048        let clockwise = vec![
1049            parameter_line(0.0, 0.0, 0.0, 3.0).unwrap(),
1050            parameter_line(0.0, 3.0, 4.0, 3.0).unwrap(),
1051            parameter_line(4.0, 3.0, 4.0, 0.0).unwrap(),
1052            parameter_line(4.0, 0.0, 0.0, 0.0).unwrap(),
1053        ];
1054        let error = thicken_trimmed_sheet(&sheet, &[clockwise], 0.5, false).unwrap_err();
1055        assert!(
1056            error.contains("counter-clockwise"),
1057            "unexpected message: {error}"
1058        );
1059        // A counter-clockwise HOLE loop is equally misoriented.
1060        let ccw_hole =
1061            make_circle(Vec3::new(2.0, 1.5, 0.0), Vec3::new(0.0, 0.0, 1.0), 0.8).unwrap();
1062        let error = thicken_trimmed_sheet(
1063            &sheet,
1064            &[rectangle_loop(4.0, 3.0), vec![ccw_hole]],
1065            0.5,
1066            false,
1067        )
1068        .unwrap_err();
1069        assert!(error.contains("clockwise"), "unexpected message: {error}");
1070        // No loops at all is not a trim.
1071        assert!(thicken_trimmed_sheet(&sheet, &[], 0.5, false).is_err());
1072    }
1073
1074    /// (8b) A GENERAL (non-iso) trim on a CURVED sheet used to be refused with
1075    /// "must be iso-parameter line segments".  The general image ladder builds
1076    /// it: the two slanted pcurves get fitted images on both sheets, the
1077    /// axis-parallel one still takes the exact iso shortcut, and the result is
1078    /// a closed valid solid.  Classification: previously-refused, now built.
1079    #[test]
1080    fn thickens_a_general_non_iso_trim_on_a_curved_sheet() {
1081        let curved = quarter_cylinder(2.0, 5.0);
1082        let diagonal = vec![
1083            parameter_line(0.2, 0.2, 0.8, 0.4).unwrap(),
1084            parameter_line(0.8, 0.4, 0.8, 0.8).unwrap(),
1085            parameter_line(0.8, 0.8, 0.2, 0.2).unwrap(),
1086        ];
1087        let solid = thicken_trimmed_sheet(&curved, &[diagonal], 0.3, false)
1088            .expect("a general trim on a curved sheet");
1089        let issues = solid.validate();
1090        assert!(issues.is_empty(), "validate: {issues:?}");
1091        assert_eq!(
1092            solid.shells[0].faces.len(),
1093            5,
1094            "2 caps + 3 walls, one per boundary pcurve"
1095        );
1096        let volume = crate::solid_signed_volume(&solid).unwrap().abs();
1097        assert!(volume > 0.0, "volume {volume}");
1098        // The wall between two fitted images only exists if the pair shares a
1099        // basis: `ruled_wall` refuses a mismatch, so five faces IS that proof.
1100        // Independently, every boundary edge must trace its own pcurve on the
1101        // sheet it bounds — which is what `validate` just measured.
1102    }
1103
1104    /// (8c) A RATIONAL closed pcurve — a circular hole — on a CURVED sheet.
1105    /// On a flat sheet this is the affine lane and yields an exact cylindrical
1106    /// tube; on a curved one the hole's 3D image is a genuinely non-rational
1107    /// curve, which is the shape only the general tier can build.  Also the
1108    /// genus check: one hole is one handle.
1109    #[test]
1110    fn thickens_a_circular_hole_in_a_curved_sheet() {
1111        let curved = quarter_cylinder(2.0, 5.0);
1112        let [u0, u1] = curved.domain_u().unwrap();
1113        let [v0, v1] = curved.domain_v().unwrap();
1114        let outer = vec![
1115            parameter_line(u0, v0, u1, v0).unwrap(),
1116            parameter_line(u1, v0, u1, v1).unwrap(),
1117            parameter_line(u1, v1, u0, v1).unwrap(),
1118            parameter_line(u0, v1, u0, v0).unwrap(),
1119        ];
1120        // Clockwise in (u, v) — the hole convention — so wind the circle about
1121        // −z in parameter space.
1122        let hole = make_circle(
1123            Vec3::new(0.5 * (u0 + u1), 0.5 * (v0 + v1), 0.0),
1124            Vec3::new(0.0, 0.0, -1.0),
1125            0.25 * (u1 - u0).min(v1 - v0),
1126        )
1127        .unwrap();
1128        let solid = thicken_trimmed_sheet(&curved, &[outer, vec![hole]], 0.2, false)
1129            .expect("a circular hole in a curved sheet");
1130        let issues = solid.validate();
1131        assert!(issues.is_empty(), "validate: {issues:?}");
1132        // 2 caps + 4 outer walls + 1 hole tube.
1133        assert_eq!(solid.shells[0].faces.len(), 7);
1134    }
1135}