Skip to main content

brep_kernel/offset/
offset.rs

1use crate::fit::solve_dense;
2use crate::topology::{BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord, VertexRecord};
3use crate::{interpolate_curve, KnotVector, NurbsCurve, NurbsSurface, Vec3, Vec4};
4use rustc_hash::FxHashMap as HashMap;
5use serde::Serialize;
6
7fn domains(surface: &NurbsSurface) -> Result<([f64; 2], [f64; 2]), String> {
8    Ok((
9        KnotVector::new(surface.knots_u.clone(), surface.degree_u)?.domain(),
10        KnotVector::new(surface.knots_v.clone(), surface.degree_v)?.domain(),
11    ))
12}
13
14fn stable_face_normal(face: &FaceRecord, u: f64, v: f64) -> Result<Vec3, String> {
15    let normal_at = |u, v| face.surface.normal(u, v).ok();
16    let mut normal = normal_at(u, v);
17    let ([u0, u1], [v0, v1]) = domains(&face.surface)?;
18    if normal.is_none() {
19        let du = (u1 - u0) * 1e-5;
20        let dv = (v1 - v0) * 1e-5;
21        for (candidate_u, candidate_v) in [
22            ((u + du).clamp(u0, u1), v),
23            ((u - du).clamp(u0, u1), v),
24            (u, (v + dv).clamp(v0, v1)),
25            (u, (v - dv).clamp(v0, v1)),
26        ] {
27            normal = normal_at(candidate_u, candidate_v);
28            if normal.is_some() {
29                break;
30            }
31        }
32    }
33    // SINGULAR-ROW override: at a surface singularity where a whole
34    // parameter row collapses to one point (a cone apex), the at-point /
35    // nudged normal is the cross of a vanishing partial with noise — the
36    // AXIS direction instead of the ruling normal, which offsets the apex
37    // row straight down the axis and bends the fitted surface by exactly
38    // d·cos(half-angle). Detect the collapse by local point spread, walk
39    // DEEP inward for the true per-ruling limit, and replace the at-point
40    // value only when the two genuinely DISAGREE. A sphere/dome pole also
41    // reads as collapsed, but there the at-point normal (the axis) IS the
42    // limit — agreement keeps the exact baseline value.
43    let singular_here = {
44        let du = (u1 - u0) * 1e-4;
45        let dv = (v1 - v0) * 1e-4;
46        let here = face.surface.evaluate(u, v)?;
47        let along_u = face
48            .surface
49            .evaluate((u + du).clamp(u0, u1), v)?
50            .sub(here)
51            .length()
52            .max(
53                face.surface
54                    .evaluate((u - du).clamp(u0, u1), v)?
55                    .sub(here)
56                    .length(),
57            );
58        let along_v = face
59            .surface
60            .evaluate(u, (v + dv).clamp(v0, v1))?
61            .sub(here)
62            .length()
63            .max(
64                face.surface
65                    .evaluate(u, (v - dv).clamp(v0, v1))?
66                    .sub(here)
67                    .length(),
68            );
69        let scale = along_u.max(along_v);
70        scale > 0.0 && along_u.min(along_v) < scale * 1e-6
71    };
72    if singular_here {
73        let v_mid = (v0 + v1) * 0.5;
74        let u_mid = (u0 + u1) * 0.5;
75        let mut interior = None;
76        for fraction in [1e-3, 1e-2, 5e-2, 0.25] {
77            let candidate_v = v + (v_mid - v) * fraction;
78            let candidate_u = u + (u_mid - u) * fraction;
79            for (cu, cv) in [(u, candidate_v), (candidate_u, v), (candidate_u, candidate_v)] {
80                if let Some(candidate) = normal_at(cu, cv) {
81                    interior = Some(candidate);
82                    break;
83                }
84            }
85            if interior.is_some() {
86                break;
87            }
88        }
89        normal = match (normal, interior) {
90            (Some(at_point), Some(interior)) if at_point.dot(interior) > 1.0 - 1e-6 => {
91                Some(at_point)
92            }
93            (_, Some(interior)) => Some(interior),
94            (at_point, None) => at_point,
95        };
96    }
97    let normal =
98        normal.ok_or_else(|| "offset_surface: cannot determine surface normal".to_string())?;
99    Ok(if face.same_sense {
100        normal
101    } else {
102        normal.scale(-1.0)
103    })
104}
105
106fn greville_parameters(knots: &KnotVector) -> Vec<f64> {
107    let mut parameters = (0..knots.control_point_count())
108        .map(|index| {
109            knots.knots[index + 1..=index + knots.degree]
110                .iter()
111                .sum::<f64>()
112                / knots.degree as f64
113        })
114        .collect::<Vec<_>>();
115    let domain = knots.domain();
116    parameters[0] = domain[0];
117    *parameters.last_mut().unwrap() = domain[1];
118    parameters
119}
120
121/// Rational collocation matrix: rows are the rational basis functions
122/// R_i(t) = N_i(t)·w_i / Σ_k N_k(t)·w_k evaluated at each parameter. With
123/// uniform weights this reduces to the ordinary B-spline collocation matrix.
124fn collocation_matrix(knots: &KnotVector, parameters: &[f64], weights: &[f64]) -> Vec<Vec<f64>> {
125    parameters
126        .iter()
127        .map(|parameter| {
128            let mut row = vec![0.0; knots.control_point_count()];
129            let span = knots.find_span(*parameter);
130            for (offset, value) in knots
131                .basis_functions(span, *parameter)
132                .into_iter()
133                .enumerate()
134            {
135                let index = span - knots.degree + offset;
136                row[index] = value * weights[index];
137            }
138            let denominator: f64 = row.iter().sum();
139            if denominator.abs() > 0.0 {
140                for value in &mut row {
141                    *value /= denominator;
142                }
143            }
144            row
145        })
146        .collect()
147}
148
149/// Split the weight grid into per-direction factors when it is separable
150/// (w_ij = a_i·b_j), which covers every tensor surface built from rational
151/// profile/rail curves (cylinders, cones, spheres, tori, revolves).
152fn separable_weights(weights: &[Vec<f64>]) -> Option<(Vec<f64>, Vec<f64>)> {
153    let first_row = weights.first()?;
154    let anchor = *first_row.first()?;
155    if anchor.abs() <= 1e-12 {
156        return None;
157    }
158    let a: Vec<f64> = weights.iter().map(|row| row[0]).collect();
159    let b: Vec<f64> = first_row.iter().map(|w| w / anchor).collect();
160    for (i, row) in weights.iter().enumerate() {
161        for (j, &w) in row.iter().enumerate() {
162            if (w - a[i] * b[j]).abs() > 1e-10 * (1.0 + w.abs()) {
163                return None;
164            }
165        }
166    }
167    Some((a, b))
168}
169
170/// Interpolate the sample grid in the SOURCE surface's rational basis (same
171/// knots and weights). When the true offset is representable in that basis —
172/// planes, cylinders, cones, spheres, tori — collocation at the Greville grid
173/// recovers it EXACTLY, so offset carriers stay real analytic surfaces
174/// instead of non-rational approximations with span-scale wobble.
175fn interpolate_tensor(
176    knot_u: &KnotVector,
177    knot_v: &KnotVector,
178    parameters_u: &[f64],
179    parameters_v: &[f64],
180    samples: &[Vec<Vec3>],
181    weights: &[Vec<f64>],
182) -> Result<Vec<Vec<Vec4>>, String> {
183    let count_u = parameters_u.len();
184    let count_v = parameters_v.len();
185    if let Some((weights_u, weights_v)) = separable_weights(weights) {
186        let matrix_u = collocation_matrix(knot_u, parameters_u, &weights_u);
187        let matrix_v = collocation_matrix(knot_v, parameters_v, &weights_v);
188        let mut intermediate = vec![vec![Vec3::default(); count_v]; count_u];
189        for column in 0..count_v {
190            let solve_axis = |axis: fn(Vec3) -> f64| {
191                solve_dense(
192                    matrix_u.clone(),
193                    samples.iter().map(|row| axis(row[column])).collect(),
194                )
195            };
196            let x = solve_axis(|point| point.x)?;
197            let y = solve_axis(|point| point.y)?;
198            let z = solve_axis(|point| point.z)?;
199            for row in 0..count_u {
200                intermediate[row][column] = Vec3::new(x[row], y[row], z[row]);
201            }
202        }
203        let mut controls = vec![vec![Vec4::from_point(Vec3::default(), 1.0); count_v]; count_u];
204        for row in 0..count_u {
205            let solve_axis = |axis: fn(Vec3) -> f64| {
206                solve_dense(
207                    matrix_v.clone(),
208                    intermediate[row].iter().copied().map(axis).collect(),
209                )
210            };
211            let x = solve_axis(|point| point.x)?;
212            let y = solve_axis(|point| point.y)?;
213            let z = solve_axis(|point| point.z)?;
214            for column in 0..count_v {
215                controls[row][column] = Vec4::from_point(
216                    Vec3::new(x[column], y[column], z[column]),
217                    weights[row][column],
218                );
219            }
220        }
221        return Ok(controls);
222    }
223
224    // Non-separable weights: solve the full tensor collocation system with
225    // the exact 2D rational basis. Nets are small in practice.
226    let unknowns = count_u * count_v;
227    let mut matrix = vec![vec![0.0; unknowns]; unknowns];
228    for (k, &u) in parameters_u.iter().enumerate() {
229        let span_u = knot_u.find_span(u);
230        let basis_u = knot_u.basis_functions(span_u, u);
231        for (l, &v) in parameters_v.iter().enumerate() {
232            let span_v = knot_v.find_span(v);
233            let basis_v = knot_v.basis_functions(span_v, v);
234            let row = &mut matrix[k * count_v + l];
235            let mut denominator = 0.0;
236            for (du, value_u) in basis_u.iter().enumerate() {
237                let i = span_u - knot_u.degree + du;
238                for (dv, value_v) in basis_v.iter().enumerate() {
239                    let j = span_v - knot_v.degree + dv;
240                    let entry = value_u * value_v * weights[i][j];
241                    row[i * count_v + j] = entry;
242                    denominator += entry;
243                }
244            }
245            if denominator.abs() > 0.0 {
246                for value in row.iter_mut() {
247                    *value /= denominator;
248                }
249            }
250        }
251    }
252    let solve_axis = |axis: fn(Vec3) -> f64| {
253        solve_dense(
254            matrix.clone(),
255            samples
256                .iter()
257                .flat_map(|row| row.iter().copied().map(axis))
258                .collect(),
259        )
260    };
261    let x = solve_axis(|point| point.x)?;
262    let y = solve_axis(|point| point.y)?;
263    let z = solve_axis(|point| point.z)?;
264    let mut controls = vec![vec![Vec4::from_point(Vec3::default(), 1.0); count_v]; count_u];
265    for row in 0..count_u {
266        for column in 0..count_v {
267            let index = row * count_v + column;
268            controls[row][column] = Vec4::from_point(
269                Vec3::new(x[index], y[index], z[index]),
270                weights[row][column],
271            );
272        }
273    }
274    Ok(controls)
275}
276
277/// Construct the same fitted offset carrier surface as the reference shell
278/// implementation. Positive distance follows its convention and moves
279/// opposite the face's outward normal.
280pub fn offset_surface(
281    face: &FaceRecord,
282    distance: f64,
283    planar_extension: f64,
284) -> Result<NurbsSurface, String> {
285    let source = &face.surface;
286    if source.is_affine()? {
287        let ([u0, u1], [v0, v1]) = domains(source)?;
288        let normal = stable_face_normal(face, (u0 + u1) / 2.0, (v0 + v1) / 2.0)?;
289        let shift = normal.scale(-distance);
290        let mut points = source
291            .control_points
292            .iter()
293            .map(|row| {
294                row.iter()
295                    .map(|control| Ok(control.point()?.add(shift)))
296                    .collect::<Result<Vec<_>, String>>()
297            })
298            .collect::<Result<Vec<_>, String>>()?;
299        if planar_extension > 0.0 {
300            let p00 = points[0][0];
301            let p01 = points[0][1];
302            let p10 = points[1][0];
303            let direction_u = p10.sub(p00).normalized()?;
304            let direction_v = p01.sub(p00).normalized()?;
305            points[0][0] = p00
306                .sub(direction_u.scale(planar_extension))
307                .sub(direction_v.scale(planar_extension));
308            points[0][1] = p01
309                .sub(direction_u.scale(planar_extension))
310                .add(direction_v.scale(planar_extension));
311            points[1][0] = p10
312                .add(direction_u.scale(planar_extension))
313                .sub(direction_v.scale(planar_extension));
314            points[1][1] = points[1][1]
315                .add(direction_u.scale(planar_extension))
316                .add(direction_v.scale(planar_extension));
317        }
318        let controls = points
319            .into_iter()
320            .enumerate()
321            .map(|(row, points)| {
322                points
323                    .into_iter()
324                    .enumerate()
325                    .map(|(column, point)| {
326                        Vec4::from_point(point, source.control_points[row][column].w)
327                    })
328                    .collect()
329            })
330            .collect();
331        return NurbsSurface::new(
332            source.degree_u,
333            source.degree_v,
334            source.knots_u.clone(),
335            source.knots_v.clone(),
336            controls,
337        );
338    }
339
340    let knot_u = KnotVector::new(source.knots_u.clone(), source.degree_u)?;
341    let knot_v = KnotVector::new(source.knots_v.clone(), source.degree_v)?;
342    let parameters_u = greville_parameters(&knot_u);
343    let parameters_v = greville_parameters(&knot_v);
344    let mut samples = Vec::new();
345    for &u in &parameters_u {
346        let mut row = Vec::new();
347        for &v in &parameters_v {
348            row.push(
349                source
350                    .evaluate(u, v)?
351                    .add(stable_face_normal(face, u, v)?.scale(-distance)),
352            );
353        }
354        samples.push(row);
355    }
356    // APEX-CONE PINCH RETRIM: offsetting an apex cone INWARD moves each
357    // ruling past the axis — the sampled far row becomes a ring on the far
358    // side (radius d·cos half-angle, mirrored through the axis) and the
359    // offset surface self-pinches inside the v-domain. The genuine cavity
360    // ends AT the pinch (the offset cone's own apex). For a linear-v net
361    // (two sample rows — every made/booleaned cone) the pinch lies on each
362    // ruling at the fraction where the radial vector vanishes: detect the
363    // inversion (far-row radials anti-parallel to near-row radials about the
364    // row centroids) and pull the far row back to the pinch point, so the
365    // fitted surface ends in a proper degenerate apex row instead of a
366    // parasitic inverted tip ending in an unweldable ring.
367    if parameters_v.len() == 2 && parameters_u.len() >= 3 {
368        let centroid = |column: usize| {
369            let mut sum = Vec3::default();
370            for row in &samples {
371                sum = sum.add(row[column]);
372            }
373            sum.scale(1.0 / samples.len() as f64)
374        };
375        let near_centroid = centroid(0);
376        let far_centroid = centroid(1);
377        let mut inverted = true;
378        let mut pinch_fraction = 0.0f64;
379        let mut near_mean = 0.0f64;
380        let mut far_mean = 0.0f64;
381        for row in &samples {
382            let near_radial = row[0].sub(near_centroid);
383            let far_radial = row[1].sub(far_centroid);
384            let near_len = near_radial.length();
385            let far_len = far_radial.length();
386            if near_len <= 1e-9 || far_len <= 1e-9 {
387                inverted = false;
388                break;
389            }
390            if near_radial.dot(far_radial) >= 0.0 {
391                inverted = false;
392                break;
393            }
394            pinch_fraction += near_len / (near_len + far_len) / samples.len() as f64;
395            near_mean += near_len / samples.len() as f64;
396            far_mean += far_len / samples.len() as f64;
397        }
398        if inverted {
399            // Pull the crossed (smaller-ring, past-the-pinch) end back to the
400            // pinch point on each ruling.
401            let retrim_far = far_mean <= near_mean;
402            for row in &mut samples {
403                let near = row[0];
404                let far = row[1];
405                let pinch = near.add(far.sub(near).scale(pinch_fraction));
406                if retrim_far {
407                    row[1] = pinch;
408                } else {
409                    row[0] = pinch;
410                }
411            }
412        }
413        // RULED EXTENSION: `planar_extension` is a no-op for curved carriers
414        // above, but a cone/cylinder lateral joined at a reflex edge needs
415        // its offset skin to GROW past the source rim exactly like a plane
416        // (a cylinder piercing a cone: the two offsets only meet past both
417        // cloned rims). A linear-v net is ruled — stretching each sampled
418        // ruling beyond both ends stays ON the same surface, so the fitted
419        // carrier keeps its parameterization (knots/pcurves untouched) while
420        // its world image (and with it the cloned trim's image) inflates.
421        if planar_extension > 0.0 && !inverted {
422            let mut min_ruling = f64::MAX;
423            let mut back_allowance = f64::MAX;
424            let mut forward_allowance = f64::MAX;
425            let mut extendable = true;
426            for row in &samples {
427                let ruling = row[1].sub(row[0]);
428                let length = ruling.length();
429                min_ruling = min_ruling.min(length);
430                // Radii about the row centroids expose a converging (conic)
431                // ruling sheaf; the extension must stop short of its apex or
432                // the sheet folds through it.
433                let near_radial = row[0].sub(near_centroid).length();
434                let far_radial = row[1].sub(far_centroid).length();
435                if (far_radial - near_radial).abs() > 1e-9 {
436                    let apex_at = near_radial / (near_radial - far_radial);
437                    if (-1e-9..=1.0 + 1e-9).contains(&apex_at) {
438                        // Apex inside the span: degenerate sheet, do not touch.
439                        extendable = false;
440                        break;
441                    }
442                    if apex_at < 0.0 {
443                        back_allowance = back_allowance.min(0.9 * -apex_at);
444                    } else {
445                        forward_allowance = forward_allowance.min(0.9 * (apex_at - 1.0));
446                    }
447                }
448            }
449            if extendable && min_ruling > 1e-9 {
450                let stretch = planar_extension / min_ruling;
451                let back = stretch.min(back_allowance);
452                let forward = stretch.min(forward_allowance);
453                for row in &mut samples {
454                    let ruling = row[1].sub(row[0]);
455                    row[0] = row[0].sub(ruling.scale(back));
456                    row[1] = row[1].add(ruling.scale(forward));
457                }
458            }
459        }
460    }
461    let weights = source
462        .control_points
463        .iter()
464        .map(|row| row.iter().map(|point| point.w).collect::<Vec<_>>())
465        .collect::<Vec<_>>();
466    NurbsSurface::new(
467        source.degree_u,
468        source.degree_v,
469        source.knots_u.clone(),
470        source.knots_v.clone(),
471        interpolate_tensor(
472            &knot_u,
473            &knot_v,
474            &parameters_u,
475            &parameters_v,
476            &samples,
477            &weights,
478        )?,
479    )
480}
481
482fn mapped_pcurve_polyline(
483    surface: &NurbsSurface,
484    pcurve: &NurbsCurve,
485    degenerate: bool,
486) -> Result<(Vec<Vec3>, Vec<f64>), String> {
487    let [start, end] = pcurve.domain()?;
488    let evaluate = |fraction: f64| {
489        let uv = pcurve.evaluate(start + (end - start) * fraction)?;
490        surface.evaluate(uv.x, uv.y)
491    };
492    let first = evaluate(0.0)?;
493    let last = evaluate(1.0)?;
494    if degenerate {
495        return Ok((vec![first, last], vec![0.0, 1.0]));
496    }
497    fn append(
498        evaluate: &impl Fn(f64) -> Result<Vec3, String>,
499        a_fraction: f64,
500        a: Vec3,
501        b_fraction: f64,
502        b: Vec3,
503        depth: usize,
504        parameters: &mut Vec<f64>,
505        points: &mut Vec<Vec3>,
506    ) -> Result<(), String> {
507        let fractions =
508            [0.25, 0.5, 0.75].map(|local| a_fraction + (b_fraction - a_fraction) * local);
509        let samples = fractions
510            .map(evaluate)
511            .into_iter()
512            .collect::<Result<Vec<_>, String>>()?;
513        let deviation = samples
514            .iter()
515            .enumerate()
516            .map(|(index, point)| {
517                point
518                    .sub(a.add(b.sub(a).scale((index + 1) as f64 * 0.25)))
519                    .length()
520            })
521            .fold(0.0, f64::max);
522        if deviation <= 5e-4 || depth >= 10 {
523            parameters.push(b_fraction);
524            points.push(b);
525            return Ok(());
526        }
527        append(
528            evaluate,
529            a_fraction,
530            a,
531            fractions[1],
532            samples[1],
533            depth + 1,
534            parameters,
535            points,
536        )?;
537        append(
538            evaluate,
539            fractions[1],
540            samples[1],
541            b_fraction,
542            b,
543            depth + 1,
544            parameters,
545            points,
546        )
547    }
548    let mut parameters = vec![0.0];
549    let mut points = vec![first];
550    append(
551        &evaluate,
552        0.0,
553        first,
554        1.0,
555        last,
556        0,
557        &mut parameters,
558        &mut points,
559    )?;
560    Ok((points, parameters))
561}
562
563#[derive(Clone, Debug, Serialize)]
564pub struct OffsetFaceCarrier {
565    pub vertices: Vec<VertexRecord>,
566    pub edges: Vec<EdgeRecord>,
567    pub face: FaceRecord,
568}
569
570fn claim_vertex_image(
571    source_id: u64,
572    point: Vec3,
573    vertex_images: &mut HashMap<u64, u64>,
574    vertices: &mut Vec<VertexRecord>,
575    next_id: &mut u64,
576) -> u64 {
577    if let Some(id) = vertex_images.get(&source_id) {
578        return *id;
579    }
580    let id = *next_id;
581    *next_id += 1;
582    vertices.push(VertexRecord { id, point });
583    vertex_images.insert(source_id, id);
584    id
585}
586
587pub fn offset_face_carrier(
588    solid: &BrepSolid,
589    face_id: u64,
590    distance: f64,
591    planar_extension: f64,
592) -> Result<OffsetFaceCarrier, String> {
593    let source = solid
594        .shells
595        .iter()
596        .flat_map(|shell| &shell.faces)
597        .find(|face| face.id == face_id)
598        .ok_or_else(|| format!("offset_face_carrier: missing face {face_id}"))?;
599    let surface = offset_surface(source, distance, planar_extension)?;
600    let source_edges = solid
601        .edges
602        .iter()
603        .map(|edge| (edge.id, edge))
604        .collect::<HashMap<_, _>>();
605    let source_vertices = solid
606        .vertices
607        .iter()
608        .map(|vertex| (vertex.id, vertex))
609        .collect::<HashMap<_, _>>();
610    let mut vertices = Vec::new();
611    let mut vertex_images = HashMap::default();
612    let mut edges = Vec::new();
613    let mut edge_images = HashMap::default();
614    let mut loops = Vec::new();
615    let mut next_id = 1u64;
616
617    for source_loop in &source.loops {
618        let mut coedges = Vec::new();
619        for source_coedge in &source_loop.coedges {
620            let source_edge = source_edges
621                .get(&source_coedge.edge_id)
622                .ok_or_else(|| "offset_face_carrier: missing source edge".to_string())?;
623            let (source_start, source_end) = if source_coedge.forward {
624                (source_edge.start_vertex_id, source_edge.end_vertex_id)
625            } else {
626                (source_edge.end_vertex_id, source_edge.start_vertex_id)
627            };
628            if !source_vertices.contains_key(&source_start)
629                || !source_vertices.contains_key(&source_end)
630            {
631                return Err("offset_face_carrier: missing source vertex".into());
632            }
633            // Map even DEGENERATE source edges through the full polyline: a
634            // cone apex's image on the offset surface is a genuine CIRCLE
635            // (radius d·cos half-angle), not a point — shortcutting to the
636            // two endpoints would collapse the ring and leave the carrier's
637            // topology inconsistent with its surface. Edges whose image truly
638            // collapses (sphere poles, planar corners) still interpolate to a
639            // point-sized curve and keep their degenerate flag below.
640            // Map even DEGENERATE source edges through the full polyline: an
641            // EXTERIOR cone offset turns the apex point into a genuine RING
642            // (radius d·cos half-angle) — shortcutting to the endpoints would
643            // collapse it and leave the carrier topology inconsistent with
644            // its surface (and the ring imprint would be dropped as
645            // boundary-coincident with a "degenerate" edge). Images that
646            // truly collapse (sphere poles; interior apexes after the pinch
647            // retrim) stay degenerate below. The threshold scales with the
648            // offset distance: a real ring measures ~d·cos α, while fitted
649            // pole rows wobble ~1e-4 absolute.
650            let (points, parameters) =
651                mapped_pcurve_polyline(&surface, &source_coedge.pcurve, false)?;
652            let collapse_tolerance = 1e-6f64.max(distance.abs() * 1e-2);
653            let image_collapsed = points
654                .iter()
655                .all(|point| point.sub(points[0]).length() <= collapse_tolerance);
656            let (edge_id, forward) =
657                if let Some((edge_id, edge_start_vertex_id)) = edge_images.get(&source_edge.id) {
658                    (
659                        *edge_id,
660                        vertex_images.get(&source_start) == Some(edge_start_vertex_id),
661                    )
662                } else {
663                    let curve = if image_collapsed {
664                        NurbsCurve::new(
665                            1,
666                            vec![0.0, 0.0, 1.0, 1.0],
667                            vec![
668                                Vec4::from_point(points[0], 1.0),
669                                Vec4::from_point(points[0], 1.0),
670                            ],
671                        )?
672                    } else {
673                        interpolate_curve(&points, 1, &parameters)?
674                    };
675                    let start_vertex_id = claim_vertex_image(
676                        source_start,
677                        points[0],
678                        &mut vertex_images,
679                        &mut vertices,
680                        &mut next_id,
681                    );
682                    let end_vertex_id = claim_vertex_image(
683                        source_end,
684                        points[points.len() - 1],
685                        &mut vertex_images,
686                        &mut vertices,
687                        &mut next_id,
688                    );
689                    let id = next_id;
690                    next_id += 1;
691                    let domain = curve.domain()?;
692                    edges.push(EdgeRecord {
693                        id,
694                        curve,
695                        t0: domain[0],
696                        t1: domain[1],
697                        start_vertex_id,
698                        end_vertex_id,
699                        // Degenerate only if the IMAGE collapsed too — a cone
700                        // apex maps to a real ring on the offset surface and
701                        // must carry a real closed edge.
702                        degenerate: source_edge.degenerate && image_collapsed,
703                        // Image of a named source edge on the offset carrier;
704                        // suffixed so it cannot collide with the source edge
705                        // when both faces survive into one solid.
706                        name: source_edge
707                            .name
708                            .as_ref()
709                            .map(|name| format!("{name}_Offset")),
710                    });
711                    edge_images.insert(source_edge.id, (id, start_vertex_id));
712                    (id, true)
713                };
714            let id = next_id;
715            next_id += 1;
716            coedges.push(CoedgeRecord {
717                id,
718                edge_id,
719                forward,
720                pcurve: source_coedge.pcurve.clone(),
721            });
722        }
723        let id = next_id;
724        next_id += 1;
725        loops.push(LoopRecord { id, coedges });
726    }
727    Ok(OffsetFaceCarrier {
728        vertices,
729        edges,
730        face: FaceRecord {
731            id: next_id,
732            surface,
733            same_sense: source.same_sense,
734            loops,
735            name: source.name.as_ref().map(|name| format!("{name}_Offset")),
736        },
737    })
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743    use crate::{make_box_brep, make_cylinder_brep};
744
745    #[test]
746    fn affine_offset_is_exact_and_preserves_weights() {
747        let solid = make_box_brep(Vec3::default(), 4.0, 4.0, 4.0).unwrap();
748        let face = &solid.shells[0].faces[0];
749        let offset = offset_surface(face, 0.75, 0.0).unwrap();
750        let domain_u = KnotVector::new(face.surface.knots_u.clone(), 1)
751            .unwrap()
752            .domain();
753        let domain_v = KnotVector::new(face.surface.knots_v.clone(), 1)
754            .unwrap()
755            .domain();
756        let u = (domain_u[0] + domain_u[1]) / 2.0;
757        let v = (domain_v[0] + domain_v[1]) / 2.0;
758        let displacement = offset
759            .evaluate(u, v)
760            .unwrap()
761            .sub(face.surface.evaluate(u, v).unwrap());
762        assert!((displacement.length() - 0.75).abs() < 1e-12);
763    }
764
765    #[test]
766    fn curved_offset_carrier_maps_every_trim_to_new_surface() {
767        let solid =
768            make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 4.0).unwrap();
769        let side = &solid.shells[0].faces[0];
770        let carrier = offset_face_carrier(&solid, side.id, 0.5, 0.0).unwrap();
771        for coedge in carrier
772            .face
773            .loops
774            .iter()
775            .flat_map(|loop_record| &loop_record.coedges)
776        {
777            let edge = carrier
778                .edges
779                .iter()
780                .find(|edge| edge.id == coedge.edge_id)
781                .unwrap();
782            for fraction in [0.0, 0.3, 0.8, 1.0] {
783                let uv = coedge.pcurve.evaluate(fraction).unwrap();
784                let on_surface = carrier.face.surface.evaluate(uv.x, uv.y).unwrap();
785                let parameter = if coedge.forward {
786                    edge.t0 + (edge.t1 - edge.t0) * fraction
787                } else {
788                    edge.t1 - (edge.t1 - edge.t0) * fraction
789                };
790                assert!(
791                    on_surface
792                        .sub(edge.curve.evaluate(parameter).unwrap())
793                        .length()
794                        < 7e-4
795                );
796            }
797        }
798    }
799}