Skip to main content

brepkit_operations/
offset_face.rs

1//! Face offset: create a new face offset from an existing face by a given
2//! distance along its surface normal.
3//!
4//! For planar faces, this is an exact operation (translate along normal).
5//! For NURBS faces, the offset is approximated by sampling the surface
6//! normal field and refitting via surface interpolation.
7
8use brepkit_math::nurbs::surface_fitting::interpolate_surface;
9use brepkit_math::tolerance::Tolerance;
10use brepkit_math::vec::{Point3, Vec3};
11use brepkit_topology::Topology;
12use brepkit_topology::face::{FaceId, FaceSurface};
13
14use crate::OperationsError;
15
16/// Create a new face that is offset from `face_id` by `distance` along
17/// the outward surface normal.
18///
19/// Positive distance offsets outward (away from the solid interior),
20/// negative distance offsets inward.
21///
22/// For planar faces, the operation is exact. For NURBS faces, the
23/// surface is sampled at a grid of `samples × samples` points and
24/// re-interpolated.
25///
26/// # Errors
27///
28/// Returns an error if:
29/// - The face lookup fails
30/// - NURBS surface normal computation fails at any sample point
31/// - Surface re-interpolation fails
32pub fn offset_face(
33    topo: &mut Topology,
34    face_id: FaceId,
35    distance: f64,
36    samples: usize,
37) -> Result<FaceId, OperationsError> {
38    let tol = Tolerance::new();
39    if distance.abs() < tol.linear {
40        return copy_face(topo, face_id);
41    }
42
43    let face = topo.face(face_id)?;
44    let surface = face.surface().clone();
45    let outer_wire = face.outer_wire();
46    let inner_wires: Vec<_> = face.inner_wires().to_vec();
47
48    match surface {
49        FaceSurface::Plane { normal, d } => {
50            offset_planar_face(topo, outer_wire, &inner_wires, normal, d, distance)
51        }
52        FaceSurface::Nurbs(ref nurbs) => offset_nurbs_face(topo, face_id, nurbs, distance, samples),
53        FaceSurface::Cylinder(ref cyl) => {
54            offset_cylinder_face(topo, outer_wire, &inner_wires, cyl, distance)
55        }
56        FaceSurface::Cone(ref cone) => {
57            offset_cone_face(topo, outer_wire, &inner_wires, cone, distance)
58        }
59        FaceSurface::Sphere(ref sphere) => {
60            offset_sphere_face(topo, outer_wire, &inner_wires, sphere, distance)
61        }
62        FaceSurface::Torus(ref torus) => {
63            offset_torus_face(topo, outer_wire, &inner_wires, torus, distance)
64        }
65    }
66}
67
68/// Offset a planar face: shift plane along its normal.
69fn offset_planar_face(
70    topo: &mut Topology,
71    outer_wire: brepkit_topology::wire::WireId,
72    inner_wires: &[brepkit_topology::wire::WireId],
73    normal: Vec3,
74    d: f64,
75    distance: f64,
76) -> Result<FaceId, OperationsError> {
77    let new_d = d + distance;
78
79    let offset_vec = Vec3::new(
80        normal.x() * distance,
81        normal.y() * distance,
82        normal.z() * distance,
83    );
84
85    let new_outer = offset_wire_vertices(topo, outer_wire, offset_vec)?;
86
87    let mut new_inner = Vec::new();
88    for &iw in inner_wires {
89        let new_iw = offset_wire_vertices(topo, iw, offset_vec)?;
90        new_inner.push(new_iw);
91    }
92
93    let new_surface = FaceSurface::Plane { normal, d: new_d };
94    let face_id = topo.add_face(brepkit_topology::face::Face::new(
95        new_outer,
96        new_inner,
97        new_surface,
98    ));
99    Ok(face_id)
100}
101
102/// Offset a NURBS face by sampling and refitting with adaptive refinement.
103///
104/// Uses a two-pass approach:
105/// 1. Coarse grid sampling with curvature estimation at each point
106/// 2. Adaptive refinement: regions where `curvature × |distance|` is large
107///    (indicating potential cusps in the offset surface) get denser sampling
108///
109/// This prevents the previous uniform-grid approach from missing cusps
110/// at high-curvature regions of the input surface.
111#[allow(clippy::too_many_lines)]
112fn offset_nurbs_face(
113    topo: &mut Topology,
114    face_id: FaceId,
115    nurbs: &brepkit_math::nurbs::NurbsSurface,
116    distance: f64,
117    samples: usize,
118) -> Result<FaceId, OperationsError> {
119    let n = samples.max(4);
120    let tol = Tolerance::new();
121
122    let coarse = n.max(4);
123    #[allow(clippy::cast_precision_loss)]
124    let coarse_div = (coarse - 1) as f64;
125    let mut max_curvature = 0.0_f64;
126    let mut curvatures: Vec<Vec<f64>> = Vec::with_capacity(coarse);
127
128    #[allow(clippy::cast_precision_loss)]
129    for i in 0..coarse {
130        let u = i as f64 / coarse_div;
131        let mut row = Vec::with_capacity(coarse);
132        for j in 0..coarse {
133            let v = j as f64 / coarse_div;
134            let kappa = estimate_curvature(nurbs, u, v);
135            max_curvature = max_curvature.max(kappa);
136            row.push(kappa);
137        }
138        curvatures.push(row);
139    }
140
141    // Pre-check: if offset distance exceeds the minimum radius of curvature
142    // (1/max_curvature), the offset surface will self-intersect. Log a warning
143    // so callers know the result may be approximate.
144    if max_curvature > 1e-12 {
145        let min_radius_of_curvature = 1.0 / max_curvature;
146        if distance.abs() > min_radius_of_curvature {
147            log::warn!(
148                "offset_face: offset distance ({:.6}) exceeds minimum radius of curvature \
149                 ({:.6}); offset surface will self-intersect and be approximated",
150                distance.abs(),
151                min_radius_of_curvature,
152            );
153        }
154    }
155
156    // The 0.25 factor sets the refinement sensitivity: a cell is refined when
157    // its local `curvature * |distance|` exceeds 25% of the surface-wide
158    // maximum `max_curvature * |distance|`. This ensures at least the top
159    // 75% of curvature variation gets additional samples while avoiding
160    // over-refinement in nearly-flat regions.
161    let threshold = max_curvature * distance.abs() * 0.25;
162    let mut u_params: Vec<f64> = Vec::new();
163    let mut v_params: Vec<f64> = Vec::new();
164
165    #[allow(clippy::cast_precision_loss)]
166    for i in 0..coarse {
167        let u0 = i as f64 / coarse_div;
168        u_params.push(u0);
169
170        if i + 1 < coarse {
171            let row_max = curvatures[i].iter().copied().fold(0.0_f64, f64::max);
172            let cell_metric = row_max * distance.abs();
173            if threshold > 1e-12 && cell_metric > threshold {
174                let u1 = (i + 1) as f64 / coarse_div;
175                let mid = 0.5 * (u0 + u1);
176                u_params.push(mid);
177                if cell_metric > threshold * 3.0 {
178                    u_params.push(0.25_f64.mul_add(u1 - u0, u0));
179                    u_params.push(0.75_f64.mul_add(u1 - u0, u0));
180                }
181            }
182        }
183    }
184    if u_params.last().is_none_or(|&u| (u - 1.0).abs() > 1e-15) {
185        u_params.push(1.0);
186    }
187
188    #[allow(clippy::cast_precision_loss)]
189    for j in 0..coarse {
190        let v0 = j as f64 / coarse_div;
191        v_params.push(v0);
192
193        if j + 1 < coarse {
194            let col_max = curvatures.iter().map(|row| row[j]).fold(0.0_f64, f64::max);
195            let cell_metric = col_max * distance.abs();
196            if threshold > 1e-12 && cell_metric > threshold {
197                let v1 = (j + 1) as f64 / coarse_div;
198                let mid = 0.5 * (v0 + v1);
199                v_params.push(mid);
200                if cell_metric > threshold * 3.0 {
201                    v_params.push(0.25_f64.mul_add(v1 - v0, v0));
202                    v_params.push(0.75_f64.mul_add(v1 - v0, v0));
203                }
204            }
205        }
206    }
207    if v_params.last().is_none_or(|&v| (v - 1.0).abs() > 1e-15) {
208        v_params.push(1.0);
209    }
210
211    u_params.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
212    u_params.dedup_by(|a, b| (*a - *b).abs() < 1e-15);
213    v_params.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
214    v_params.dedup_by(|a, b| (*a - *b).abs() < 1e-15);
215
216    let nu = u_params.len();
217    let nv = v_params.len();
218    let mut offset_grid: Vec<Vec<Point3>> = Vec::with_capacity(nu);
219
220    for &u in &u_params {
221        let mut row = Vec::with_capacity(nv);
222        for &v in &v_params {
223            let pt = nurbs.evaluate(u, v);
224            let normal = nurbs
225                .normal(u, v)
226                .map_err(|e| OperationsError::InvalidInput {
227                    reason: format!("NURBS normal computation failed at ({u}, {v}): {e}"),
228                })?;
229
230            let offset_pt = Point3::new(
231                normal.x().mul_add(distance, pt.x()),
232                normal.y().mul_add(distance, pt.y()),
233                normal.z().mul_add(distance, pt.z()),
234            );
235            row.push(offset_pt);
236        }
237        offset_grid.push(row);
238    }
239
240    let degree = nurbs.degree_u().min(nurbs.degree_v()).min(3);
241    let raw_offset = interpolate_surface(&offset_grid, degree, degree).map_err(|e| {
242        OperationsError::InvalidInput {
243            reason: format!("offset surface interpolation failed: {e}"),
244        }
245    })?;
246
247    // Attempt self-intersection detection and trimming. If trimming fails,
248    // fall back to the raw offset surface. This can happen when SSI detection
249    // is inconclusive or the valid region is too small to refit. The raw
250    // offset may contain self-intersections in high-curvature regions.
251    let offset_surface = match crate::offset_trim::trim_offset_self_intersections(
252        nurbs,
253        &raw_offset,
254        distance,
255        tol.linear,
256    ) {
257        Ok(trimmed) => trimmed,
258        Err(e) => {
259            log::debug!(
260                target: "brepkit_approx",
261                "offset_face: raw-offset-surface fallback (SSI trim failed: {e})"
262            );
263            log::warn!(
264                "offset_face: self-intersection trimming failed ({e}), \
265                 using raw offset surface"
266            );
267            raw_offset
268        }
269    };
270
271    let face = topo.face(face_id)?;
272    let outer_wire = face.outer_wire();
273    let inner_wires: Vec<_> = face.inner_wires().to_vec();
274
275    let new_outer = offset_wire_along_nurbs(topo, outer_wire, nurbs, distance)?;
276
277    let mut new_inner = Vec::new();
278    for &iw in &inner_wires {
279        let new_iw = offset_wire_along_nurbs(topo, iw, nurbs, distance)?;
280        new_inner.push(new_iw);
281    }
282
283    let new_surface = FaceSurface::Nurbs(offset_surface);
284    let face_id = topo.add_face(brepkit_topology::face::Face::new(
285        new_outer,
286        new_inner,
287        new_surface,
288    ));
289    Ok(face_id)
290}
291
292/// Offset a cylindrical face: create a new cylinder with adjusted radius.
293///
294/// Cylinder offset: radius R → R + distance. The surface type is preserved.
295fn offset_cylinder_face(
296    topo: &mut Topology,
297    outer_wire: brepkit_topology::wire::WireId,
298    inner_wires: &[brepkit_topology::wire::WireId],
299    cyl: &brepkit_math::surfaces::CylindricalSurface,
300    distance: f64,
301) -> Result<FaceId, OperationsError> {
302    let new_radius = cyl.radius() + distance;
303    if new_radius <= 0.0 {
304        return Err(OperationsError::InvalidInput {
305            reason: format!(
306                "cylinder offset by {distance} would produce negative radius \
307                 (original radius = {})",
308                cyl.radius()
309            ),
310        });
311    }
312
313    let new_cyl =
314        brepkit_math::surfaces::CylindricalSurface::new(cyl.origin(), cyl.axis(), new_radius)
315            .map_err(OperationsError::Math)?;
316
317    let radial_offset = |pt: Point3| -> Point3 {
318        let to_axis = Vec3::new(
319            pt.x() - cyl.origin().x(),
320            pt.y() - cyl.origin().y(),
321            pt.z() - cyl.origin().z(),
322        );
323        // Project out the axis component to get the radial direction.
324        let along_axis = cyl.axis() * cyl.axis().dot(to_axis);
325        let radial = to_axis - along_axis;
326        if let Ok(dir) = radial.normalize() {
327            pt + dir * distance
328        } else {
329            pt // Point is on the axis; can't determine radial direction.
330        }
331    };
332
333    let new_outer = offset_wire_by_fn(topo, outer_wire, &radial_offset)?;
334    let mut new_inner = Vec::new();
335    for &iw in inner_wires {
336        new_inner.push(offset_wire_by_fn(topo, iw, &radial_offset)?);
337    }
338
339    let face_id = topo.add_face(brepkit_topology::face::Face::new(
340        new_outer,
341        new_inner,
342        FaceSurface::Cylinder(new_cyl),
343    ));
344    Ok(face_id)
345}
346
347/// Offset a spherical face: create a new sphere with adjusted radius.
348///
349/// Sphere offset: radius R → R + distance. The surface type is preserved.
350fn offset_sphere_face(
351    topo: &mut Topology,
352    outer_wire: brepkit_topology::wire::WireId,
353    inner_wires: &[brepkit_topology::wire::WireId],
354    sphere: &brepkit_math::surfaces::SphericalSurface,
355    distance: f64,
356) -> Result<FaceId, OperationsError> {
357    let new_radius = sphere.radius() + distance;
358    if new_radius <= 0.0 {
359        return Err(OperationsError::InvalidInput {
360            reason: format!(
361                "sphere offset by {distance} would produce negative radius \
362                 (original radius = {})",
363                sphere.radius()
364            ),
365        });
366    }
367
368    let new_sphere = brepkit_math::surfaces::SphericalSurface::new(sphere.center(), new_radius)
369        .map_err(OperationsError::Math)?;
370
371    let radial_offset = |pt: Point3| -> Point3 {
372        let to_center = pt - sphere.center();
373        if let Ok(dir) = to_center.normalize() {
374            pt + dir * distance
375        } else {
376            pt
377        }
378    };
379
380    let new_outer = offset_wire_by_fn(topo, outer_wire, &radial_offset)?;
381    let mut new_inner = Vec::new();
382    for &iw in inner_wires {
383        new_inner.push(offset_wire_by_fn(topo, iw, &radial_offset)?);
384    }
385
386    let face_id = topo.add_face(brepkit_topology::face::Face::new(
387        new_outer,
388        new_inner,
389        FaceSurface::Sphere(new_sphere),
390    ));
391    Ok(face_id)
392}
393
394/// Offset a conical face: shift apex along axis, preserve half-angle.
395///
396/// Cone offset: the apex moves along the axis by distance / sin(half_angle),
397/// preserving the half-angle. The new cone has the same shape but is larger
398/// (positive offset) or smaller (negative offset).
399fn offset_cone_face(
400    topo: &mut Topology,
401    outer_wire: brepkit_topology::wire::WireId,
402    inner_wires: &[brepkit_topology::wire::WireId],
403    cone: &brepkit_math::surfaces::ConicalSurface,
404    distance: f64,
405) -> Result<FaceId, OperationsError> {
406    // The offset of a cone is another cone with the same half-angle.
407    // The apex shifts along the axis by d / sin(α).
408    let tol = Tolerance::new();
409    let sin_ha = cone.half_angle().sin();
410    if sin_ha.abs() < tol.linear {
411        return Err(OperationsError::InvalidInput {
412            reason: "cone half-angle is degenerate (sin ≈ 0)".into(),
413        });
414    }
415
416    let apex_shift = distance / sin_ha;
417    let new_apex = cone.apex() + cone.axis() * apex_shift;
418
419    let new_cone =
420        brepkit_math::surfaces::ConicalSurface::new(new_apex, cone.axis(), cone.half_angle())
421            .map_err(OperationsError::Math)?;
422
423    let radial_offset = |pt: Point3| -> Point3 {
424        let to_apex = Vec3::new(
425            pt.x() - cone.apex().x(),
426            pt.y() - cone.apex().y(),
427            pt.z() - cone.apex().z(),
428        );
429        let along_axis = cone.axis() * cone.axis().dot(to_apex);
430        let radial = to_apex - along_axis;
431        if let Ok(dir) = radial.normalize() {
432            pt + dir * distance
433        } else {
434            pt
435        }
436    };
437
438    let new_outer = offset_wire_by_fn(topo, outer_wire, &radial_offset)?;
439    let mut new_inner = Vec::new();
440    for &iw in inner_wires {
441        new_inner.push(offset_wire_by_fn(topo, iw, &radial_offset)?);
442    }
443
444    let face_id = topo.add_face(brepkit_topology::face::Face::new(
445        new_outer,
446        new_inner,
447        FaceSurface::Cone(new_cone),
448    ));
449    Ok(face_id)
450}
451
452/// Offset a toroidal face: adjust the minor radius.
453///
454/// Torus offset: minor radius r → r + distance. Major radius unchanged.
455fn offset_torus_face(
456    topo: &mut Topology,
457    outer_wire: brepkit_topology::wire::WireId,
458    inner_wires: &[brepkit_topology::wire::WireId],
459    torus: &brepkit_math::surfaces::ToroidalSurface,
460    distance: f64,
461) -> Result<FaceId, OperationsError> {
462    let new_minor = torus.minor_radius() + distance;
463    if new_minor <= 0.0 {
464        return Err(OperationsError::InvalidInput {
465            reason: format!(
466                "torus offset by {distance} would produce negative minor radius \
467                 (original minor = {})",
468                torus.minor_radius()
469            ),
470        });
471    }
472
473    let new_torus = brepkit_math::surfaces::ToroidalSurface::new(
474        torus.center(),
475        torus.major_radius(),
476        new_minor,
477    )
478    .map_err(OperationsError::Math)?;
479
480    let z_axis = torus.z_axis();
481    let center = torus.center();
482    let radial_offset = |pt: Point3| -> Point3 {
483        let to_center = Vec3::new(
484            pt.x() - center.x(),
485            pt.y() - center.y(),
486            pt.z() - center.z(),
487        );
488        // Project to the plane perpendicular to the torus axis.
489        let in_plane = to_center - z_axis * z_axis.dot(to_center);
490        // Direction from the tube center circle to the point.
491        if let Ok(ring_dir) = in_plane.normalize() {
492            let tube_center = center + ring_dir * torus.major_radius();
493            let to_tube = Vec3::new(
494                pt.x() - tube_center.x(),
495                pt.y() - tube_center.y(),
496                pt.z() - tube_center.z(),
497            );
498            if let Ok(tube_dir) = to_tube.normalize() {
499                pt + tube_dir * distance
500            } else {
501                pt
502            }
503        } else {
504            pt
505        }
506    };
507
508    let new_outer = offset_wire_by_fn(topo, outer_wire, &radial_offset)?;
509    let mut new_inner = Vec::new();
510    for &iw in inner_wires {
511        new_inner.push(offset_wire_by_fn(topo, iw, &radial_offset)?);
512    }
513
514    let face_id = topo.add_face(brepkit_topology::face::Face::new(
515        new_outer,
516        new_inner,
517        FaceSurface::Torus(new_torus),
518    ));
519    Ok(face_id)
520}
521
522/// Estimate surface curvature at parameter (u, v) using second derivatives.
523///
524/// Returns the maximum absolute principal curvature. Uses the second
525/// fundamental form eigenvalues: `κ = (L·N - M²) / (E·G - F²)` for
526/// Gaussian curvature, and `(E·N - 2·F·M + G·L) / (2·(E·G - F²))` for
527/// mean curvature. The max principal curvature is `|H| + sqrt(H² - K)`.
528fn estimate_curvature(nurbs: &brepkit_math::nurbs::NurbsSurface, u: f64, v: f64) -> f64 {
529    let d = nurbs.derivatives(u, v, 2);
530    if d.len() < 3 || d[0].len() < 3 {
531        return 0.0;
532    }
533
534    let su = d[1][0]; // ∂S/∂u
535    let sv = d[0][1]; // ∂S/∂v
536    let suu = d[2][0]; // ∂²S/∂u²
537    let suv = d[1][1]; // ∂²S/∂u∂v
538    let svv = d[0][2]; // ∂²S/∂v²
539
540    let n_raw = su.cross(sv);
541    let n_len = n_raw.length();
542    if n_len < 1e-20 {
543        return 0.0;
544    }
545    let n = n_raw * (1.0 / n_len);
546
547    // First fundamental form coefficients.
548    let e_coeff = su.dot(su);
549    let f_coeff = su.dot(sv);
550    let g_coeff = sv.dot(sv);
551
552    // Second fundamental form coefficients.
553    let l_coeff = suu.dot(n);
554    let m_coeff = suv.dot(n);
555    let n_coeff = svv.dot(n);
556
557    let denom = e_coeff * g_coeff - f_coeff * f_coeff;
558    if denom.abs() < 1e-30 {
559        return 0.0;
560    }
561
562    // Mean curvature H and Gaussian curvature K.
563    let h = (e_coeff * n_coeff - 2.0 * f_coeff * m_coeff + g_coeff * l_coeff) / (2.0 * denom);
564    let k = (l_coeff * n_coeff - m_coeff * m_coeff) / denom;
565
566    // Max principal curvature: |H| + sqrt(max(H² - K, 0))
567    let disc = (h * h - k).max(0.0).sqrt();
568    (h.abs() + disc).abs()
569}
570
571/// Offset all vertices in a wire using a position-dependent function.
572fn offset_wire_by_fn(
573    topo: &mut Topology,
574    wire_id: brepkit_topology::wire::WireId,
575    offset_fn: &dyn Fn(Point3) -> Point3,
576) -> Result<brepkit_topology::wire::WireId, OperationsError> {
577    use brepkit_topology::edge::{Edge, EdgeCurve};
578    use brepkit_topology::vertex::Vertex;
579    use brepkit_topology::wire::{OrientedEdge, Wire};
580
581    let wire = topo.wire(wire_id)?;
582    let edges = wire.edges().to_vec();
583
584    // Snapshot then allocate.
585    let mut snaps: Vec<(Point3, Point3, EdgeCurve, bool)> = Vec::new();
586    for oe in &edges {
587        let edge = topo.edge(oe.edge())?;
588        let start_pt = topo.vertex(edge.start())?.point();
589        let end_pt = topo.vertex(edge.end())?.point();
590        snaps.push((start_pt, end_pt, edge.curve().clone(), oe.is_forward()));
591    }
592
593    let tol = Tolerance::new();
594    let mut new_oriented = Vec::new();
595    for (start_pt, end_pt, curve, forward) in snaps {
596        let new_start = topo.add_vertex(Vertex::new(offset_fn(start_pt), tol.linear));
597        let new_end = topo.add_vertex(Vertex::new(offset_fn(end_pt), tol.linear));
598        let new_edge = topo.add_edge(Edge::new(new_start, new_end, curve));
599        new_oriented.push(OrientedEdge::new(new_edge, forward));
600    }
601
602    let new_wire = topo.add_wire(Wire::new(new_oriented, true)?);
603    Ok(new_wire)
604}
605
606/// Copy a face with new IDs.
607fn copy_face(topo: &mut Topology, face_id: FaceId) -> Result<FaceId, OperationsError> {
608    let face = topo.face(face_id)?;
609    let surface = face.surface().clone();
610    let outer_wire = face.outer_wire();
611    let inner_wires: Vec<_> = face.inner_wires().to_vec();
612
613    let new_outer = copy_wire(topo, outer_wire)?;
614    let mut new_inner = Vec::new();
615    for &iw in &inner_wires {
616        new_inner.push(copy_wire(topo, iw)?);
617    }
618
619    let new_face = topo.add_face(brepkit_topology::face::Face::new(
620        new_outer, new_inner, surface,
621    ));
622    Ok(new_face)
623}
624
625/// Copy a wire with new vertex and edge IDs.
626fn copy_wire(
627    topo: &mut Topology,
628    wire_id: brepkit_topology::wire::WireId,
629) -> Result<brepkit_topology::wire::WireId, OperationsError> {
630    use brepkit_topology::edge::Edge;
631    use brepkit_topology::edge::EdgeCurve;
632    use brepkit_topology::vertex::Vertex;
633    use brepkit_topology::wire::{OrientedEdge, Wire};
634
635    let wire = topo.wire(wire_id)?;
636    let edges = wire.edges().to_vec();
637
638    // Snapshot edge data before allocating (borrow checker).
639    let mut edge_snaps: Vec<(Point3, f64, Point3, f64, EdgeCurve, bool)> = Vec::new();
640    for oe in &edges {
641        let edge = topo.edge(oe.edge())?;
642        let start = topo.vertex(edge.start())?;
643        let end = topo.vertex(edge.end())?;
644        edge_snaps.push((
645            start.point(),
646            start.tolerance(),
647            end.point(),
648            end.tolerance(),
649            edge.curve().clone(),
650            oe.is_forward(),
651        ));
652    }
653
654    let mut new_oriented = Vec::new();
655    for (start_pt, start_tol, end_pt, end_tol, curve, forward) in edge_snaps {
656        let new_start = topo.add_vertex(Vertex::new(start_pt, start_tol));
657        let new_end = topo.add_vertex(Vertex::new(end_pt, end_tol));
658        let new_edge = topo.add_edge(Edge::new(new_start, new_end, curve));
659        new_oriented.push(OrientedEdge::new(new_edge, forward));
660    }
661
662    let new_wire = topo.add_wire(Wire::new(new_oriented, true)?);
663    Ok(new_wire)
664}
665
666/// Offset all vertices in a wire by a constant vector.
667fn offset_wire_vertices(
668    topo: &mut Topology,
669    wire_id: brepkit_topology::wire::WireId,
670    offset: Vec3,
671) -> Result<brepkit_topology::wire::WireId, OperationsError> {
672    use brepkit_topology::edge::{Edge, EdgeCurve};
673    use brepkit_topology::vertex::Vertex;
674    use brepkit_topology::wire::{OrientedEdge, Wire};
675
676    let wire = topo.wire(wire_id)?;
677    let edges = wire.edges().to_vec();
678
679    // Snapshot then allocate.
680    let mut edge_snaps: Vec<(Point3, Point3, EdgeCurve, bool)> = Vec::new();
681    for oe in &edges {
682        let edge = topo.edge(oe.edge())?;
683        let start_pt = topo.vertex(edge.start())?.point();
684        let end_pt = topo.vertex(edge.end())?.point();
685        edge_snaps.push((start_pt, end_pt, edge.curve().clone(), oe.is_forward()));
686    }
687
688    let tol = Tolerance::new();
689    let mut new_oriented = Vec::new();
690    for (start_pt, end_pt, curve, forward) in edge_snaps {
691        let new_start = topo.add_vertex(Vertex::new(start_pt + offset, tol.linear));
692        let new_end = topo.add_vertex(Vertex::new(end_pt + offset, tol.linear));
693        let new_edge = topo.add_edge(Edge::new(new_start, new_end, curve));
694        new_oriented.push(OrientedEdge::new(new_edge, forward));
695    }
696
697    let new_wire = topo.add_wire(Wire::new(new_oriented, true)?);
698    Ok(new_wire)
699}
700
701/// Offset wire vertices along the NURBS surface normal at their closest
702/// parametric position.
703fn offset_wire_along_nurbs(
704    topo: &mut Topology,
705    wire_id: brepkit_topology::wire::WireId,
706    nurbs: &brepkit_math::nurbs::NurbsSurface,
707    distance: f64,
708) -> Result<brepkit_topology::wire::WireId, OperationsError> {
709    use brepkit_topology::edge::{Edge, EdgeCurve};
710    use brepkit_topology::vertex::Vertex;
711    use brepkit_topology::wire::{OrientedEdge, Wire};
712
713    let wire = topo.wire(wire_id)?;
714    let edges = wire.edges().to_vec();
715
716    // Snapshot then compute offsets.
717    let mut snaps: Vec<(Point3, Point3, bool)> = Vec::new();
718    for oe in &edges {
719        let edge = topo.edge(oe.edge())?;
720        let start_pt = topo.vertex(edge.start())?.point();
721        let end_pt = topo.vertex(edge.end())?.point();
722        snaps.push((start_pt, end_pt, oe.is_forward()));
723    }
724
725    let tol = Tolerance::new();
726    let mut new_oriented = Vec::new();
727    for (start_pt, end_pt, forward) in snaps {
728        let new_start_pt = offset_point_on_surface(nurbs, start_pt, distance)?;
729        let new_end_pt = offset_point_on_surface(nurbs, end_pt, distance)?;
730
731        let new_start = topo.add_vertex(Vertex::new(new_start_pt, tol.linear));
732        let new_end = topo.add_vertex(Vertex::new(new_end_pt, tol.linear));
733        let new_edge = topo.add_edge(Edge::new(new_start, new_end, EdgeCurve::Line));
734        new_oriented.push(OrientedEdge::new(new_edge, forward));
735    }
736
737    let new_wire = topo.add_wire(Wire::new(new_oriented, true)?);
738    Ok(new_wire)
739}
740
741/// Offset a single point along the surface normal at its closest parametric
742/// position on the NURBS surface.
743fn offset_point_on_surface(
744    nurbs: &brepkit_math::nurbs::NurbsSurface,
745    point: Point3,
746    distance: f64,
747) -> Result<Point3, OperationsError> {
748    use brepkit_math::nurbs::projection::project_point_to_surface;
749
750    let tol = Tolerance::new();
751    let proj = project_point_to_surface(nurbs, point, tol.linear).map_err(|e| {
752        OperationsError::InvalidInput {
753            reason: format!("surface projection failed: {e}"),
754        }
755    })?;
756    let u = proj.u;
757    let v = proj.v;
758    let normal = nurbs
759        .normal(u, v)
760        .map_err(|e| OperationsError::InvalidInput {
761            reason: format!("NURBS normal at ({u}, {v}) failed: {e}"),
762        })?;
763
764    Ok(Point3::new(
765        normal.x().mul_add(distance, point.x()),
766        normal.y().mul_add(distance, point.y()),
767        normal.z().mul_add(distance, point.z()),
768    ))
769}
770
771#[cfg(test)]
772mod tests {
773    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
774
775    use brepkit_topology::Topology;
776    use brepkit_topology::test_utils::make_unit_square_face;
777
778    use super::*;
779
780    #[test]
781    fn offset_planar_face_outward() {
782        let mut topo = Topology::new();
783        let face = make_unit_square_face(&mut topo);
784
785        let offset = offset_face(&mut topo, face, 1.0, 10).unwrap();
786
787        // The offset face should have a shifted plane.
788        let offset_face = topo.face(offset).unwrap();
789        match offset_face.surface() {
790            FaceSurface::Plane { normal, d } => {
791                // Unit square face is on z=0, normal=(0,0,1), d=0.
792                // Offset by 1.0 should give d=1.0.
793                assert!((normal.z() - 1.0).abs() < 1e-6);
794                assert!((d - 1.0).abs() < 1e-6);
795            }
796            _ => panic!("expected planar surface"),
797        }
798    }
799
800    #[test]
801    fn offset_planar_face_inward() {
802        let mut topo = Topology::new();
803        let face = make_unit_square_face(&mut topo);
804
805        let offset = offset_face(&mut topo, face, -0.5, 10).unwrap();
806
807        let offset_face = topo.face(offset).unwrap();
808        match offset_face.surface() {
809            FaceSurface::Plane { d, .. } => {
810                assert!((d - (-0.5)).abs() < 1e-6);
811            }
812            _ => panic!("expected planar surface"),
813        }
814    }
815
816    #[test]
817    fn offset_zero_returns_copy() {
818        let mut topo = Topology::new();
819        let face = make_unit_square_face(&mut topo);
820
821        let offset = offset_face(&mut topo, face, 0.0, 10).unwrap();
822
823        // Should return a different face ID (it's a copy).
824        assert_ne!(face, offset);
825
826        // But same surface properties.
827        let original = topo.face(face).unwrap();
828        let copied = topo.face(offset).unwrap();
829        match (original.surface(), copied.surface()) {
830            (
831                FaceSurface::Plane { normal: n1, d: d1 },
832                FaceSurface::Plane { normal: n2, d: d2 },
833            ) => {
834                assert!((n1.x() - n2.x()).abs() < 1e-10);
835                assert!((n1.y() - n2.y()).abs() < 1e-10);
836                assert!((n1.z() - n2.z()).abs() < 1e-10);
837                assert!((d1 - d2).abs() < 1e-10);
838            }
839            _ => panic!("expected both planar"),
840        }
841    }
842
843    #[test]
844    fn offset_face_preserves_vertex_count() {
845        let mut topo = Topology::new();
846        let face = make_unit_square_face(&mut topo);
847
848        let offset = offset_face(&mut topo, face, 2.0, 10).unwrap();
849
850        // Count vertices in original vs offset.
851        let orig_face = topo.face(face).unwrap();
852        let offset_face = topo.face(offset).unwrap();
853
854        let orig_wire = topo.wire(orig_face.outer_wire()).unwrap();
855        let off_wire = topo.wire(offset_face.outer_wire()).unwrap();
856
857        assert_eq!(orig_wire.edges().len(), off_wire.edges().len());
858    }
859
860    #[test]
861    fn offset_vertices_are_shifted() {
862        let mut topo = Topology::new();
863        let face = make_unit_square_face(&mut topo);
864
865        let offset = offset_face(&mut topo, face, 3.0, 10).unwrap();
866
867        // Get a vertex from the offset face and check it's shifted.
868        let off_face = topo.face(offset).unwrap();
869        let off_wire = topo.wire(off_face.outer_wire()).unwrap();
870        let first_edge = off_wire.edges()[0];
871        let edge = topo.edge(first_edge.edge()).unwrap();
872        let vert = topo.vertex(edge.start()).unwrap();
873
874        // Original unit square has vertices at z=0.
875        // Offset of 3.0 along +Z normal should give z=3.0.
876        assert!(
877            (vert.point().z() - 3.0).abs() < 1e-6,
878            "expected z=3.0, got z={}",
879            vert.point().z()
880        );
881    }
882
883    /// Build a flat bilinear NURBS face on the XY plane (z = `z_height`).
884    ///
885    /// Degree-1 in both u and v, 2×2 control points, clamped knot vectors.
886    /// The wire has 4 line-edges forming a unit square at `z_height`.
887    fn make_flat_nurbs_face(topo: &mut Topology, z_height: f64) -> FaceId {
888        use brepkit_math::nurbs::NurbsSurface;
889        use brepkit_math::vec::Point3 as P;
890        use brepkit_topology::edge::{Edge, EdgeCurve};
891        use brepkit_topology::face::Face;
892        use brepkit_topology::vertex::Vertex;
893        use brepkit_topology::wire::{OrientedEdge, Wire};
894
895        // 2×2 control-point grid spanning [0,1]×[0,1] at given z.
896        let ctrl = vec![
897            vec![P::new(0.0, 0.0, z_height), P::new(1.0, 0.0, z_height)],
898            vec![P::new(0.0, 1.0, z_height), P::new(1.0, 1.0, z_height)],
899        ];
900        let weights = vec![vec![1.0_f64, 1.0], vec![1.0, 1.0]];
901        // Clamped knot vector for degree 1, 2 control points: [0,0,1,1]
902        let knots = vec![0.0, 0.0, 1.0, 1.0];
903        let nurbs = NurbsSurface::new(1, 1, knots.clone(), knots, ctrl, weights).unwrap();
904
905        // Wire: unit square in XY at z_height.
906        let tol = 1e-7;
907        let v0 = topo.add_vertex(Vertex::new(P::new(0.0, 0.0, z_height), tol));
908        let v1 = topo.add_vertex(Vertex::new(P::new(1.0, 0.0, z_height), tol));
909        let v2 = topo.add_vertex(Vertex::new(P::new(1.0, 1.0, z_height), tol));
910        let v3 = topo.add_vertex(Vertex::new(P::new(0.0, 1.0, z_height), tol));
911
912        let e0 = topo.add_edge(Edge::new(v0, v1, EdgeCurve::Line));
913        let e1 = topo.add_edge(Edge::new(v1, v2, EdgeCurve::Line));
914        let e2 = topo.add_edge(Edge::new(v2, v3, EdgeCurve::Line));
915        let e3 = topo.add_edge(Edge::new(v3, v0, EdgeCurve::Line));
916
917        let wire = Wire::new(
918            vec![
919                OrientedEdge::new(e0, true),
920                OrientedEdge::new(e1, true),
921                OrientedEdge::new(e2, true),
922                OrientedEdge::new(e3, true),
923            ],
924            true,
925        )
926        .unwrap();
927        let wid = topo.add_wire(wire);
928
929        topo.add_face(Face::new(wid, vec![], FaceSurface::Nurbs(nurbs)))
930    }
931
932    #[test]
933    fn offset_nurbs_face_outward_produces_nurbs_surface() {
934        let mut topo = Topology::new();
935        let face = make_flat_nurbs_face(&mut topo, 0.0);
936
937        let offset_id = offset_face(&mut topo, face, 1.0, 6).unwrap();
938
939        // The result must carry a NURBS surface.
940        let off_face = topo.face(offset_id).unwrap();
941        assert!(
942            matches!(off_face.surface(), FaceSurface::Nurbs(_)),
943            "expected NURBS surface after NURBS offset"
944        );
945    }
946
947    #[test]
948    fn offset_nurbs_face_new_id_differs_from_original() {
949        let mut topo = Topology::new();
950        let face = make_flat_nurbs_face(&mut topo, 0.0);
951
952        let offset_id = offset_face(&mut topo, face, 0.5, 6).unwrap();
953
954        assert_ne!(face, offset_id, "offset should return a new face ID");
955    }
956
957    #[test]
958    fn offset_nurbs_face_wire_has_same_edge_count() {
959        let mut topo = Topology::new();
960        let face = make_flat_nurbs_face(&mut topo, 0.0);
961
962        let offset_id = offset_face(&mut topo, face, 1.0, 6).unwrap();
963
964        let orig_wire = topo.wire(topo.face(face).unwrap().outer_wire()).unwrap();
965        let off_wire = topo
966            .wire(topo.face(offset_id).unwrap().outer_wire())
967            .unwrap();
968        assert_eq!(
969            orig_wire.edges().len(),
970            off_wire.edges().len(),
971            "offset wire should have the same edge count as the original"
972        );
973    }
974
975    #[test]
976    fn offset_nurbs_face_negative_distance() {
977        let mut topo = Topology::new();
978        // Place the surface at z=2 so a negative offset moves it toward z=1.
979        let face = make_flat_nurbs_face(&mut topo, 0.0);
980
981        let offset_id = offset_face(&mut topo, face, -0.5, 6).unwrap();
982
983        // Result is still a valid NURBS face.
984        let off_face = topo.face(offset_id).unwrap();
985        assert!(matches!(off_face.surface(), FaceSurface::Nurbs(_)));
986    }
987
988    #[test]
989    fn offset_nurbs_face_very_small_distance() {
990        let mut topo = Topology::new();
991        let face = make_flat_nurbs_face(&mut topo, 0.0);
992
993        // A distance well above the linear tolerance (1e-7) but very small.
994        let offset_id = offset_face(&mut topo, face, 1e-4, 6).unwrap();
995
996        let off_face = topo.face(offset_id).unwrap();
997        assert!(matches!(off_face.surface(), FaceSurface::Nurbs(_)));
998    }
999
1000    #[test]
1001    fn offset_nurbs_face_zero_returns_copy() {
1002        let mut topo = Topology::new();
1003        let face = make_flat_nurbs_face(&mut topo, 0.0);
1004
1005        // Exactly zero: should go through the copy_face path, keeping NURBS.
1006        let copy_id = offset_face(&mut topo, face, 0.0, 6).unwrap();
1007
1008        assert_ne!(face, copy_id);
1009        let copy_face = topo.face(copy_id).unwrap();
1010        assert!(
1011            matches!(copy_face.surface(), FaceSurface::Nurbs(_)),
1012            "zero offset of NURBS face should still be NURBS"
1013        );
1014    }
1015
1016    #[test]
1017    fn offset_cylinder_face_preserves_type() {
1018        use brepkit_math::surfaces::CylindricalSurface;
1019        use brepkit_math::vec::{Point3 as P, Vec3};
1020        use brepkit_topology::edge::{Edge, EdgeCurve};
1021        use brepkit_topology::face::Face;
1022        use brepkit_topology::vertex::Vertex;
1023        use brepkit_topology::wire::{OrientedEdge, Wire};
1024
1025        let mut topo = Topology::new();
1026
1027        let tol = 1e-7;
1028        let v0 = topo.add_vertex(Vertex::new(P::new(1.0, 0.0, 0.0), tol));
1029        let v1 = topo.add_vertex(Vertex::new(P::new(0.0, 1.0, 0.0), tol));
1030        let v2 = topo.add_vertex(Vertex::new(P::new(0.0, 1.0, 1.0), tol));
1031        let v3 = topo.add_vertex(Vertex::new(P::new(1.0, 0.0, 1.0), tol));
1032        let e0 = topo.add_edge(Edge::new(v0, v1, EdgeCurve::Line));
1033        let e1 = topo.add_edge(Edge::new(v1, v2, EdgeCurve::Line));
1034        let e2 = topo.add_edge(Edge::new(v2, v3, EdgeCurve::Line));
1035        let e3 = topo.add_edge(Edge::new(v3, v0, EdgeCurve::Line));
1036        let wire = Wire::new(
1037            vec![
1038                OrientedEdge::new(e0, true),
1039                OrientedEdge::new(e1, true),
1040                OrientedEdge::new(e2, true),
1041                OrientedEdge::new(e3, true),
1042            ],
1043            true,
1044        )
1045        .unwrap();
1046        let wid = topo.add_wire(wire);
1047        let cyl =
1048            CylindricalSurface::new(P::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0).unwrap();
1049        let face_id = topo.add_face(Face::new(wid, vec![], FaceSurface::Cylinder(cyl)));
1050
1051        // Offset should succeed and produce a cylinder with larger radius.
1052        let result = offset_face(&mut topo, face_id, 0.5, 6).unwrap();
1053        let off_face = topo.face(result).unwrap();
1054        match off_face.surface() {
1055            FaceSurface::Cylinder(cyl) => {
1056                assert!(
1057                    (cyl.radius() - 1.5).abs() < 1e-10,
1058                    "offset cylinder radius should be 1.5, got {}",
1059                    cyl.radius()
1060                );
1061            }
1062            _ => panic!("expected cylinder surface after offset"),
1063        }
1064    }
1065
1066    #[test]
1067    fn offset_cylinder_negative_radius_error() {
1068        use brepkit_math::surfaces::CylindricalSurface;
1069        use brepkit_math::vec::{Point3 as P, Vec3};
1070        use brepkit_topology::edge::{Edge, EdgeCurve};
1071        use brepkit_topology::face::Face;
1072        use brepkit_topology::vertex::Vertex;
1073        use brepkit_topology::wire::{OrientedEdge, Wire};
1074
1075        let mut topo = Topology::new();
1076
1077        let tol = 1e-7;
1078        let v0 = topo.add_vertex(Vertex::new(P::new(0.5, 0.0, 0.0), tol));
1079        let v1 = topo.add_vertex(Vertex::new(P::new(0.0, 0.5, 0.0), tol));
1080        let e0 = topo.add_edge(Edge::new(v0, v1, EdgeCurve::Line));
1081        let e1 = topo.add_edge(Edge::new(v1, v0, EdgeCurve::Line));
1082        let wire = Wire::new(
1083            vec![OrientedEdge::new(e0, true), OrientedEdge::new(e1, true)],
1084            true,
1085        )
1086        .unwrap();
1087        let wid = topo.add_wire(wire);
1088        let cyl =
1089            CylindricalSurface::new(P::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 0.5).unwrap();
1090        let face_id = topo.add_face(Face::new(wid, vec![], FaceSurface::Cylinder(cyl)));
1091
1092        // Offset by -0.6 would produce negative radius.
1093        let result = offset_face(&mut topo, face_id, -0.6, 6);
1094        assert!(
1095            result.is_err(),
1096            "negative-radius cylinder offset should fail"
1097        );
1098    }
1099
1100    #[test]
1101    fn offset_nurbs_face_large_distance() {
1102        let mut topo = Topology::new();
1103        let face = make_flat_nurbs_face(&mut topo, 0.0);
1104
1105        // A large positive offset should still succeed (the surface is flat so
1106        // normals are uniform and well-defined everywhere).
1107        let offset_id = offset_face(&mut topo, face, 100.0, 8).unwrap();
1108
1109        let off_face = topo.face(offset_id).unwrap();
1110        assert!(matches!(off_face.surface(), FaceSurface::Nurbs(_)));
1111    }
1112
1113    #[test]
1114    fn offset_nurbs_face_minimum_samples_clamped() {
1115        let mut topo = Topology::new();
1116        let face = make_flat_nurbs_face(&mut topo, 0.0);
1117
1118        // Passing samples=1 should be clamped to 4 internally and still succeed.
1119        let offset_id = offset_face(&mut topo, face, 1.0, 1).unwrap();
1120
1121        let off_face = topo.face(offset_id).unwrap();
1122        assert!(matches!(off_face.surface(), FaceSurface::Nurbs(_)));
1123    }
1124}