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