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