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