1use 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
16pub 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
68fn 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#[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 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 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 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
292fn 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 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 }
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
347fn 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
394fn 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 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
452fn 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 let in_plane = to_center - z_axis * z_axis.dot(to_center);
490 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
522fn 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]; let sv = d[0][1]; let suu = d[2][0]; let suv = d[1][1]; let svv = d[0][2]; 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 let e_coeff = su.dot(su);
549 let f_coeff = su.dot(sv);
550 let g_coeff = sv.dot(sv);
551
552 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 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 let disc = (h * h - k).max(0.0).sqrt();
568 (h.abs() + disc).abs()
569}
570
571fn 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 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
606fn 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
625fn 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 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
666fn 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 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
701fn 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 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
741fn 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 let offset_face = topo.face(offset).unwrap();
789 match offset_face.surface() {
790 FaceSurface::Plane { normal, d } => {
791 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 assert_ne!(face, offset);
825
826 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}