use crate::{AnalyticSurface, NurbsSurface, Vec3};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OffsetNormal {
Raw,
Face { same_sense: bool },
FaceStable { same_sense: bool },
ExactAnalytic { same_sense: bool },
}
impl OffsetNormal {
fn same_sense(self) -> bool {
match self {
OffsetNormal::Raw => true,
OffsetNormal::Face { same_sense }
| OffsetNormal::FaceStable { same_sense }
| OffsetNormal::ExactAnalytic { same_sense } => same_sense,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct OffsetSample {
pub source: Vec3,
pub normal: Vec3,
pub point: Vec3,
}
#[derive(Clone, Copy)]
pub struct OffsetEvaluator<'a> {
site: &'static str,
surface: &'a NurbsSurface,
convention: OffsetNormal,
}
impl<'a> OffsetEvaluator<'a> {
pub fn new(site: &'static str, surface: &'a NurbsSurface, convention: OffsetNormal) -> Self {
Self {
site,
surface,
convention,
}
}
pub fn normal(&self, u: f64, v: f64) -> Result<Vec3, String> {
let result = self.normal_with(self.convention, u, v);
offset_normal_diagnostic(self.site, self.surface, self.convention, u, v, &result);
result
}
pub fn at(&self, u: f64, v: f64, distance: f64) -> Result<OffsetSample, String> {
let (source, normal) = self.evaluate(u, v)?;
Ok(OffsetSample {
source,
normal,
point: source.add(normal.scale(distance)),
})
}
fn evaluate(&self, u: f64, v: f64) -> Result<(Vec3, Vec3), String> {
let result = match self.convention {
OffsetNormal::Raw => self
.surface
.deriv1_extended(u, v)
.and_then(|(point, su, sv)| Ok((point, su.cross(sv).normalized()?))),
convention => self
.surface
.evaluate(u, v)
.and_then(|point| Ok((point, self.normal_with(convention, u, v)?))),
};
let normal = result
.as_ref()
.map(|(_, normal)| *normal)
.map_err(Clone::clone);
offset_normal_diagnostic(self.site, self.surface, self.convention, u, v, &normal);
result
}
fn normal_with(&self, convention: OffsetNormal, u: f64, v: f64) -> Result<Vec3, String> {
match convention {
OffsetNormal::Raw => {
let (_, su, sv) = self.surface.deriv1_extended(u, v)?;
su.cross(sv).normalized()
}
OffsetNormal::Face { same_sense } => {
Ok(orient(self.surface.normal(u, v)?, same_sense))
}
OffsetNormal::FaceStable { same_sense } => {
stable_normal(self.surface, same_sense, u, v)
}
OffsetNormal::ExactAnalytic { same_sense } => {
let point = self.surface.evaluate(u, v)?;
match exact_analytic_normal(self.surface, u, v, point)? {
Some(normal) => Ok(orient(normal, same_sense)),
None => Ok(orient(self.surface.normal(u, v)?, same_sense)),
}
}
}
}
}
fn orient(normal: Vec3, same_sense: bool) -> Vec3 {
if same_sense {
normal
} else {
normal.scale(-1.0)
}
}
fn domains(surface: &NurbsSurface) -> Result<([f64; 2], [f64; 2]), String> {
Ok((surface.domain_u()?, surface.domain_v()?))
}
fn stable_normal(
surface: &NurbsSurface,
same_sense: bool,
u: f64,
v: f64,
) -> Result<Vec3, String> {
let normal_at = |u, v| surface.normal(u, v).ok();
let mut normal = normal_at(u, v);
let ([u0, u1], [v0, v1]) = domains(surface)?;
if normal.is_none() {
let du = (u1 - u0) * 1e-5;
let dv = (v1 - v0) * 1e-5;
for (candidate_u, candidate_v) in [
((u + du).clamp(u0, u1), v),
((u - du).clamp(u0, u1), v),
(u, (v + dv).clamp(v0, v1)),
(u, (v - dv).clamp(v0, v1)),
] {
normal = normal_at(candidate_u, candidate_v);
if normal.is_some() {
break;
}
}
}
let singular_here = {
let du = (u1 - u0) * 1e-4;
let dv = (v1 - v0) * 1e-4;
let here = surface.evaluate(u, v)?;
let along_u = surface
.evaluate((u + du).clamp(u0, u1), v)?
.sub(here)
.length()
.max(
surface
.evaluate((u - du).clamp(u0, u1), v)?
.sub(here)
.length(),
);
let along_v = surface
.evaluate(u, (v + dv).clamp(v0, v1))?
.sub(here)
.length()
.max(
surface
.evaluate(u, (v - dv).clamp(v0, v1))?
.sub(here)
.length(),
);
let scale = along_u.max(along_v);
scale > 0.0 && along_u.min(along_v) < scale * 1e-6
};
if singular_here {
let v_mid = (v0 + v1) * 0.5;
let u_mid = (u0 + u1) * 0.5;
let mut interior = None;
for fraction in [1e-3, 1e-2, 5e-2, 0.25] {
let candidate_v = v + (v_mid - v) * fraction;
let candidate_u = u + (u_mid - u) * fraction;
for (cu, cv) in [(u, candidate_v), (candidate_u, v), (candidate_u, candidate_v)] {
if let Some(candidate) = normal_at(cu, cv) {
interior = Some(candidate);
break;
}
}
if interior.is_some() {
break;
}
}
normal = match (normal, interior) {
(Some(at_point), Some(interior)) if at_point.dot(interior) > 1.0 - 1e-6 => {
Some(at_point)
}
(_, Some(interior)) => Some(interior),
(at_point, None) => at_point,
};
}
let normal =
normal.ok_or_else(|| "offset_surface: cannot determine surface normal".to_string())?;
Ok(orient(normal, same_sense))
}
fn exact_analytic_normal(
surface: &NurbsSurface,
u: f64,
v: f64,
point: Vec3,
) -> Result<Option<Vec3>, String> {
let Some(analytic) = surface.analytic() else {
return Ok(None);
};
let Some(direction) = exact_direction(surface, analytic, u, v, point)? else {
return Ok(None);
};
let ([u0, u1], [v0, v1]) = domains(surface)?;
let probes = [
(u, v),
((u0 + u1) * 0.5, (v0 + v1) * 0.5),
(u0 + (u1 - u0) * 0.37, v0 + (v1 - v0) * 0.41),
(u0 + (u1 - u0) * 0.63, v0 + (v1 - v0) * 0.59),
];
for (pu, pv) in probes {
let Ok(parametric) = surface.normal(pu, pv) else {
continue;
};
let probe_point = if (pu, pv) == (u, v) {
point
} else {
surface.evaluate(pu, pv)?
};
let Some(probe_direction) = exact_direction(surface, analytic, pu, pv, probe_point)? else {
continue;
};
let alignment = probe_direction.dot(parametric);
if alignment.abs() < 0.5 {
continue;
}
return Ok(Some(if alignment > 0.0 {
direction
} else {
direction.scale(-1.0)
}));
}
Ok(None)
}
fn exact_direction(
surface: &NurbsSurface,
analytic: &AnalyticSurface,
u: f64,
v: f64,
point: Vec3,
) -> Result<Option<Vec3>, String> {
Ok(match analytic {
AnalyticSurface::Plane { u_dir, v_dir, .. } => u_dir.cross(*v_dir).normalized().ok(),
AnalyticSurface::Sphere { frame, .. } => {
point.sub(frame.origin).normalized().ok()
}
AnalyticSurface::RuledRevolution {
frame,
rho0,
rho1,
height,
} => {
let radial = match radial_direction(surface, frame.origin, frame.axis, u, v, point)? {
Some(radial) => radial,
None => return Ok(None),
};
let dr = rho1 - rho0;
let length = (dr * dr + height * height).sqrt();
if length <= 0.0 {
return Ok(None);
}
radial
.scale(*height / length)
.sub(frame.axis.scale(dr / length))
.normalized()
.ok()
}
AnalyticSurface::Torus {
frame,
major_radius,
minor_radius,
} => {
let radial = match radial_direction(surface, frame.origin, frame.axis, u, v, point)? {
Some(radial) => radial,
None => return Ok(None),
};
if *minor_radius <= 0.0 {
return Ok(None);
}
let tube_centre = frame.origin.add(radial.scale(*major_radius));
point.sub(tube_centre).normalized().ok()
}
AnalyticSurface::Revolution { .. } => None,
})
}
fn radial_direction(
surface: &NurbsSurface,
origin: Vec3,
axis: Vec3,
u: f64,
v: f64,
point: Vec3,
) -> Result<Option<Vec3>, String> {
let radial_of = |point: Vec3| {
let relative = point.sub(origin);
relative.sub(axis.scale(relative.dot(axis)))
};
let here = radial_of(point);
let [v0, v1] = surface.domain_v()?;
let scale = point.sub(origin).length().max(1.0);
if here.length() > 1e-12 * scale {
return Ok(here.normalized().ok());
}
let far = if (v - v0).abs() >= (v1 - v).abs() {
v0
} else {
v1
};
let candidate = radial_of(surface.evaluate(u, far)?);
if candidate.length() <= 1e-12 * scale {
return Ok(None);
}
Ok(candidate.normalized().ok())
}
#[derive(Default, Clone, Copy)]
struct LaneStats {
compared: u64,
lane_errors: u64,
lane_rescues: u64,
worst_chord: f64,
flipped: u64,
worst_u: f64,
worst_v: f64,
}
#[derive(Default, Clone, Copy)]
struct SiteStats {
calls: u64,
errors: u64,
convention: Option<OffsetNormal>,
lanes: [LaneStats; 4],
}
const LANE_NAMES: [&str; 4] = ["raw", "face", "stable", "exact"];
fn lane_of(index: usize, same_sense: bool) -> OffsetNormal {
match index {
0 => OffsetNormal::Raw,
1 => OffsetNormal::Face { same_sense },
2 => OffsetNormal::FaceStable { same_sense },
_ => OffsetNormal::ExactAnalytic { same_sense },
}
}
struct DiagnosticSink {
sites: std::collections::BTreeMap<&'static str, SiteStats>,
}
impl Drop for DiagnosticSink {
fn drop(&mut self) {
use std::fmt::Write as _;
for (site, stats) in &self.sites {
let mut line = format!(
"offset-diag site={site} convention={} calls={} errors={}",
stats
.convention
.map(|convention| format!("{convention:?}"))
.unwrap_or_else(|| "?".to_string()),
stats.calls,
stats.errors
);
for (index, lane) in stats.lanes.iter().enumerate() {
if lane.compared == 0 && lane.lane_errors == 0 && lane.lane_rescues == 0 {
continue;
}
let _ = write!(
line,
" | {}: n={} chord={:.3e} at=({:.6},{:.6}) flip={} lane_err={} rescue={}",
LANE_NAMES[index],
lane.compared,
lane.worst_chord,
lane.worst_u,
lane.worst_v,
lane.flipped,
lane.lane_errors,
lane.lane_rescues
);
}
eprintln!("{line}");
}
}
}
fn diagnostic_stride() -> u64 {
static STRIDE: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
*STRIDE.get_or_init(|| match std::env::var("BREP_OFFSET_DIAG") {
Ok(value) => value.trim().parse::<u64>().unwrap_or(1).max(1),
Err(_) => 0,
})
}
thread_local! {
static DIAGNOSTIC: std::cell::RefCell<DiagnosticSink> = std::cell::RefCell::new(DiagnosticSink {
sites: std::collections::BTreeMap::new(),
});
}
fn offset_normal_diagnostic(
site: &'static str,
surface: &NurbsSurface,
convention: OffsetNormal,
u: f64,
v: f64,
selected: &Result<Vec3, String>,
) {
let stride = diagnostic_stride();
if stride == 0 {
return;
}
let sampled = DIAGNOSTIC.with(|sink| {
let mut sink = sink.borrow_mut();
let stats = sink.sites.entry(site).or_default();
stats.convention = Some(convention);
stats.calls += 1;
if selected.is_err() {
stats.errors += 1;
}
stats.calls % stride == 0
});
if !sampled {
return;
}
let evaluator = OffsetEvaluator::new(site, surface, convention);
let same_sense = convention.same_sense();
let mut readings = [(0usize, f64::NAN, false, false); 4];
for (index, reading) in readings.iter_mut().enumerate() {
let lane = lane_of(index, same_sense);
let value = evaluator.normal_with(lane, u, v);
*reading = match (selected, &value) {
(Ok(chosen), Ok(other)) => {
let aligned = chosen.sub(*other).length();
let opposed = chosen.add(*other).length();
(index, aligned.min(opposed), true, opposed < aligned)
}
(Ok(_), Err(_)) => (index, f64::NAN, false, false),
(Err(_), Ok(_)) => (index, f64::NAN, true, false),
(Err(_), Err(_)) => (index, f64::NAN, false, false),
};
}
DIAGNOSTIC.with(|sink| {
let mut sink = sink.borrow_mut();
let stats = sink.sites.entry(site).or_default();
for (index, chord, lane_ok, flipped) in readings {
let lane = &mut stats.lanes[index];
if chord.is_finite() {
lane.compared += 1;
if flipped {
lane.flipped += 1;
}
if chord > lane.worst_chord {
lane.worst_chord = chord;
lane.worst_u = u;
lane.worst_v = v;
}
} else if selected.is_err() && lane_ok {
lane.lane_rescues += 1;
} else if selected.is_ok() && !lane_ok {
lane.lane_errors += 1;
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{make_box_brep, make_cylinder_brep, make_sphere_brep, Vec3};
fn cylinder() -> crate::topology::BrepSolid {
make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap()
}
fn sphere() -> crate::topology::BrepSolid {
make_sphere_brep(Vec3::default(), 3.0, Vec3::new(0.0, 0.0, 1.0)).unwrap()
}
fn surfaces(solid: &crate::topology::BrepSolid) -> Vec<(&NurbsSurface, bool)> {
solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.map(|face| (&face.surface, face.same_sense))
.collect()
}
#[test]
fn the_raw_lane_point_is_bitwise_evaluate_extended() {
let solid = cylinder();
for (surface, _) in surfaces(&solid) {
let [u0, u1] = surface.domain_u().unwrap();
let [v0, v1] = surface.domain_v().unwrap();
let evaluator = OffsetEvaluator::new("test", surface, OffsetNormal::Raw);
for iu in 0..5 {
for iv in 0..5 {
let u = u0 + (u1 - u0) * (iu as f64 - 0.1) / 4.0;
let v = v0 + (v1 - v0) * (iv as f64 - 0.1) / 4.0;
let expected = surface.evaluate_extended(u, v).unwrap();
let Ok(sample) = evaluator.at(u, v, 0.5) else {
continue;
};
assert_eq!(sample.source.x.to_bits(), expected.x.to_bits());
assert_eq!(sample.source.y.to_bits(), expected.y.to_bits());
assert_eq!(sample.source.z.to_bits(), expected.z.to_bits());
}
}
}
}
#[test]
fn the_face_lane_is_bitwise_the_hand_written_block() {
let solid = sphere();
for (surface, same_sense) in surfaces(&solid) {
let [u0, u1] = surface.domain_u().unwrap();
let [v0, v1] = surface.domain_v().unwrap();
let evaluator = OffsetEvaluator::new("test", surface, OffsetNormal::Face { same_sense });
for iu in 0..7 {
for iv in 0..7 {
let u = u0 + (u1 - u0) * (iu as f64 + 0.31) / 7.0;
let v = v0 + (v1 - v0) * (iv as f64 + 0.43) / 7.0;
let point = surface.evaluate(u, v).unwrap();
let mut expected = surface.normal(u, v).unwrap();
if !same_sense {
expected = expected.scale(-1.0);
}
let sample = evaluator.at(u, v, -0.25).unwrap();
assert_eq!(sample.normal.x.to_bits(), expected.x.to_bits());
assert_eq!(sample.normal.y.to_bits(), expected.y.to_bits());
assert_eq!(sample.normal.z.to_bits(), expected.z.to_bits());
let offset = point.add(expected.scale(-0.25));
assert_eq!(sample.point.x.to_bits(), offset.x.to_bits());
assert_eq!(sample.point.y.to_bits(), offset.y.to_bits());
assert_eq!(sample.point.z.to_bits(), offset.z.to_bits());
}
}
}
}
#[test]
fn the_raw_and_face_lanes_diverge_outside_the_domain() {
let solid = cylinder();
let mut worst = 0.0f64;
for (surface, same_sense) in surfaces(&solid) {
let [u0, u1] = surface.domain_u().unwrap();
let [v0, v1] = surface.domain_v().unwrap();
let raw = OffsetEvaluator::new("test", surface, OffsetNormal::Raw);
let face = OffsetEvaluator::new("test", surface, OffsetNormal::Face { same_sense });
for overshoot in [0.05, 0.25, 0.5] {
let u = u1 + (u1 - u0) * overshoot;
let v = (v0 + v1) * 0.5;
let (Ok(a), Ok(b)) = (raw.normal(u, v), face.normal(u, v)) else {
continue;
};
worst = worst.max(a.sub(b).length().min(a.add(b).length()));
}
}
assert!(
worst > 1e-3,
"the two lanes agreed everywhere outside the domain (worst {worst:.3e}) — either the \
extension changed or this fixture stopped leaving the domain, and either way the \
corpus no longer pins why both conventions exist"
);
}
#[test]
fn the_exact_lane_agrees_with_the_parametric_normal_where_both_are_defined() {
for solid in [
make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap(),
cylinder(),
sphere(),
] {
for (surface, same_sense) in surfaces(&solid) {
if surface.analytic().is_none() {
continue;
}
let [u0, u1] = surface.domain_u().unwrap();
let [v0, v1] = surface.domain_v().unwrap();
let exact =
OffsetEvaluator::new("test", surface, OffsetNormal::ExactAnalytic { same_sense });
let face = OffsetEvaluator::new("test", surface, OffsetNormal::Face { same_sense });
for iu in 0..9 {
for iv in 0..9 {
let u = u0 + (u1 - u0) * (iu as f64 + 0.37) / 9.0;
let v = v0 + (v1 - v0) * (iv as f64 + 0.29) / 9.0;
let Ok(parametric) = face.normal(u, v) else {
continue;
};
let closed = exact.normal(u, v).unwrap();
let chord = closed.sub(parametric).length();
assert!(
chord < 1e-14,
"exact normal disagrees by {chord:.3e} at ({u}, {v})"
);
}
}
}
}
}
#[test]
fn the_exact_lane_is_defined_at_a_sphere_pole() {
let solid = sphere();
let mut checked = 0;
for (surface, same_sense) in surfaces(&solid) {
if !matches!(surface.analytic(), Some(AnalyticSurface::Sphere { .. })) {
continue;
}
let [u0, u1] = surface.domain_u().unwrap();
let [v0, v1] = surface.domain_v().unwrap();
let exact =
OffsetEvaluator::new("test", surface, OffsetNormal::ExactAnalytic { same_sense });
for v in [v0, v1] {
let u = (u0 + u1) * 0.5;
let normal = exact
.normal(u, v)
.unwrap_or_else(|error| panic!("pole normal at v={v}: {error}"));
assert!(
(normal.length() - 1.0).abs() < 1e-12,
"pole normal is not unit: {normal:?}"
);
assert!(
normal.x.abs() < 1e-9 && normal.y.abs() < 1e-9,
"pole normal is not axial: {normal:?}"
);
checked += 1;
}
}
assert!(checked > 0, "no spherical carrier was reached");
}
#[test]
fn offsetting_a_sphere_stays_concentric() {
let solid = sphere();
for (surface, same_sense) in surfaces(&solid) {
let Some(AnalyticSurface::Sphere { frame, radius }) = surface.analytic() else {
continue;
};
let (centre, radius) = (frame.origin, *radius);
let [u0, u1] = surface.domain_u().unwrap();
let [v0, v1] = surface.domain_v().unwrap();
let evaluator =
OffsetEvaluator::new("test", surface, OffsetNormal::ExactAnalytic { same_sense });
for iu in 0..5 {
for iv in 0..5 {
let u = u0 + (u1 - u0) * iu as f64 / 4.0;
let v = v0 + (v1 - v0) * iv as f64 / 4.0;
let sample = evaluator.at(u, v, 0.75).unwrap();
let outward = sample.normal.dot(sample.source.sub(centre)) > 0.0;
let expected = if outward { radius + 0.75 } else { radius - 0.75 };
let actual = sample.point.sub(centre).length();
assert!(
(actual - expected).abs() < 1e-12,
"offset sphere radius {actual} vs {expected} at ({u}, {v})"
);
}
}
}
}
#[test]
fn the_exact_lane_is_defined_at_a_cone_apex() {
let generatrix = crate::interpolate_curve(
&[Vec3::new(2.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 4.0)],
1,
&[0.0, 1.0],
)
.unwrap();
let cone = crate::make_revolution(
Vec3::default(),
Vec3::new(0.0, 0.0, 1.0),
&generatrix,
std::f64::consts::TAU,
)
.unwrap();
assert!(matches!(
cone.analytic(),
Some(AnalyticSurface::RuledRevolution { .. })
));
let [u0, u1] = cone.domain_u().unwrap();
let [_, v1] = cone.domain_v().unwrap();
let exact = OffsetEvaluator::new("test", &cone, OffsetNormal::ExactAnalytic { same_sense: true });
let flank = exact.normal((u0 + u1) * 0.5, 0.5).unwrap();
let apex = exact.normal((u0 + u1) * 0.5, v1).unwrap();
let chord = flank.sub(apex).length();
assert!(chord < 1e-14, "apex normal differs from its ruling by {chord:.3e}");
let expected_axial = 2.0 / 20.0f64.sqrt();
assert!(
(apex.z.abs() - expected_axial).abs() < 1e-9,
"apex normal axial component {} vs {expected_axial}",
apex.z
);
}
}