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;
}
}
});
}