brep_kernel/offset/offset.rs
1use crate::fit::solve_dense;
2use crate::topology::{BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord, VertexRecord};
3use crate::{
4 interpolate_curve, measure_edge_against_pcurve_image,
5 measure_surface_fit_against_pointwise_offset, offset_construction_band, solid_model_scale,
6 vertex_endpoint_gap, vertex_tolerance_from_edges, KnotVector, MeasuredTolerance, NurbsCurve,
7 NurbsSurface, OffsetEvaluator, OffsetNormal, Vec2, Vec3, Vec4,
8};
9use rustc_hash::FxHashMap as HashMap;
10use serde::Serialize;
11
12fn domains(surface: &NurbsSurface) -> Result<([f64; 2], [f64; 2]), String> {
13 Ok((
14 KnotVector::new(surface.knots_u.clone(), surface.degree_u)?.domain(),
15 KnotVector::new(surface.knots_v.clone(), surface.degree_v)?.domain(),
16 ))
17}
18
19/// The normal `offset_surface` offsets along: the face's oriented normal with
20/// the singular-row recovery, i.e. the shared evaluator's
21/// [`OffsetNormal::FaceStable`] lane, whose body this function used to be.
22fn stable_face_normal(face: &FaceRecord, u: f64, v: f64) -> Result<Vec3, String> {
23 OffsetEvaluator::new(
24 "offset_surface",
25 &face.surface,
26 OffsetNormal::FaceStable {
27 same_sense: face.same_sense,
28 },
29 )
30 .normal(u, v)
31}
32
33fn greville_parameters(knots: &KnotVector) -> Vec<f64> {
34 let mut parameters = (0..knots.control_point_count())
35 .map(|index| {
36 knots.knots[index + 1..=index + knots.degree]
37 .iter()
38 .sum::<f64>()
39 / knots.degree as f64
40 })
41 .collect::<Vec<_>>();
42 let domain = knots.domain();
43 parameters[0] = domain[0];
44 *parameters.last_mut().unwrap() = domain[1];
45 parameters
46}
47
48/// Rational collocation matrix: rows are the rational basis functions
49/// R_i(t) = N_i(t)·w_i / Σ_k N_k(t)·w_k evaluated at each parameter. With
50/// uniform weights this reduces to the ordinary B-spline collocation matrix.
51fn collocation_matrix(knots: &KnotVector, parameters: &[f64], weights: &[f64]) -> Vec<Vec<f64>> {
52 parameters
53 .iter()
54 .map(|parameter| {
55 let mut row = vec![0.0; knots.control_point_count()];
56 let span = knots.find_span(*parameter);
57 for (offset, value) in knots
58 .basis_functions(span, *parameter)
59 .into_iter()
60 .enumerate()
61 {
62 let index = span - knots.degree + offset;
63 row[index] = value * weights[index];
64 }
65 let denominator: f64 = row.iter().sum();
66 if denominator.abs() > 0.0 {
67 for value in &mut row {
68 *value /= denominator;
69 }
70 }
71 row
72 })
73 .collect()
74}
75
76/// Split the weight grid into per-direction factors when it is separable
77/// (w_ij = a_i·b_j), which covers every tensor surface built from rational
78/// profile/rail curves (cylinders, cones, spheres, tori, revolves).
79fn separable_weights(weights: &[Vec<f64>]) -> Option<(Vec<f64>, Vec<f64>)> {
80 let first_row = weights.first()?;
81 let anchor = *first_row.first()?;
82 if anchor.abs() <= 1e-12 {
83 return None;
84 }
85 let a: Vec<f64> = weights.iter().map(|row| row[0]).collect();
86 let b: Vec<f64> = first_row.iter().map(|w| w / anchor).collect();
87 for (i, row) in weights.iter().enumerate() {
88 for (j, &w) in row.iter().enumerate() {
89 if (w - a[i] * b[j]).abs() > 1e-10 * (1.0 + w.abs()) {
90 return None;
91 }
92 }
93 }
94 Some((a, b))
95}
96
97/// Interpolate the sample grid in the SOURCE surface's rational basis (same
98/// knots and weights). When the true offset is representable in that basis —
99/// planes, cylinders, cones, spheres, tori — collocation at the Greville grid
100/// recovers it EXACTLY, so offset carriers stay real analytic surfaces
101/// instead of non-rational approximations with span-scale wobble.
102fn interpolate_tensor(
103 knot_u: &KnotVector,
104 knot_v: &KnotVector,
105 parameters_u: &[f64],
106 parameters_v: &[f64],
107 samples: &[Vec<Vec3>],
108 weights: &[Vec<f64>],
109) -> Result<Vec<Vec<Vec4>>, String> {
110 let count_u = parameters_u.len();
111 let count_v = parameters_v.len();
112 if let Some((weights_u, weights_v)) = separable_weights(weights) {
113 let matrix_u = collocation_matrix(knot_u, parameters_u, &weights_u);
114 let matrix_v = collocation_matrix(knot_v, parameters_v, &weights_v);
115 let mut intermediate = vec![vec![Vec3::default(); count_v]; count_u];
116 for column in 0..count_v {
117 let solve_axis = |axis: fn(Vec3) -> f64| {
118 solve_dense(
119 matrix_u.clone(),
120 samples.iter().map(|row| axis(row[column])).collect(),
121 )
122 };
123 let x = solve_axis(|point| point.x)?;
124 let y = solve_axis(|point| point.y)?;
125 let z = solve_axis(|point| point.z)?;
126 for row in 0..count_u {
127 intermediate[row][column] = Vec3::new(x[row], y[row], z[row]);
128 }
129 }
130 let mut controls = vec![vec![Vec4::from_point(Vec3::default(), 1.0); count_v]; count_u];
131 for row in 0..count_u {
132 let solve_axis = |axis: fn(Vec3) -> f64| {
133 solve_dense(
134 matrix_v.clone(),
135 intermediate[row].iter().copied().map(axis).collect(),
136 )
137 };
138 let x = solve_axis(|point| point.x)?;
139 let y = solve_axis(|point| point.y)?;
140 let z = solve_axis(|point| point.z)?;
141 for column in 0..count_v {
142 controls[row][column] = Vec4::from_point(
143 Vec3::new(x[column], y[column], z[column]),
144 weights[row][column],
145 );
146 }
147 }
148 return Ok(controls);
149 }
150
151 // Non-separable weights: solve the full tensor collocation system with
152 // the exact 2D rational basis. Nets are small in practice.
153 let unknowns = count_u * count_v;
154 let mut matrix = vec![vec![0.0; unknowns]; unknowns];
155 for (k, &u) in parameters_u.iter().enumerate() {
156 let span_u = knot_u.find_span(u);
157 let basis_u = knot_u.basis_functions(span_u, u);
158 for (l, &v) in parameters_v.iter().enumerate() {
159 let span_v = knot_v.find_span(v);
160 let basis_v = knot_v.basis_functions(span_v, v);
161 let row = &mut matrix[k * count_v + l];
162 let mut denominator = 0.0;
163 for (du, value_u) in basis_u.iter().enumerate() {
164 let i = span_u - knot_u.degree + du;
165 for (dv, value_v) in basis_v.iter().enumerate() {
166 let j = span_v - knot_v.degree + dv;
167 let entry = value_u * value_v * weights[i][j];
168 row[i * count_v + j] = entry;
169 denominator += entry;
170 }
171 }
172 if denominator.abs() > 0.0 {
173 for value in row.iter_mut() {
174 *value /= denominator;
175 }
176 }
177 }
178 }
179 let solve_axis = |axis: fn(Vec3) -> f64| {
180 solve_dense(
181 matrix.clone(),
182 samples
183 .iter()
184 .flat_map(|row| row.iter().copied().map(axis))
185 .collect(),
186 )
187 };
188 let x = solve_axis(|point| point.x)?;
189 let y = solve_axis(|point| point.y)?;
190 let z = solve_axis(|point| point.z)?;
191 let mut controls = vec![vec![Vec4::from_point(Vec3::default(), 1.0); count_v]; count_u];
192 for row in 0..count_u {
193 for column in 0..count_v {
194 let index = row * count_v + column;
195 controls[row][column] = Vec4::from_point(
196 Vec3::new(x[index], y[index], z[index]),
197 weights[row][column],
198 );
199 }
200 }
201 Ok(controls)
202}
203
204/// Which branch of [`offset_surface`] built a carrier — and, with it, whether
205/// comparing that carrier against the pointwise offset at the same `(u, v)` is
206/// even the right question.
207///
208/// Recorded rather than inferred, in the pattern `offset/reintersect.rs`
209/// established for its own two lanes: two of this function's three branches
210/// deliberately move the result off the pointwise offset, and a measurement that
211/// did not know which branch ran would report a designed divergence as a fit
212/// error.
213#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
214pub enum OffsetSurfaceLane {
215 /// A rigid control-net shift of an affine (plane-like) carrier. The normal
216 /// is constant, so the shifted net IS the pointwise offset — exact by
217 /// construction, with no fit to measure.
218 Affine,
219 /// Greville collocation of the pointwise offset. `S_fit(u, v)` is meant to
220 /// BE `offset(u, v)`, and the distance between them is the fit error this
221 /// slice measures.
222 #[default]
223 Fit,
224 /// The result was deliberately moved off the pointwise offset: the planar /
225 /// ruled EXTENSION (which grows the carrier past its source rim, so the same
226 /// `(u, v)` names a different point) or the apex-cone PINCH RETRIM (which
227 /// pulls the crossed sample row back to the offset cone's own apex). Both
228 /// are correct and intended; neither is comparable pointwise.
229 Reparameterised,
230}
231
232/// A fitted offset carrier together with what its construction measured about
233/// itself.
234#[derive(Clone, Debug)]
235pub struct MeasuredOffsetSurface {
236 pub surface: NurbsSurface,
237 pub lane: OffsetSurfaceLane,
238 /// `max ‖S_fit(u, v) − offset(u, v)‖` over
239 /// [`crate::measure_surface_fit_against_pointwise_offset`]'s grid, against
240 /// the band it was judged with.
241 ///
242 /// `None` for [`OffsetSurfaceLane::Reparameterised`] — see that variant.
243 pub fit: Option<MeasuredTolerance>,
244}
245
246/// How far an offset carrier's TRIM grows past the source face at each of
247/// its four parametric sides, in world units.
248///
249/// A carrier is trimmed by the parametric IMAGE of the source loops, so the
250/// growth is realised by reshaping the carrier surface's net while the cloned
251/// pcurves stay put: an affine plane's 2x2 net slides along the plane, a ruled
252/// (linear-v) net stretches each sampled ruling. Both are solved so that the
253/// trim's own uv bounding box moves by exactly these amounts — a trim that
254/// occupies a sub-range of its surface's domain (every booleaned or
255/// blend-trimmed face) grows by the same distance as one that spans it. The
256/// legacy `planar_extension: f64` entry points are the uniform case.
257#[derive(Clone, Copy, Debug, PartialEq)]
258pub struct CarrierExtension {
259 pub u_min: f64,
260 pub u_max: f64,
261 pub v_min: f64,
262 pub v_max: f64,
263}
264
265impl CarrierExtension {
266 pub const NONE: CarrierExtension = CarrierExtension {
267 u_min: 0.0,
268 u_max: 0.0,
269 v_min: 0.0,
270 v_max: 0.0,
271 };
272
273 pub fn uniform(amount: f64) -> CarrierExtension {
274 CarrierExtension {
275 u_min: amount,
276 u_max: amount,
277 v_min: amount,
278 v_max: amount,
279 }
280 }
281
282 pub fn is_active(&self) -> bool {
283 self.u_min > 0.0 || self.u_max > 0.0 || self.v_min > 0.0 || self.v_max > 0.0
284 }
285
286 fn extends_v(&self) -> bool {
287 self.v_min > 0.0 || self.v_max > 0.0
288 }
289}
290
291/// The trim's uv bounding box as FRACTIONS of the surface domain:
292/// `([ta_u, tb_u], [ta_v, tb_v])`, each in `[0, 1]`. Sampled over every
293/// coedge pcurve of every loop.
294fn trim_domain_fractions(
295 face: &FaceRecord,
296 [u0, u1]: [f64; 2],
297 [v0, v1]: [f64; 2],
298) -> Result<([f64; 2], [f64; 2]), String> {
299 let mut low = Vec2 {
300 x: f64::INFINITY,
301 y: f64::INFINITY,
302 };
303 let mut high = Vec2 {
304 x: f64::NEG_INFINITY,
305 y: f64::NEG_INFINITY,
306 };
307 for coedge in face
308 .loops
309 .iter()
310 .flat_map(|loop_record| &loop_record.coedges)
311 {
312 let [p0, p1] = coedge.pcurve.domain()?;
313 for sample in 0..=24 {
314 let uv = coedge
315 .pcurve
316 .evaluate(p0 + (p1 - p0) * sample as f64 / 24.0)?;
317 low.x = low.x.min(uv.x);
318 low.y = low.y.min(uv.y);
319 high.x = high.x.max(uv.x);
320 high.y = high.y.max(uv.y);
321 }
322 }
323 let fraction = |value: f64, start: f64, end: f64| {
324 let span = end - start;
325 if span.abs() <= f64::EPSILON || !value.is_finite() {
326 return None;
327 }
328 Some(((value - start) / span).clamp(0.0, 1.0))
329 };
330 let u = match (fraction(low.x, u0, u1), fraction(high.x, u0, u1)) {
331 (Some(a), Some(b)) if b - a > 1e-6 => [a, b],
332 _ => [0.0, 1.0],
333 };
334 let v = match (fraction(low.y, v0, v1), fraction(high.y, v0, v1)) {
335 (Some(a), Some(b)) if b - a > 1e-6 => [a, b],
336 _ => [0.0, 1.0],
337 };
338 Ok((u, v))
339}
340
341/// How far the surface's domain ENDS must move (`(back, forward)`: the start
342/// end backwards, the far end forwards, world units) so that a trim occupying
343/// the domain fractions `[ta, tb]` grows by exactly `grow_min` at its start and
344/// `grow_max` at its end when its pcurves are left untouched. A point at
345/// fraction `t` of a linearly re-mapped domain moves by
346/// `-(1 - t)·back + t·forward`; solving that at `ta` and `tb` gives the pair
347/// below. It reduces to `(grow_min, grow_max)` for a full-domain trim, and
348/// never shrinks either end (both results are non-negative for non-negative
349/// inputs).
350fn domain_end_moves(grow_min: f64, grow_max: f64, [ta, tb]: [f64; 2]) -> (f64, f64) {
351 let span = tb - ta;
352 if span <= 1e-6 {
353 return (grow_min, grow_max);
354 }
355 let rate = (grow_min + grow_max) / span;
356 let back = grow_min + ta * rate;
357 let forward = (1.0 - ta) * rate - grow_min;
358 (back.max(0.0), forward.max(0.0))
359}
360
361/// Construct the same fitted offset carrier surface as the reference shell
362/// implementation. Positive distance follows its convention and moves
363/// opposite the face's outward normal.
364pub fn offset_surface(
365 face: &FaceRecord,
366 distance: f64,
367 planar_extension: f64,
368) -> Result<NurbsSurface, String> {
369 offset_surface_with_lane(face, distance, &CarrierExtension::uniform(planar_extension))
370 .map(|(surface, _)| surface)
371}
372
373/// [`offset_surface`], plus the deviation MEASURED between the carrier it built
374/// and the pointwise offset that carrier approximates.
375///
376/// The surface is bit-identical to [`offset_surface`]'s — this calls the same
377/// body and adds a read-only pass afterwards. Nothing here can change the
378/// carrier: measuring the deviation AFTER the carrier is built records what
379/// happened; the size-derived band stays the floor and the bar it is judged
380/// against. A measurement never widens a band.
381pub fn offset_surface_measured(
382 face: &FaceRecord,
383 distance: f64,
384 planar_extension: f64,
385 band: f64,
386) -> Result<MeasuredOffsetSurface, String> {
387 offset_surface_measured_sided(
388 face,
389 distance,
390 &CarrierExtension::uniform(planar_extension),
391 band,
392 )
393}
394
395/// [`offset_surface_measured`] with a per-side [`CarrierExtension`].
396pub fn offset_surface_measured_sided(
397 face: &FaceRecord,
398 distance: f64,
399 extension: &CarrierExtension,
400 band: f64,
401) -> Result<MeasuredOffsetSurface, String> {
402 let (surface, lane) = offset_surface_with_lane(face, distance, extension)?;
403 let fit = match lane {
404 OffsetSurfaceLane::Affine => Some(MeasuredTolerance::exact(band)),
405 OffsetSurfaceLane::Fit => Some(measure_surface_fit_against_pointwise_offset(
406 &face.surface,
407 face.same_sense,
408 &surface,
409 distance,
410 band,
411 )?),
412 OffsetSurfaceLane::Reparameterised => None,
413 };
414 Ok(MeasuredOffsetSurface { surface, lane, fit })
415}
416
417fn offset_surface_with_lane(
418 face: &FaceRecord,
419 distance: f64,
420 extension: &CarrierExtension,
421) -> Result<(NurbsSurface, OffsetSurfaceLane), String> {
422 let source = &face.surface;
423 let extension_active = extension.is_active();
424 if source.is_affine()? {
425 let ([u0, u1], [v0, v1]) = domains(source)?;
426 let normal = stable_face_normal(face, (u0 + u1) / 2.0, (v0 + v1) / 2.0)?;
427 let shift = normal.scale(-distance);
428 let mut points = source
429 .control_points
430 .iter()
431 .map(|row| {
432 row.iter()
433 .map(|control| Ok(control.point()?.add(shift)))
434 .collect::<Result<Vec<_>, String>>()
435 })
436 .collect::<Result<Vec<_>, String>>()?;
437 if extension_active {
438 let p00 = points[0][0];
439 let p01 = points[0][1];
440 let p10 = points[1][0];
441 let direction_u = p10.sub(p00).normalized()?;
442 let direction_v = p01.sub(p00).normalized()?;
443 // The net slides while the cloned pcurves stay put, so a trim
444 // that spans only part of the domain would grow by less than
445 // asked (a 20-wide face trimmed to [0,16] by a blend moved its
446 // x=16 edge 0.6 for a 1.0 pad). Solve the slide for the TRIM's
447 // own bounding box instead.
448 let (trim_u, trim_v) = trim_domain_fractions(face, [u0, u1], [v0, v1])?;
449 let (back_u, forward_u) = domain_end_moves(extension.u_min, extension.u_max, trim_u);
450 let (back_v, forward_v) = domain_end_moves(extension.v_min, extension.v_max, trim_v);
451 points[0][0] = p00
452 .sub(direction_u.scale(back_u))
453 .sub(direction_v.scale(back_v));
454 points[0][1] = p01
455 .sub(direction_u.scale(back_u))
456 .add(direction_v.scale(forward_v));
457 points[1][0] = p10
458 .add(direction_u.scale(forward_u))
459 .sub(direction_v.scale(back_v));
460 points[1][1] = points[1][1]
461 .add(direction_u.scale(forward_u))
462 .add(direction_v.scale(forward_v));
463 }
464 let controls = points
465 .into_iter()
466 .enumerate()
467 .map(|(row, points)| {
468 points
469 .into_iter()
470 .enumerate()
471 .map(|(column, point)| {
472 Vec4::from_point(point, source.control_points[row][column].w)
473 })
474 .collect()
475 })
476 .collect();
477 // The extension slides the control net along the plane, so the same
478 // `(u, v)` no longer names the pointwise offset of the same source
479 // point; without it the shift is rigid and exact.
480 let lane = if extension_active {
481 OffsetSurfaceLane::Reparameterised
482 } else {
483 OffsetSurfaceLane::Affine
484 };
485 return Ok((
486 NurbsSurface::new(
487 source.degree_u,
488 source.degree_v,
489 source.knots_u.clone(),
490 source.knots_v.clone(),
491 controls,
492 )?,
493 lane,
494 ));
495 }
496
497 let knot_u = KnotVector::new(source.knots_u.clone(), source.degree_u)?;
498 let knot_v = KnotVector::new(source.knots_v.clone(), source.degree_v)?;
499 let parameters_u = greville_parameters(&knot_u);
500 let parameters_v = greville_parameters(&knot_v);
501 // The Greville sample grid IS a pointwise offset evaluation — this fit is
502 // the shared evaluator's consumer, not its peer. `offset_surface`'s
503 // positive distance moves OPPOSITE the face normal while the evaluator's
504 // moves ALONG it, so the negation happens once, here, with a name on it
505 // (audit §4.1's four hand negations get no fifth).
506 let evaluator = OffsetEvaluator::new(
507 "offset_surface",
508 source,
509 OffsetNormal::FaceStable {
510 same_sense: face.same_sense,
511 },
512 );
513 let mut samples = Vec::new();
514 for &u in ¶meters_u {
515 let mut row = Vec::new();
516 for &v in ¶meters_v {
517 row.push(evaluator.at(u, v, -distance)?.point);
518 }
519 samples.push(row);
520 }
521 // APEX-CONE PINCH RETRIM: offsetting an apex cone INWARD moves each
522 // ruling past the axis — the sampled far row becomes a ring on the far
523 // side (radius d·cos half-angle, mirrored through the axis) and the
524 // offset surface self-pinches inside the v-domain. The genuine cavity
525 // ends AT the pinch (the offset cone's own apex). For a linear-v net
526 // (two sample rows — every made/booleaned cone) the pinch lies on each
527 // ruling at the fraction where the radial vector vanishes: detect the
528 // inversion (far-row radials anti-parallel to near-row radials about the
529 // row centroids) and pull the far row back to the pinch point, so the
530 // fitted surface ends in a proper degenerate apex row instead of a
531 // parasitic inverted tip ending in an unweldable ring.
532 // Both blocks below move the sample grid OFF the pointwise offset on
533 // purpose. Recording that is what lets `offset_surface_measured` decline to
534 // report a designed divergence as a fit error.
535 let mut reparameterised = false;
536 if parameters_v.len() == 2 && parameters_u.len() >= 3 {
537 let centroid = |column: usize| {
538 let mut sum = Vec3::default();
539 for row in &samples {
540 sum = sum.add(row[column]);
541 }
542 sum.scale(1.0 / samples.len() as f64)
543 };
544 let near_centroid = centroid(0);
545 let far_centroid = centroid(1);
546 let mut inverted = true;
547 let mut pinch_fraction = 0.0f64;
548 let mut near_mean = 0.0f64;
549 let mut far_mean = 0.0f64;
550 for row in &samples {
551 let near_radial = row[0].sub(near_centroid);
552 let far_radial = row[1].sub(far_centroid);
553 let near_len = near_radial.length();
554 let far_len = far_radial.length();
555 if near_len <= 1e-9 || far_len <= 1e-9 {
556 inverted = false;
557 break;
558 }
559 if near_radial.dot(far_radial) >= 0.0 {
560 inverted = false;
561 break;
562 }
563 pinch_fraction += near_len / (near_len + far_len) / samples.len() as f64;
564 near_mean += near_len / samples.len() as f64;
565 far_mean += far_len / samples.len() as f64;
566 }
567 if inverted {
568 // Pull the crossed (smaller-ring, past-the-pinch) end back to the
569 // pinch point on each ruling.
570 reparameterised = true;
571 let retrim_far = far_mean <= near_mean;
572 for row in &mut samples {
573 let near = row[0];
574 let far = row[1];
575 let pinch = near.add(far.sub(near).scale(pinch_fraction));
576 if retrim_far {
577 row[1] = pinch;
578 } else {
579 row[0] = pinch;
580 }
581 }
582 }
583 // RULED EXTENSION: `planar_extension` is a no-op for curved carriers
584 // above, but a cone/cylinder lateral joined at a reflex edge needs
585 // its offset skin to GROW past the source rim exactly like a plane
586 // (a cylinder piercing a cone: the two offsets only meet past both
587 // cloned rims). A linear-v net is ruled — stretching each sampled
588 // ruling beyond both ends stays ON the same surface, so the fitted
589 // carrier keeps its parameterization (knots/pcurves untouched) while
590 // its world image (and with it the cloned trim's image) inflates.
591 if extension.extends_v() && !inverted {
592 let mut min_ruling = f64::MAX;
593 let mut back_allowance = f64::MAX;
594 let mut forward_allowance = f64::MAX;
595 let mut extendable = true;
596 for row in &samples {
597 let ruling = row[1].sub(row[0]);
598 let length = ruling.length();
599 min_ruling = min_ruling.min(length);
600 // Radii about the row centroids expose a converging (conic)
601 // ruling sheaf; the extension must stop short of its apex or
602 // the sheet folds through it.
603 let near_radial = row[0].sub(near_centroid).length();
604 let far_radial = row[1].sub(far_centroid).length();
605 if (far_radial - near_radial).abs() > 1e-9 {
606 let apex_at = near_radial / (near_radial - far_radial);
607 if (-1e-9..=1.0 + 1e-9).contains(&apex_at) {
608 // Apex inside the span: degenerate sheet, do not touch.
609 extendable = false;
610 break;
611 }
612 if apex_at < 0.0 {
613 back_allowance = back_allowance.min(0.9 * -apex_at);
614 } else {
615 forward_allowance = forward_allowance.min(0.9 * (apex_at - 1.0));
616 }
617 }
618 }
619 if extendable && min_ruling > 1e-9 {
620 reparameterised = true;
621 // Solved for the TRIM's v-extent, not the domain's: a bore
622 // face keeps its drill's full-height surface and is trimmed
623 // to the part it pierces, so moving the surface's own ends
624 // by |d| moved the trim's rims by only a fraction of that
625 // (a 30-long ruling trimmed to 20 gave 0.667 for 1.0) — and
626 // an outward shell's bore never reached the grown planes.
627 let (_, trim_v) = trim_domain_fractions(face, knot_u.domain(), knot_v.domain())?;
628 let (back_world, forward_world) =
629 domain_end_moves(extension.v_min, extension.v_max, trim_v);
630 let back = (back_world / min_ruling).min(back_allowance);
631 let forward = (forward_world / min_ruling).min(forward_allowance);
632 for row in &mut samples {
633 let ruling = row[1].sub(row[0]);
634 row[0] = row[0].sub(ruling.scale(back));
635 row[1] = row[1].add(ruling.scale(forward));
636 }
637 }
638 }
639 }
640 let weights = source
641 .control_points
642 .iter()
643 .map(|row| row.iter().map(|point| point.w).collect::<Vec<_>>())
644 .collect::<Vec<_>>();
645 let lane = if reparameterised {
646 OffsetSurfaceLane::Reparameterised
647 } else {
648 OffsetSurfaceLane::Fit
649 };
650 Ok((
651 NurbsSurface::new(
652 source.degree_u,
653 source.degree_v,
654 source.knots_u.clone(),
655 source.knots_v.clone(),
656 interpolate_tensor(
657 &knot_u,
658 &knot_v,
659 ¶meters_u,
660 ¶meters_v,
661 &samples,
662 &weights,
663 )?,
664 )?,
665 lane,
666 ))
667}
668
669/// The 3D curve of one carrier edge: the image of the source coedge's pcurve
670/// on the carrier surface, with the parameter range `(t0, t1)` that matches the
671/// pcurve's domain fraction for fraction (the contract every coedge of this
672/// carrier relies on, since the source pcurves are reused verbatim).
673///
674/// Built through the [`crate::image_curve`] ladder — exact on an affine sheet
675/// (every planar offset), the exact iso-curve where the pcurve holds one
676/// coordinate constant (a blend rail, a cylinder's cap rim, every
677/// extrude/revolve boundary), and a fitted curve verified to `fit_tolerance`
678/// off its nodes otherwise. Only where the ladder refuses does the edge fall
679/// back to the degree-1 interpolant through `points` (the historical
680/// construction), so nothing that offset before is refused now.
681///
682/// Why the polyline is no longer the first choice: its adaptive subdivision
683/// stops at a 5e-4 chord sag, and a 128-chord rim on an r = 8.5 cylinder sits
684/// up to 1.6e-4 inside the true arc. That is 16x the boolean imprint's
685/// coincidence band, so when a later operation's section curve runs along that
686/// rim (a cutter's side wall coplanar with the offset shell's opening wall,
687/// 2026-09-06 report) the imprint cannot recognise the rim as the section it
688/// already is, and reports every chord vertex as a crossing — one edge split
689/// into forty. An exact arc is recognised as coincident and left alone.
690fn carrier_edge_curve(
691 surface: &NurbsSurface,
692 pcurve: &NurbsCurve,
693 points: &[Vec3],
694 parameters: &[f64],
695 fit_tolerance: f64,
696) -> Result<(NurbsCurve, f64, f64), String> {
697 // `BREP_CARRIER_POLYLINE=1` restores the polyline for an A/B comparison.
698 let image = if std::env::var("BREP_CARRIER_POLYLINE").as_deref() == Ok("1") {
699 Err("BREP_CARRIER_POLYLINE set".to_string())
700 } else {
701 crate::image_curve::image_curve(surface, pcurve, fit_tolerance, "offset_face_carrier")
702 };
703 match image {
704 Ok(image) if image.t0 <= image.t1 => Ok((image.curve, image.t0, image.t1)),
705 Ok(image) => {
706 // The image runs against the pcurve: reverse it so the edge range
707 // is increasing, reflecting the range through the curve's domain.
708 let [start, end] = image.curve.domain()?;
709 let curve = image.curve.reversed()?;
710 Ok((curve, start + end - image.t0, start + end - image.t1))
711 }
712 Err(_) => {
713 let curve = interpolate_curve(points, 1, parameters)?;
714 let [t0, t1] = curve.domain()?;
715 Ok((curve, t0, t1))
716 }
717 }
718}
719
720fn mapped_pcurve_polyline(
721 surface: &NurbsSurface,
722 pcurve: &NurbsCurve,
723 degenerate: bool,
724) -> Result<(Vec<Vec3>, Vec<f64>), String> {
725 let [start, end] = pcurve.domain()?;
726 let evaluate = |fraction: f64| {
727 let uv = pcurve.evaluate(start + (end - start) * fraction)?;
728 surface.evaluate(uv.x, uv.y)
729 };
730 let first = evaluate(0.0)?;
731 let last = evaluate(1.0)?;
732 if degenerate {
733 return Ok((vec![first, last], vec![0.0, 1.0]));
734 }
735 fn append(
736 evaluate: &impl Fn(f64) -> Result<Vec3, String>,
737 a_fraction: f64,
738 a: Vec3,
739 b_fraction: f64,
740 b: Vec3,
741 depth: usize,
742 parameters: &mut Vec<f64>,
743 points: &mut Vec<Vec3>,
744 ) -> Result<(), String> {
745 let fractions =
746 [0.25, 0.5, 0.75].map(|local| a_fraction + (b_fraction - a_fraction) * local);
747 let samples = fractions
748 .map(evaluate)
749 .into_iter()
750 .collect::<Result<Vec<_>, String>>()?;
751 let deviation = samples
752 .iter()
753 .enumerate()
754 .map(|(index, point)| {
755 point
756 .sub(a.add(b.sub(a).scale((index + 1) as f64 * 0.25)))
757 .length()
758 })
759 .fold(0.0, f64::max);
760 if deviation <= 5e-4 || depth >= 10 {
761 parameters.push(b_fraction);
762 points.push(b);
763 return Ok(());
764 }
765 append(
766 evaluate,
767 a_fraction,
768 a,
769 fractions[1],
770 samples[1],
771 depth + 1,
772 parameters,
773 points,
774 )?;
775 append(
776 evaluate,
777 fractions[1],
778 samples[1],
779 b_fraction,
780 b,
781 depth + 1,
782 parameters,
783 points,
784 )
785 }
786 let mut parameters = vec![0.0];
787 let mut points = vec![first];
788 append(
789 &evaluate,
790 0.0,
791 first,
792 1.0,
793 last,
794 0,
795 &mut parameters,
796 &mut points,
797 )?;
798 Ok((points, parameters))
799}
800
801/// Everything an offset carrier's construction MEASURED about itself.
802///
803/// The half this kernel lacked: a tolerance MEASURED from the geometry that was
804/// actually built, rather than a band chosen from `scale` before building. It
805/// lives in the one place that needs no durable-format change: alongside the
806/// transient construction result, never as a field on a
807/// [`crate::BrepSolid`] record. `io/snapshot.rs` is a documented durable format
808/// and `SOLID_CODEC_VERSION` a versioned wire layout; persisting per-entity
809/// tolerances is real, planned, and separately designed
810/// (`docs/developer/kernel-plans/per-entity-tolerances.md` S3). This lands the
811/// measurement with no format churn at all.
812///
813/// Every number here is a RECORD. None of it widens a band — see
814/// [`MeasuredTolerance`]'s direction rule.
815#[derive(Clone, Debug)]
816pub struct CarrierDeviation {
817 /// The derived band every measurement below was judged against:
818 /// [`crate::offset_construction_band`] of the source solid's extent.
819 pub band: f64,
820 /// Which branch built the carrier surface.
821 pub lane: OffsetSurfaceLane,
822 /// The carrier surface's own fit error, when the lane has one.
823 pub surface: Option<MeasuredTolerance>,
824 /// `max_t ‖C_3d(t) − S_off(p(t))‖` per carrier edge id, folded over every
825 /// coedge that references the edge — OCCT's `FillEdgeData` rule
826 /// (`BRepOffset_SimpleOffset.cxx:296-310`), which takes the maximum over
827 /// **every** adjacent face rather than the first one.
828 pub edges: Vec<(u64, MeasuredTolerance)>,
829 /// Per carrier vertex id, propagated from the incident edge ends by
830 /// [`crate::vertex_tolerance_from_edges`] — which is also where the verdict
831 /// on OCCT's 1.001 inflation factor is recorded.
832 pub vertices: Vec<(u64, f64)>,
833}
834
835impl CarrierDeviation {
836 /// The worst thing the construction did, against the tightest band it
837 /// faced. `None` only when there was nothing at all to measure.
838 pub fn worst(&self) -> Option<MeasuredTolerance> {
839 MeasuredTolerance::worst(
840 self.surface
841 .into_iter()
842 .chain(self.edges.iter().map(|(_, measured)| *measured))
843 .chain(
844 self.vertices
845 .iter()
846 .map(|(_, gap)| MeasuredTolerance::new(*gap, self.band)),
847 ),
848 )
849 }
850
851 /// The entities whose measured deviation exceeded the derived band — the
852 /// interesting case, and the only one any gate acts on.
853 pub fn exceedances(&self) -> Vec<String> {
854 let mut out = Vec::new();
855 if let Some(surface) = self.surface {
856 if surface.exceeds_band() {
857 out.push(format!("carrier surface fit {}", surface.describe()));
858 }
859 }
860 for (id, measured) in &self.edges {
861 if measured.exceeds_band() {
862 out.push(format!("edge {id} {}", measured.describe()));
863 }
864 }
865 for (id, gap) in &self.vertices {
866 let measured = MeasuredTolerance::new(*gap, self.band);
867 if measured.exceeds_band() {
868 out.push(format!("vertex {id} {}", measured.describe()));
869 }
870 }
871 out
872 }
873}
874
875#[derive(Clone, Debug, Serialize)]
876pub struct OffsetFaceCarrier {
877 pub vertices: Vec<VertexRecord>,
878 pub edges: Vec<EdgeRecord>,
879 pub face: FaceRecord,
880 /// What the construction measured about itself, or `None` when it was built
881 /// through the unmeasured [`offset_face_carrier`] entry point.
882 ///
883 /// `#[serde(skip)]` on purpose: this struct crosses the wasm ABI as JSON
884 /// (`abi/modeling_b.rs:500`), and a measurement is a diagnostic about a
885 /// build, not part of the carrier the caller asked for. Skipping it keeps
886 /// that payload byte-identical.
887 #[serde(skip)]
888 pub deviation: Option<CarrierDeviation>,
889}
890
891fn claim_vertex_image(
892 source_id: u64,
893 point: Vec3,
894 vertex_images: &mut HashMap<u64, u64>,
895 vertices: &mut Vec<VertexRecord>,
896 next_id: &mut u64,
897) -> u64 {
898 if let Some(id) = vertex_images.get(&source_id) {
899 return *id;
900 }
901 let id = *next_id;
902 *next_id += 1;
903 vertices.push(VertexRecord { id, point });
904 vertex_images.insert(source_id, id);
905 id
906}
907
908pub fn offset_face_carrier(
909 solid: &BrepSolid,
910 face_id: u64,
911 distance: f64,
912 planar_extension: f64,
913) -> Result<OffsetFaceCarrier, String> {
914 offset_face_carrier_impl(
915 solid,
916 face_id,
917 distance,
918 &CarrierExtension::uniform(planar_extension),
919 false,
920 )
921}
922
923/// [`offset_face_carrier`] with a per-side [`CarrierExtension`].
924pub fn offset_face_carrier_sided(
925 solid: &BrepSolid,
926 face_id: u64,
927 distance: f64,
928 extension: &CarrierExtension,
929) -> Result<OffsetFaceCarrier, String> {
930 offset_face_carrier_impl(solid, face_id, distance, extension, false)
931}
932
933/// [`offset_face_carrier`], with every entity it builds measured against the
934/// deviation it was meant to reproduce.
935///
936/// The carrier is bit-identical to [`offset_face_carrier`]'s — same body, same
937/// arithmetic, in the same order — with a read-only measurement pass appended.
938/// Three quantities land in [`CarrierDeviation`]:
939///
940/// * the carrier SURFACE's fit against the pointwise offset it interpolates
941/// ([`offset_surface_measured`]);
942/// * each carrier EDGE's 3D curve against the locus its pcurve traces on that
943/// surface — the trim boundary is built by sampling exactly that composition
944/// and interpolating the images, so this is the construction's own claim,
945/// measured rather than assumed;
946/// * each carrier VERTEX, propagated from the incident edge ends.
947///
948/// The interesting output is [`CarrierDeviation::exceedances`]: an entity whose
949/// measured deviation is worse than the size-derived band assumed. That is a
950/// construction that went wrong in a way the derived band alone cannot see, and
951/// it is the case a caller should refuse on rather than ship.
952pub fn offset_face_carrier_measured(
953 solid: &BrepSolid,
954 face_id: u64,
955 distance: f64,
956 planar_extension: f64,
957) -> Result<OffsetFaceCarrier, String> {
958 offset_face_carrier_impl(
959 solid,
960 face_id,
961 distance,
962 &CarrierExtension::uniform(planar_extension),
963 true,
964 )
965}
966
967/// The measured deviations of one built carrier: surface fit, per edge, per
968/// vertex.
969///
970/// Read-only over what the construction produced. The edge measurement is
971/// [`crate::measure_edge_against_pcurve_image`] — validate's own
972/// `adaptive_coedge_error` floored by a span-midpoint pass, because the sampler
973/// alone is aliased against exactly the curves this construction builds (see
974/// that module's doc) — taken against the far tighter
975/// [`crate::offset_construction_band`] instead of the vendor-forgiving
976/// `pcurve_acceptance` validate will use later.
977fn measure_carrier(
978 surface: &NurbsSurface,
979 vertices: &[VertexRecord],
980 edges: &[EdgeRecord],
981 loops: &[LoopRecord],
982 lane: OffsetSurfaceLane,
983 surface_fit: Option<MeasuredTolerance>,
984 band: f64,
985) -> Result<CarrierDeviation, String> {
986 let edge_by_id: HashMap<u64, &EdgeRecord> = edges.iter().map(|edge| (edge.id, edge)).collect();
987 // Fold over coedges, not edges: a seam edge is referenced twice with two
988 // different pcurves, and OCCT's `FillEdgeData` takes the maximum over every
989 // adjacent face for exactly that reason.
990 let mut per_edge: HashMap<u64, MeasuredTolerance> = HashMap::default();
991 for loop_record in loops {
992 for coedge in &loop_record.coedges {
993 let Some(edge) = edge_by_id.get(&coedge.edge_id) else {
994 continue;
995 };
996 let measured = measure_edge_against_pcurve_image(
997 surface,
998 &coedge.pcurve,
999 edge,
1000 coedge.forward,
1001 band,
1002 )?;
1003 per_edge
1004 .entry(edge.id)
1005 .and_modify(|existing| *existing = existing.worse_of(measured))
1006 .or_insert(measured);
1007 }
1008 }
1009
1010 let mut measured_edges: Vec<(u64, MeasuredTolerance)> = per_edge.into_iter().collect();
1011 measured_edges.sort_by_key(|(id, _)| *id);
1012 let deviation_of: HashMap<u64, f64> = measured_edges
1013 .iter()
1014 .map(|(id, measured)| (*id, measured.deviation()))
1015 .collect();
1016
1017 // Edge ENDS by the vertex they claim, built once. A degenerate edge claims
1018 // the same vertex at both ends and contributes both, which is right: the
1019 // question is how far every representation meeting there actually lands.
1020 let mut ends_at: HashMap<u64, Vec<(&EdgeRecord, f64)>> = HashMap::default();
1021 for edge in edges {
1022 ends_at
1023 .entry(edge.start_vertex_id)
1024 .or_default()
1025 .push((edge, edge.t0));
1026 ends_at
1027 .entry(edge.end_vertex_id)
1028 .or_default()
1029 .push((edge, edge.t1));
1030 }
1031
1032 let mut measured_vertices = Vec::with_capacity(vertices.len());
1033 for vertex in vertices {
1034 let mut gaps = Vec::new();
1035 let mut incident = Vec::new();
1036 for (edge, parameter) in ends_at.get(&vertex.id).into_iter().flatten() {
1037 gaps.push(vertex_endpoint_gap(
1038 vertex.point,
1039 edge.curve.evaluate(*parameter)?,
1040 ));
1041 incident.push(deviation_of.get(&edge.id).copied().unwrap_or(0.0));
1042 }
1043 measured_vertices.push((vertex.id, vertex_tolerance_from_edges(gaps, incident)));
1044 }
1045
1046 Ok(CarrierDeviation {
1047 band,
1048 lane,
1049 surface: surface_fit,
1050 edges: measured_edges,
1051 vertices: measured_vertices,
1052 })
1053}
1054
1055fn offset_face_carrier_impl(
1056 solid: &BrepSolid,
1057 face_id: u64,
1058 distance: f64,
1059 extension: &CarrierExtension,
1060 measure: bool,
1061) -> Result<OffsetFaceCarrier, String> {
1062 let source = solid
1063 .shells
1064 .iter()
1065 .flat_map(|shell| &shell.faces)
1066 .find(|face| face.id == face_id)
1067 .ok_or_else(|| format!("offset_face_carrier: missing face {face_id}"))?;
1068 // The band is derived from the SOURCE SOLID's extent, matching every other
1069 // direct-edit/offset site (`face_offset.rs:103` and its siblings all take
1070 // `solid_model_scale`). The alternative basis is the FACE's own extent — the
1071 // reference shell keys its size-relative offset quantities on the 3D arc
1072 // length of the face's own boundary iso, never on the assembly. Which basis
1073 // is better is unsettled: it is a measurement to make over the refusal
1074 // corpus before switching, not a change to smuggle in here.
1075 let band = offset_construction_band(solid_model_scale(solid));
1076 // The accuracy bar a carrier EDGE's 3D curve must meet against the locus its
1077 // pcurve traces on the carrier surface — the kernel's own SSI fit contract,
1078 // the same bar `thicken` holds its wall boundaries to. NOT the construction
1079 // band: that is the surface-fit allowance (5e-4 of the part), 50x looser
1080 // than the boolean's coincidence band, and an edge fitted only that well is
1081 // what a later imprint mistakes for a crossing (see `carrier_edge_curve`).
1082 let fit_tolerance =
1083 crate::KernelTolerances::for_scale(solid_model_scale(solid), 1e-7).intersection_fit;
1084 let (surface, lane, surface_fit) = if measure {
1085 let measured = offset_surface_measured_sided(source, distance, extension, band)?;
1086 (measured.surface, measured.lane, measured.fit)
1087 } else {
1088 let (surface, lane) = offset_surface_with_lane(source, distance, extension)?;
1089 (surface, lane, None)
1090 };
1091 let source_edges = solid
1092 .edges
1093 .iter()
1094 .map(|edge| (edge.id, edge))
1095 .collect::<HashMap<_, _>>();
1096 let source_vertices = solid
1097 .vertices
1098 .iter()
1099 .map(|vertex| (vertex.id, vertex))
1100 .collect::<HashMap<_, _>>();
1101 let mut vertices = Vec::new();
1102 let mut vertex_images = HashMap::default();
1103 let mut edges = Vec::new();
1104 let mut edge_images = HashMap::default();
1105 let mut loops = Vec::new();
1106 let mut next_id = 1u64;
1107
1108 for source_loop in &source.loops {
1109 let mut coedges = Vec::new();
1110 for source_coedge in &source_loop.coedges {
1111 let source_edge = source_edges
1112 .get(&source_coedge.edge_id)
1113 .ok_or_else(|| "offset_face_carrier: missing source edge".to_string())?;
1114 let (source_start, source_end) = if source_coedge.forward {
1115 (source_edge.start_vertex_id, source_edge.end_vertex_id)
1116 } else {
1117 (source_edge.end_vertex_id, source_edge.start_vertex_id)
1118 };
1119 if !source_vertices.contains_key(&source_start)
1120 || !source_vertices.contains_key(&source_end)
1121 {
1122 return Err("offset_face_carrier: missing source vertex".into());
1123 }
1124 // Map even DEGENERATE source edges through the full polyline: a
1125 // cone apex's image on the offset surface is a genuine CIRCLE
1126 // (radius d·cos half-angle), not a point — shortcutting to the
1127 // two endpoints would collapse the ring and leave the carrier's
1128 // topology inconsistent with its surface. Edges whose image truly
1129 // collapses (sphere poles, planar corners) still interpolate to a
1130 // point-sized curve and keep their degenerate flag below.
1131 // Map even DEGENERATE source edges through the full polyline: an
1132 // EXTERIOR cone offset turns the apex point into a genuine RING
1133 // (radius d·cos half-angle) — shortcutting to the endpoints would
1134 // collapse it and leave the carrier topology inconsistent with
1135 // its surface (and the ring imprint would be dropped as
1136 // boundary-coincident with a "degenerate" edge). Images that
1137 // truly collapse (sphere poles; interior apexes after the pinch
1138 // retrim) stay degenerate below. The threshold scales with the
1139 // offset distance: a real ring measures ~d·cos α, while fitted
1140 // pole rows wobble ~1e-4 absolute.
1141 let (points, parameters) =
1142 mapped_pcurve_polyline(&surface, &source_coedge.pcurve, false)?;
1143 let collapse_tolerance = 1e-6f64.max(distance.abs() * 1e-2);
1144 let image_collapsed = points
1145 .iter()
1146 .all(|point| point.sub(points[0]).length() <= collapse_tolerance);
1147 let (edge_id, forward) =
1148 if let Some((edge_id, edge_start_vertex_id, creator_forward)) =
1149 edge_images.get(&source_edge.id)
1150 {
1151 if source_start == source_end {
1152 // A CLOSED source edge (a seam of a periodic face: both
1153 // ends are the same vertex) is referenced twice by the
1154 // same loop, once per seam side, and the two references
1155 // traverse it in OPPOSITE senses — that is what closes
1156 // the loop. The endpoint test below cannot see that:
1157 // both ends map to the same vertex image, so it answers
1158 // `true` for both references and the returning coedge
1159 // comes back mis-oriented.
1160 //
1161 // Measured on a full torus (one vertex, two seam edges):
1162 // the source records `forward: false` on the two
1163 // returning coedges and validates clean, while the
1164 // carrier recorded `forward: true` on both and its rim
1165 // edge measured 12.5 — a whole part diameter — against
1166 // its own composed pcurve image. The measured tolerance
1167 // this slice adds is what surfaced it; no existing gate
1168 // reaches this shape.
1169 //
1170 // The source coedge's own sense is the answer: the image
1171 // edge runs along the CREATING coedge's pcurve, so a
1172 // later reference runs with it exactly when the two
1173 // source coedges traverse the source edge the same way.
1174 (*edge_id, source_coedge.forward == *creator_forward)
1175 } else {
1176 // UNCHANGED for every open edge: the image edge's start
1177 // vertex identifies which way this coedge runs.
1178 (
1179 *edge_id,
1180 vertex_images.get(&source_start) == Some(edge_start_vertex_id),
1181 )
1182 }
1183 } else {
1184 let (curve, t0, t1) = if image_collapsed {
1185 let curve = NurbsCurve::new(
1186 1,
1187 vec![0.0, 0.0, 1.0, 1.0],
1188 vec![
1189 Vec4::from_point(points[0], 1.0),
1190 Vec4::from_point(points[0], 1.0),
1191 ],
1192 )?;
1193 (curve, 0.0, 1.0)
1194 } else {
1195 carrier_edge_curve(
1196 &surface,
1197 &source_coedge.pcurve,
1198 &points,
1199 ¶meters,
1200 fit_tolerance,
1201 )?
1202 };
1203 let start_vertex_id = claim_vertex_image(
1204 source_start,
1205 points[0],
1206 &mut vertex_images,
1207 &mut vertices,
1208 &mut next_id,
1209 );
1210 let end_vertex_id = claim_vertex_image(
1211 source_end,
1212 points[points.len() - 1],
1213 &mut vertex_images,
1214 &mut vertices,
1215 &mut next_id,
1216 );
1217 let id = next_id;
1218 next_id += 1;
1219 edges.push(EdgeRecord {
1220 id,
1221 curve,
1222 t0,
1223 t1,
1224 start_vertex_id,
1225 end_vertex_id,
1226 // Degenerate only if the IMAGE collapsed too — a cone
1227 // apex maps to a real ring on the offset surface and
1228 // must carry a real closed edge.
1229 degenerate: source_edge.degenerate && image_collapsed,
1230 // Image of a named source edge on the offset carrier;
1231 // suffixed so it cannot collide with the source edge
1232 // when both faces survive into one solid.
1233 name: source_edge
1234 .name
1235 .as_ref()
1236 .map(|name| format!("{name}_Offset")),
1237 });
1238 edge_images.insert(
1239 source_edge.id,
1240 (id, start_vertex_id, source_coedge.forward),
1241 );
1242 (id, true)
1243 };
1244 let id = next_id;
1245 next_id += 1;
1246 coedges.push(CoedgeRecord {
1247 id,
1248 edge_id,
1249 forward,
1250 pcurve: source_coedge.pcurve.clone(),
1251 });
1252 }
1253 let id = next_id;
1254 next_id += 1;
1255 loops.push(LoopRecord { id, coedges });
1256 }
1257 let deviation = if measure {
1258 Some(measure_carrier(
1259 &surface,
1260 &vertices,
1261 &edges,
1262 &loops,
1263 lane,
1264 surface_fit,
1265 band,
1266 )?)
1267 } else {
1268 None
1269 };
1270 Ok(OffsetFaceCarrier {
1271 vertices,
1272 edges,
1273 face: FaceRecord {
1274 id: next_id,
1275 surface,
1276 same_sense: source.same_sense,
1277 loops,
1278 name: source.name.as_ref().map(|name| format!("{name}_Offset")),
1279 },
1280 deviation,
1281 })
1282}
1283
1284// BREP private tests: f18b466be3e719c5