use brepkit_math::nurbs::projection::project_point_to_surface;
use brepkit_math::nurbs::self_intersection::detect_self_intersection;
use brepkit_math::nurbs::surface::NurbsSurface;
use brepkit_math::nurbs::surface_fitting::interpolate_surface;
use brepkit_math::vec::Point3;
use crate::OperationsError;
const DETECTION_GRID: usize = 20;
const SSI_GRID: usize = 25;
const REFIT_GRID: usize = 30;
const MAX_INVALID_FRACTION: f64 = 0.5;
const DISTANCE_TOLERANCE_FACTOR: f64 = 10.0;
const RELATIVE_DISTANCE_TOL: f64 = 0.02;
#[allow(clippy::too_many_lines)]
pub fn trim_offset_self_intersections(
original: &NurbsSurface,
offset: &NurbsSurface,
offset_distance: f64,
tolerance: f64,
) -> Result<NurbsSurface, OperationsError> {
if let Ok(ssi_curves) = detect_self_intersection(offset, SSI_GRID, tolerance)
&& !ssi_curves.is_empty()
{
return trim_via_ssi(original, offset, offset_distance, tolerance, &ssi_curves);
}
log::debug!(
target: "brepkit_approx",
"offset_trim: SSI curve detection found nothing — falling back to grid-sampling trim ({DETECTION_GRID}x{DETECTION_GRID})"
);
trim_via_sampling(original, offset, offset_distance, tolerance)
}
#[allow(clippy::cast_precision_loss)]
fn trim_via_ssi(
original: &NurbsSurface,
offset: &NurbsSurface,
offset_distance: f64,
tolerance: f64,
ssi_curves: &[brepkit_math::nurbs::self_intersection::SelfIntersectionCurve],
) -> Result<NurbsSurface, OperationsError> {
let expected_dist = offset_distance.abs();
let dist_tol = distance_tolerance(tolerance, expected_dist);
let (u_min, u_max) = offset.domain_u();
let (v_min, v_max) = offset.domain_v();
let n = REFIT_GRID;
let mut invalid_params: Vec<(f64, f64)> = Vec::new();
for ssi in ssi_curves {
if ssi.params_a.is_empty() {
continue;
}
let mid_idx = ssi.params_a.len() / 2;
let (ua, va) = ssi.params_a[mid_idx];
let (ub, vb) = ssi.params_b[mid_idx];
let pt_a = offset.evaluate(ua, va);
let pt_b = offset.evaluate(ub, vb);
let dist_a = project_point_to_surface(original, pt_a, tolerance)
.map_or(expected_dist, |proj| proj.distance);
let dist_b = project_point_to_surface(original, pt_b, tolerance)
.map_or(expected_dist, |proj| proj.distance);
let a_err = (dist_a - expected_dist).abs();
let b_err = (dist_b - expected_dist).abs();
if a_err > b_err {
invalid_params.extend_from_slice(&ssi.params_a);
} else {
invalid_params.extend_from_slice(&ssi.params_b);
}
}
let u_cell = (u_max - u_min) / (n as f64);
let v_cell = (v_max - v_min) / (n as f64);
let proximity = (u_cell * u_cell + v_cell * v_cell).sqrt() * 1.5;
let mut mask = Vec::with_capacity(n);
for i in 0..n {
let u = lerp(u_min, u_max, i as f64 / (n - 1) as f64);
let mut row = Vec::with_capacity(n);
for j in 0..n {
let v = lerp(v_min, v_max, j as f64 / (n - 1) as f64);
let near_invalid = invalid_params
.iter()
.any(|&(iu, iv)| ((u - iu).powi(2) + (v - iv).powi(2)).sqrt() < proximity);
let valid = if near_invalid {
let pt = offset.evaluate(u, v);
project_point_to_surface(original, pt, tolerance).map_or(true, |proj| {
let dist_ok = (proj.distance - expected_dist).abs() <= dist_tol;
let normal_ok =
check_normal_consistency(original, offset, proj.u, proj.v, u, v);
dist_ok && normal_ok
})
} else {
true
};
row.push(valid);
}
mask.push(row);
}
let total = n * n;
let invalid_count = mask
.iter()
.flat_map(|row| row.iter())
.filter(|&&v| !v)
.count();
if invalid_count == 0 {
return Ok(offset.clone());
}
let invalid_fraction = invalid_count as f64 / total as f64;
if invalid_fraction > MAX_INVALID_FRACTION {
return Err(OperationsError::InvalidInput {
reason: format!(
"offset self-intersection covers {:.0}% of the surface (limit: {:.0}%)",
invalid_fraction * 100.0,
MAX_INVALID_FRACTION * 100.0
),
});
}
refit_valid_region(offset, &mask)
}
fn trim_via_sampling(
original: &NurbsSurface,
offset: &NurbsSurface,
offset_distance: f64,
tolerance: f64,
) -> Result<NurbsSurface, OperationsError> {
let validity_mask =
detect_self_intersections_sampling(original, offset, offset_distance, tolerance);
let total = validity_mask.len() * validity_mask[0].len();
let invalid_count = validity_mask
.iter()
.flat_map(|row| row.iter())
.filter(|&&v| !v)
.count();
if invalid_count == 0 {
return Ok(offset.clone());
}
#[allow(clippy::cast_precision_loss)]
let invalid_fraction = invalid_count as f64 / total as f64;
if invalid_fraction > MAX_INVALID_FRACTION {
return Err(OperationsError::InvalidInput {
reason: format!(
"offset self-intersection covers {:.0}% of the surface (limit: {:.0}%), \
offset distance may be too large",
invalid_fraction * 100.0,
MAX_INVALID_FRACTION * 100.0
),
});
}
let refined_mask = refine_validity_mask(original, offset, offset_distance, tolerance);
let refined_total = refined_mask.len() * refined_mask[0].len();
let refined_invalid = refined_mask
.iter()
.flat_map(|row| row.iter())
.filter(|&&v| !v)
.count();
#[allow(clippy::cast_precision_loss)]
let refined_fraction = refined_invalid as f64 / refined_total as f64;
if refined_fraction > MAX_INVALID_FRACTION {
return Err(OperationsError::InvalidInput {
reason: format!(
"offset self-intersection covers {:.0}% after refinement (limit: {:.0}%)",
refined_fraction * 100.0,
MAX_INVALID_FRACTION * 100.0
),
});
}
refit_valid_region(offset, &refined_mask)
}
fn distance_tolerance(tolerance: f64, expected_dist: f64) -> f64 {
(tolerance * DISTANCE_TOLERANCE_FACTOR).max(expected_dist * RELATIVE_DISTANCE_TOL)
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
t.mul_add(b - a, a)
}
#[allow(clippy::cast_precision_loss)]
fn detect_self_intersections_sampling(
original: &NurbsSurface,
offset: &NurbsSurface,
offset_distance: f64,
tolerance: f64,
) -> Vec<Vec<bool>> {
let (u_min, u_max) = offset.domain_u();
let (v_min, v_max) = offset.domain_v();
let n = DETECTION_GRID;
let expected_dist = offset_distance.abs();
let dist_tol = distance_tolerance(tolerance, expected_dist);
let mut mask = Vec::with_capacity(n);
for i in 0..n {
let u = lerp(u_min, u_max, i as f64 / (n - 1) as f64);
let mut row = Vec::with_capacity(n);
for j in 0..n {
let v = lerp(v_min, v_max, j as f64 / (n - 1) as f64);
let offset_pt = offset.evaluate(u, v);
let valid = project_point_to_surface(original, offset_pt, tolerance)
.map_or(true, |proj| {
(proj.distance - expected_dist).abs() <= dist_tol
});
row.push(valid);
}
mask.push(row);
}
mask
}
#[allow(clippy::cast_precision_loss)]
fn refine_validity_mask(
original: &NurbsSurface,
offset: &NurbsSurface,
offset_distance: f64,
tolerance: f64,
) -> Vec<Vec<bool>> {
let (u_min, u_max) = offset.domain_u();
let (v_min, v_max) = offset.domain_v();
let n = REFIT_GRID;
let expected_dist = offset_distance.abs();
let dist_tol = distance_tolerance(tolerance, expected_dist);
let mut mask = Vec::with_capacity(n);
for i in 0..n {
let u = lerp(u_min, u_max, i as f64 / (n - 1) as f64);
let mut row = Vec::with_capacity(n);
for j in 0..n {
let v = lerp(v_min, v_max, j as f64 / (n - 1) as f64);
let offset_pt = offset.evaluate(u, v);
let valid =
project_point_to_surface(original, offset_pt, tolerance).map_or(true, |proj| {
let dist_ok = (proj.distance - expected_dist).abs() <= dist_tol;
let normal_ok =
check_normal_consistency(original, offset, proj.u, proj.v, u, v);
dist_ok && normal_ok
});
row.push(valid);
}
mask.push(row);
}
mask
}
#[allow(clippy::similar_names)]
fn check_normal_consistency(
original: &NurbsSurface,
offset: &NurbsSurface,
orig_u: f64,
orig_v: f64,
off_u: f64,
off_v: f64,
) -> bool {
let Ok(orig_normal) = original.normal(orig_u, orig_v) else {
return true; };
let Ok(off_normal) = offset.normal(off_u, off_v) else {
return true;
};
orig_normal.dot(off_normal) > 0.0
}
#[allow(clippy::cast_precision_loss)]
fn refit_valid_region(
offset: &NurbsSurface,
validity_mask: &[Vec<bool>],
) -> Result<NurbsSurface, OperationsError> {
let (u_min, u_max) = offset.domain_u();
let (v_min, v_max) = offset.domain_v();
let n = validity_mask.len();
let mut u_valid_min = n;
let mut u_valid_max = 0_usize;
let mut v_valid_min = n;
let mut v_valid_max = 0_usize;
for (i, row) in validity_mask.iter().enumerate() {
for (j, &valid) in row.iter().enumerate() {
if valid {
u_valid_min = u_valid_min.min(i);
u_valid_max = u_valid_max.max(i);
v_valid_min = v_valid_min.min(j);
v_valid_max = v_valid_max.max(j);
}
}
}
if u_valid_max <= u_valid_min || v_valid_max <= v_valid_min {
return Err(OperationsError::InvalidInput {
reason: "valid region of offset surface is too small to refit".into(),
});
}
let refit_n = REFIT_GRID.min(u_valid_max - u_valid_min + 1).max(4);
let n_div = (n - 1) as f64;
let u_start = lerp(u_min, u_max, u_valid_min as f64 / n_div);
let u_end = lerp(u_min, u_max, u_valid_max as f64 / n_div);
let v_start = lerp(v_min, v_max, v_valid_min as f64 / n_div);
let v_end = lerp(v_min, v_max, v_valid_max as f64 / n_div);
let refit_div = (refit_n - 1) as f64;
let mut grid: Vec<Vec<Point3>> = Vec::with_capacity(refit_n);
for i in 0..refit_n {
let u = lerp(u_start, u_end, i as f64 / refit_div);
let mut row = Vec::with_capacity(refit_n);
for j in 0..refit_n {
let v = lerp(v_start, v_end, j as f64 / refit_div);
row.push(offset.evaluate(u, v));
}
grid.push(row);
}
let degree = offset.degree_u().min(offset.degree_v()).clamp(1, 3);
let degree = degree.min(refit_n - 1);
interpolate_surface(&grid, degree, degree).map_err(|e| OperationsError::InvalidInput {
reason: format!("offset refit interpolation failed: {e}"),
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use brepkit_math::nurbs::NurbsSurface;
use brepkit_math::vec::Point3 as P;
fn make_convex_surface() -> NurbsSurface {
let ctrl = vec![
vec![
P::new(0.0, 0.0, 1.0),
P::new(0.5, 0.0, 0.0),
P::new(1.0, 0.0, 1.0),
],
vec![
P::new(0.0, 0.5, 0.0),
P::new(0.5, 0.5, -1.0),
P::new(1.0, 0.5, 0.0),
],
vec![
P::new(0.0, 1.0, 1.0),
P::new(0.5, 1.0, 0.0),
P::new(1.0, 1.0, 1.0),
],
];
let weights = vec![vec![1.0; 3]; 3];
let knots = vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
NurbsSurface::new(2, 2, knots.clone(), knots, ctrl, weights).unwrap()
}
fn make_flat_surface() -> NurbsSurface {
let ctrl = vec![
vec![P::new(0.0, 0.0, 0.0), P::new(1.0, 0.0, 0.0)],
vec![P::new(0.0, 1.0, 0.0), P::new(1.0, 1.0, 0.0)],
];
let weights = vec![vec![1.0; 2]; 2];
let knots = vec![0.0, 0.0, 1.0, 1.0];
NurbsSurface::new(1, 1, knots.clone(), knots, ctrl, weights).unwrap()
}
#[allow(clippy::cast_precision_loss)]
fn manual_offset(surface: &NurbsSurface, distance: f64, samples: usize) -> NurbsSurface {
let n = samples;
let (u_min, u_max) = surface.domain_u();
let (v_min, v_max) = surface.domain_v();
let mut grid: Vec<Vec<P>> = Vec::with_capacity(n);
for i in 0..n {
let u = lerp(u_min, u_max, i as f64 / (n - 1) as f64);
let mut row = Vec::with_capacity(n);
for j in 0..n {
let v = lerp(v_min, v_max, j as f64 / (n - 1) as f64);
let pt = surface.evaluate(u, v);
let normal = surface.normal(u, v).unwrap();
row.push(P::new(
normal.x().mul_add(distance, pt.x()),
normal.y().mul_add(distance, pt.y()),
normal.z().mul_add(distance, pt.z()),
));
}
grid.push(row);
}
let degree = surface.degree_u().min(surface.degree_v()).min(3);
brepkit_math::nurbs::surface_fitting::interpolate_surface(&grid, degree, degree).unwrap()
}
#[test]
fn convex_surface_no_trim() {
let original = make_convex_surface();
let offset = manual_offset(&original, 0.1, 10);
let result = trim_offset_self_intersections(&original, &offset, 0.1, 1e-6).unwrap();
let p_orig = offset.evaluate(0.0, 0.0);
let p_result = result.evaluate(0.0, 0.0);
let dist = ((p_orig.x() - p_result.x()).powi(2)
+ (p_orig.y() - p_result.y()).powi(2)
+ (p_orig.z() - p_result.z()).powi(2))
.sqrt();
assert!(
dist < 0.1,
"convex surface should pass through with minimal change, got dist={dist}"
);
}
#[test]
fn concave_surface_trims_self_intersection() {
let original = make_convex_surface();
let offset = manual_offset(&original, -2.0, 10);
let result = trim_offset_self_intersections(&original, &offset, -2.0, 1e-6);
match result {
Ok(trimmed) => {
let orig_cp_count =
offset.control_points().len() * offset.control_points()[0].len();
let trim_cp_count =
trimmed.control_points().len() * trimmed.control_points()[0].len();
assert!(
trim_cp_count >= 4,
"trimmed surface should have at least 2x2 control points"
);
let _ = orig_cp_count; }
Err(e) => {
let msg = format!("{e}");
assert!(
msg.contains("self-intersection"),
"error should mention self-intersection: {msg}"
);
}
}
}
#[test]
fn fallback_on_extreme_offset() {
let original = make_flat_surface();
let bogus_offset = manual_offset(&original, 100.0, 6);
let result = trim_offset_self_intersections(&original, &bogus_offset, 1.0, 1e-6);
assert!(
result.is_err(),
"extreme offset mismatch should return an error"
);
}
#[test]
fn flat_surface_no_trim_needed() {
let original = make_flat_surface();
let offset = manual_offset(&original, 1.0, 6);
let result = trim_offset_self_intersections(&original, &offset, 1.0, 1e-6).unwrap();
let p = result.evaluate(0.5, 0.5);
assert!(
p.z().abs() > 0.5,
"flat offset should be displaced from z=0, got z={}",
p.z()
);
}
}