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