use super::*;
use crate::projection::SurfaceProjection;
use crate::{parameter_point_in_face, project_point_to_surface, PointClass, PolygonClass, SolidClassifier, Vec2};
pub(super) fn edge_and_faces<'a>(
solid: &'a BrepSolid,
edge_id: u64,
) -> Result<(&'a EdgeRecord, Vec<&'a FaceRecord>), KernelRefusal> {
let edge = solid
.edges
.iter()
.find(|edge| edge.id == edge_id)
.ok_or_else(|| {
KernelRefusal::input(
KernelStage::Collect,
"edge_id",
format!("fillet: edge {edge_id} not found"),
)
})?;
let mut faces = Vec::new();
for shell in &solid.shells {
for face in &shell.faces {
if face
.loops
.iter()
.flat_map(|loop_record| &loop_record.coedges)
.any(|coedge| coedge.edge_id == edge_id)
&& !faces.iter().any(|known: &&FaceRecord| known.id == face.id)
{
faces.push(face);
}
}
}
Ok((edge, faces))
}
pub(crate) fn face_plane_normal(
face: &FaceRecord,
near: Vec3,
) -> Result<(Vec3, Vec3), KernelRefusal> {
let projection = crate::project_point_to_surface(&face.surface, near)
.or_refuse(KernelStage::Classify, "face_projection")?;
let normal = face
.surface
.normal(projection.u, projection.v)
.or_refuse(KernelStage::Classify, "face_normal")?;
let outward = if face.same_sense {
normal
} else {
normal.scale(-1.0)
};
Ok((projection.point, outward))
}
pub(crate) fn into_face_direction(
face: &FaceRecord,
construct_point: Vec3,
construct_tangent: Vec3,
probe_point: Vec3,
probe_tangent: Vec3,
probe_step: f64,
) -> Result<Vec3, KernelRefusal> {
let (_, probe_outward) = face_plane_normal(face, probe_point)?;
let probe_candidate = probe_outward
.cross(probe_tangent)
.normalized()
.or_refuse(KernelStage::Classify, "probe_candidate")?;
let (_, construct_outward) = face_plane_normal(face, construct_point)?;
let construct_candidate = construct_outward
.cross(construct_tangent)
.normalized()
.or_refuse(KernelStage::Classify, "construct_candidate")?;
for sign in [1.0, -1.0] {
let probe = probe_point.add(probe_candidate.scale(sign * probe_step));
let projection = crate::project_point_to_surface(&face.surface, probe)
.or_refuse(KernelStage::Classify, "probe_projection")?;
if projection.distance <= probe_step * 0.5 {
let class = crate::parameter_point_in_face(
face,
crate::Vec2 {
x: projection.u,
y: projection.v,
},
1e-6,
)
.or_refuse(KernelStage::Classify, "probe_trim_class")?;
if class == crate::PolygonClass::Inside {
return Ok(construct_candidate.scale(sign));
}
}
}
Err(support_direction_refusal(face))
}
fn support_direction_refusal(face: &FaceRecord) -> KernelRefusal {
KernelRefusal::ill_posed(
KernelStage::Classify,
"support_direction",
format!(
"fillet: could not orient the support direction into face {}",
face.id
),
)
}
const SIGN_PROBE_FRACTIONS: [f64; 9] = [
0.5, 0.25, 0.75, 0.375, 0.625, 0.125, 0.875, 0.0625, 0.9375,
];
fn into_face_direction_probed(
face: &FaceRecord,
construct_point: Vec3,
construct_tangent: Vec3,
station: &dyn Fn(f64) -> Result<(Vec3, Vec3), KernelRefusal>,
probe_step: f64,
) -> Result<Vec3, KernelRefusal> {
let mut refusal = None;
for fraction in SIGN_PROBE_FRACTIONS {
let Ok((probe_point, probe_tangent)) = station(fraction) else {
continue;
};
match into_face_direction(
face,
construct_point,
construct_tangent,
probe_point,
probe_tangent,
probe_step,
) {
Ok(direction) => return Ok(direction),
Err(error) => refusal = Some(error),
}
}
Err(refusal.unwrap_or_else(|| support_direction_refusal(face)))
}
pub(super) enum EdgePath {
Straight {
direction: Vec3,
length: f64,
},
Circular {
center: Vec3,
axis: Vec3,
sweep: f64,
},
}
pub(super) struct EdgeCross {
pub(super) start: Vec3,
pub(super) path: EdgePath,
pub(super) into_first: Vec3,
pub(super) into_second: Vec3,
pub(super) convex: bool,
}
pub(super) fn analyze_edge(
solid: &BrepSolid,
edge_id: u64,
radius: f64,
) -> Result<EdgeCross, KernelRefusal> {
if !(radius > 0.0) || !radius.is_finite() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"radius",
"fillet: radius must be positive",
));
}
let (edge, faces) = edge_and_faces(solid, edge_id)?;
if faces.len() != 2 {
return Err(KernelRefusal::input(
KernelStage::Collect,
"edge_faces",
format!(
"fillet: edge {edge_id} borders {} faces (expected 2)",
faces.len()
),
));
}
if faces.iter().all(|face| {
matches!(
face.surface.analytic(),
Some(crate::AnalyticSurface::Sphere { .. })
)
}) {
return Err(KernelRefusal::unsupported(
KernelStage::Classify,
"spherical_meridians",
"fillet: spherical meridians require the general blend surgery",
));
}
let evaluate = |t: f64| {
edge.curve
.evaluate(t)
.or_refuse(KernelStage::Classify, "edge_evaluate")
};
let start = evaluate(edge.t0)?;
let end = evaluate(edge.t1)?;
let middle = evaluate((edge.t0 + edge.t1) * 0.5)?;
let chord = end.sub(start);
let straight = chord.length() > 1e-9 && {
let dir = chord.scale(1.0 / chord.length());
let tol = 1e-6 * (1.0 + chord.length());
(0..=8).all(|i| {
let t = edge.t0 + (edge.t1 - edge.t0) * i as f64 / 8.0;
edge.curve
.evaluate(t)
.map(|p| {
let d = p.sub(start);
d.sub(dir.scale(d.dot(dir))).length() <= tol
})
.unwrap_or(false)
})
};
let path = if straight {
let length = chord.length();
EdgePath::Straight {
direction: chord.scale(1.0 / length),
length,
}
} else {
let quarter = evaluate(edge.t0 + (edge.t1 - edge.t0) * 0.25)?;
let center = crate::analytic_surface::circumcenter(start, quarter, middle)
.ok_or_else(|| {
KernelRefusal::unsupported(
KernelStage::Classify,
"edge_path_fit",
"fillet: edge is neither straight nor circular",
)
})?;
let radius = start.sub(center).length();
let axis = quarter
.sub(center)
.cross(middle.sub(center))
.normalized()
.map_err(|_| {
KernelRefusal::unsupported(
KernelStage::Classify,
"circular_axis",
"fillet: circular edge axis is degenerate",
)
})?;
for index in 0..=16 {
let point = evaluate(edge.t0 + (edge.t1 - edge.t0) * index as f64 / 16.0)?;
let radial = point.sub(center);
if (radial.length() - radius).abs() > 1e-6 * (1.0 + radius)
|| radial.dot(axis).abs() > 1e-6 * (1.0 + radius)
{
return Err(KernelRefusal::unsupported(
KernelStage::Classify,
"edge_path_samples",
"fillet: edge is neither straight nor circular",
));
}
}
let closed = start.sub(end).length() <= 1e-6 * (1.0 + radius);
let sweep = if closed {
std::f64::consts::TAU
} else {
let from = start.sub(center);
let to = end.sub(center);
let mut angle = from.cross(to).dot(axis).atan2(from.dot(to));
if angle <= 0.0 {
angle += std::f64::consts::TAU;
}
angle
};
EdgePath::Circular {
center,
axis,
sweep,
}
};
for face in &faces {
let analytic = face.surface.analytic();
let supported = match (&path, analytic) {
(EdgePath::Straight { .. }, Some(crate::AnalyticSurface::Plane { .. })) => true,
(EdgePath::Circular { axis, .. }, Some(surface)) => {
match surface {
crate::AnalyticSurface::Plane { u_dir, v_dir, .. } => {
u_dir
.cross(*v_dir)
.normalized()
.map(|normal| normal.cross(*axis).length() <= 1e-6)
== Ok(true)
}
crate::AnalyticSurface::RuledRevolution { frame, .. } => {
frame.axis.cross(*axis).length() <= 1e-6
}
crate::AnalyticSurface::Revolution {
frame, generatrix, ..
} if generatrix.degree == 1 => frame.axis.cross(*axis).length() <= 1e-6,
_ => false,
}
}
_ => false,
};
if !supported {
return Err(KernelRefusal::unsupported(
KernelStage::Classify,
"exact_tool_carriers",
"fillet: edge/face combination not supported by the exact tool (straight edges \
need planar faces; circular edges need straight-meridian faces rotationally \
symmetric about the edge axis)",
));
}
}
let (_, first_outward) = face_plane_normal(faces[0], start)?;
let (_, second_outward) = face_plane_normal(faces[1], start)?;
if first_outward.cross(second_outward).length() <= 1e-6 {
return Err(KernelRefusal::input(
KernelStage::Classify,
"tangent_faces",
"fillet: faces are tangent along the edge",
));
}
let tangent_at = |point: Vec3| -> Result<Vec3, KernelRefusal> {
match &path {
EdgePath::Straight { direction, .. } => Ok(*direction),
EdgePath::Circular { center, axis, .. } => axis
.cross(point.sub(*center))
.normalized()
.or_refuse(KernelStage::Classify, "edge_tangent"),
}
};
let scale = match &path {
EdgePath::Straight { length, .. } => *length,
EdgePath::Circular { center, .. } => start.sub(*center).length(),
};
let probe_step = (scale * 0.05).min(radius * 0.5).max(1e-6);
let start_tangent = tangent_at(start)?;
let station = |fraction: f64| -> Result<(Vec3, Vec3), KernelRefusal> {
let point = evaluate(edge.t0 + (edge.t1 - edge.t0) * fraction)?;
Ok((point, tangent_at(point)?))
};
let into_first =
into_face_direction_probed(faces[0], start, start_tangent, &station, probe_step)?;
let into_second =
into_face_direction_probed(faces[1], start, start_tangent, &station, probe_step)?;
let convex = into_first.dot(second_outward) < -1e-9 || into_second.dot(first_outward) < -1e-9;
Ok(EdgeCross {
start,
path,
into_first,
into_second,
convex,
})
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct DihedralProfile {
pub(crate) min_angle: f64,
pub(crate) max_angle: f64,
pub(crate) any_convex: bool,
pub(crate) any_concave: bool,
pub(crate) samples: usize,
pub(crate) flip_fraction: Option<f64>,
}
impl DihedralProfile {
pub(crate) fn is_mixed(&self) -> bool {
self.any_convex && self.any_concave
}
}
pub(crate) fn scan_dihedral(
solid: &BrepSolid,
edge_id: u64,
) -> Result<DihedralProfile, KernelRefusal> {
let (edge, faces) = edge_and_faces(solid, edge_id)?;
if faces.len() != 2 {
return Err(KernelRefusal::input(
KernelStage::Collect,
"scan_edge_faces",
format!(
"dihedral scan: edge {edge_id} borders {} faces (expected 2)",
faces.len()
),
));
}
let angular = crate::KernelTolerances::for_solid(solid, 1e-7).angular;
let span = edge.t1 - edge.t0;
let tangent_at = |t: f64| -> Result<Vec3, KernelRefusal> {
edge.curve
.point_and_unit_tangent_extended(t, edge.t0, edge.t1)
.map(|(_, tangent)| tangent)
.map_err(|error| {
KernelRefusal::internal(
KernelStage::Classify,
"scan_tangent",
format!("dihedral scan: edge {edge_id}: {error}"),
)
})
};
let evaluate = |t: f64| {
edge.curve
.evaluate(t)
.or_refuse(KernelStage::Classify, "scan_evaluate")
};
let middle_t = edge.t0 + span * 0.5;
let middle = evaluate(middle_t)?;
let middle_tangent = tangent_at(middle_t)?;
let scale = evaluate(edge.t0)?
.sub(evaluate(edge.t1)?)
.length()
.max(1.0);
let probe_step = (scale * 0.05).max(1e-6);
let station = |fraction: f64| -> Result<(Vec3, Vec3), KernelRefusal> {
let t = edge.t0 + span * fraction;
Ok((evaluate(t)?, tangent_at(t)?))
};
let mut signs = [1.0f64; 2];
for (index, face) in faces.iter().enumerate() {
let into =
into_face_direction_probed(face, middle, middle_tangent, &station, probe_step)?;
let (_, outward) = face_plane_normal(face, middle)?;
let candidate = outward
.cross(middle_tangent)
.normalized()
.or_refuse(KernelStage::Classify, "scan_candidate")?;
signs[index] = if candidate.dot(into) >= 0.0 { 1.0 } else { -1.0 };
}
const SAMPLES: usize = 32;
let mut profile = DihedralProfile {
min_angle: f64::INFINITY,
max_angle: f64::NEG_INFINITY,
any_convex: false,
any_concave: false,
samples: 0,
flip_fraction: None,
};
let mut first_sense: Option<bool> = None;
for step in 0..SAMPLES {
let fraction = (step as f64 + 0.5) / SAMPLES as f64;
let t = edge.t0 + span * fraction;
let Ok(point) = edge.curve.evaluate(t) else {
continue;
};
let Ok(tangent) = tangent_at(t) else {
continue;
};
let mut normals = [Vec3::default(); 2];
let mut into = [Vec3::default(); 2];
let mut usable = true;
for (index, face) in faces.iter().enumerate() {
let Ok((_, outward)) = face_plane_normal(face, point) else {
usable = false;
break;
};
let Ok(candidate) = outward.cross(tangent).normalized() else {
usable = false;
break;
};
normals[index] = outward;
into[index] = candidate.scale(signs[index]);
}
if !usable {
continue;
}
profile.samples += 1;
let opening = into[0].dot(into[1]).clamp(-1.0, 1.0).acos();
let sense = into[0].dot(normals[1]) + into[1].dot(normals[0]);
let interior = if sense < 0.0 {
opening
} else {
std::f64::consts::TAU - opening
};
profile.min_angle = profile.min_angle.min(interior);
profile.max_angle = profile.max_angle.max(interior);
let flat = std::f64::consts::PI;
let classified = if interior < flat - angular {
profile.any_convex = true;
Some(true)
} else if interior > flat + angular {
profile.any_concave = true;
Some(false)
} else {
None
};
if let Some(current) = classified {
match first_sense {
None => first_sense = Some(current),
Some(known) if known != current && profile.flip_fraction.is_none() => {
profile.flip_fraction = Some(fraction);
}
_ => {}
}
}
}
if profile.samples * 2 < SAMPLES {
return Err(KernelRefusal::internal(
KernelStage::Classify,
"scan_coverage",
format!(
"dihedral scan: only {} of {} stations on edge {edge_id} could be evaluated",
profile.samples,
SAMPLES
),
));
}
Ok(profile)
}
pub(super) fn check_mixed_concavity(
solid: &BrepSolid,
edge_id: u64,
entry: &str,
) -> Result<(), KernelRefusal> {
let Ok(profile) = scan_dihedral(solid, edge_id) else {
return Ok(());
};
if !profile.is_mixed() {
return Ok(());
}
Err(KernelRefusal::ill_posed(
KernelStage::Classify,
"mixed_concavity",
format!(
"{entry}: edge {edge_id} has MIXED concavity — its interior angle runs from {:.3}° to \
{:.3}° and crosses flat{}, so it is convex along part of its length and concave along \
the rest. A rolling ball would have to pass from inside the material to outside it \
partway along, so no single blend serves this edge: split it where the faces become \
tangent and blend the parts separately.",
profile.min_angle.to_degrees(),
profile.max_angle.to_degrees(),
profile
.flip_fraction
.map(|fraction| format!(" at about {:.0}% along", fraction * 100.0))
.unwrap_or_default(),
),
))
}
fn rotate_about(v: Vec3, axis: Vec3, angle: f64) -> Vec3 {
let (sin, cos) = angle.sin_cos();
v.scale(cos)
.add(axis.cross(v).scale(sin))
.add(axis.scale(axis.dot(v) * (1.0 - cos)))
}
fn tangent_neighbours<'a>(solid: &'a BrepSolid, face: &FaceRecord) -> Vec<&'a FaceRecord> {
let angular = crate::KernelTolerances::for_solid(solid, 1e-7).angular;
let borders: Vec<&EdgeRecord> = face
.loops
.iter()
.flat_map(|loop_record| &loop_record.coedges)
.filter_map(|coedge| solid.edges.iter().find(|edge| edge.id == coedge.edge_id))
.collect();
let mut found: Vec<&FaceRecord> = Vec::new();
for other in solid.shells.iter().flat_map(|shell| &shell.faces) {
if other.id == face.id {
continue;
}
let shared: Vec<&&EdgeRecord> = borders
.iter()
.filter(|border| {
other
.loops
.iter()
.flat_map(|lp| &lp.coedges)
.any(|coedge| coedge.edge_id == border.id)
})
.collect();
let tangent = shared.iter().any(|border| {
(0..=2).all(|step| {
let t = border.t0 + (border.t1 - border.t0) * step as f64 / 2.0;
let Ok(point) = border.curve.evaluate(t) else {
return false;
};
match (
face_plane_normal(face, point),
face_plane_normal(other, point),
) {
(Ok((_, here)), Ok((_, there))) => {
here.dot(there) > 0.0 && here.cross(there).length() <= angular
}
_ => false,
}
})
});
if tangent {
found.push(other);
}
}
found
}
pub(crate) fn neighbour_carries_ball(
solid: &BrepSolid,
face: &FaceRecord,
neighbour: &FaceRecord,
size: f64,
) -> bool {
let limit = size * (1.0 + 1e-6);
let mut stations = 0usize;
for coedge in face.loops.iter().flat_map(|lp| &lp.coedges) {
let shared = neighbour
.loops
.iter()
.flat_map(|lp| &lp.coedges)
.any(|other| other.edge_id == coedge.edge_id);
if !shared {
continue;
}
let Some(border) = solid.edges.iter().find(|edge| edge.id == coedge.edge_id) else {
continue;
};
for step in 0..=2 {
let t = border.t0 + (border.t1 - border.t0) * step as f64 / 2.0;
let Ok(point) = border.curve.evaluate(t) else {
return false;
};
let Ok(projection) = crate::project_point_to_surface(&neighbour.surface, point) else {
return false;
};
let Ok((kappa_min, kappa_max)) = neighbour
.surface
.principal_curvatures(projection.u, projection.v)
else {
return false;
};
let tightest = kappa_min.abs().max(kappa_max.abs());
if tightest > 0.0 && 1.0 / tightest <= limit {
return false;
}
stations += 1;
}
}
stations > 0
}
fn boundary_extent_bound(
curve: &NurbsCurve,
station: Vec3,
direction: Vec3,
required: f64,
depth: usize,
) -> f64 {
let mut upper = f64::NEG_INFINITY;
for p in &curve.control_points {
let Ok(point) = p.point() else {
return f64::INFINITY;
};
if p.w <= 0.0 {
return f64::INFINITY;
}
upper = upper.max(point.sub(station).dot(direction));
}
if upper < required || depth == 20 {
return upper;
}
let Ok([a, b]) = curve.domain() else {
return upper;
};
let mid = (a + b) * 0.5;
if [a, mid, b].iter().any(|&t| {
curve
.evaluate(t)
.is_ok_and(|p| p.sub(station).dot(direction) >= required)
}) {
return upper;
}
let Ok((left, right)) = curve.split(mid) else {
return upper;
};
boundary_extent_bound(&left, station, direction, required, depth + 1).max(
boundary_extent_bound(&right, station, direction, required, depth + 1),
)
}
pub(super) fn check_support_extent(
solid: &BrepSolid,
edge_id: u64,
size: f64,
entry: &str,
) -> Result<(), KernelRefusal> {
match support_extent(solid, edge_id, size, entry)? {
SupportExtent::Fits => Ok(()),
SupportExtent::ConcaveClip { refusal, .. } => Err(refusal),
}
}
pub(super) fn check_support_extent_clipping(
solid: &BrepSolid,
edge_id: u64,
size: f64,
entry: &str,
) -> Result<(), KernelRefusal> {
support_extent(solid, edge_id, size, entry).map(|_| ())
}
pub(super) fn concave_clip_face(solid: &BrepSolid, edge_id: u64, size: f64) -> Option<u64> {
match support_extent(solid, edge_id, size, "") {
Ok(SupportExtent::ConcaveClip { face, .. }) => Some(face),
_ => None,
}
}
enum SupportExtent {
Fits,
ConcaveClip { face: u64, refusal: KernelRefusal },
}
fn support_extent(
solid: &BrepSolid,
edge_id: u64,
size: f64,
entry: &str,
) -> Result<SupportExtent, KernelRefusal> {
if std::env::var_os("BREP_NO_SUPPORT_EXTENT").is_some() {
return Ok(SupportExtent::Fits);
}
let Ok(cross) = analyze_edge(solid, edge_id, size) else {
return Ok(SupportExtent::Fits);
};
let Ok((_, faces)) = edge_and_faces(solid, edge_id) else {
return Ok(SupportExtent::Fits);
};
if faces.len() != 2 {
return Ok(SupportExtent::Fits);
}
let phi = cross
.into_first
.dot(cross.into_second)
.clamp(-1.0, 1.0)
.acos();
if !(phi > 1e-9) || phi >= std::f64::consts::PI - 1e-9 {
return Ok(SupportExtent::Fits);
}
let reach = size / (phi * 0.5).tan();
if !reach.is_finite() || reach <= 0.0 {
return Ok(SupportExtent::Fits);
}
let (center, axis, sweep) = match &cross.path {
EdgePath::Circular {
center,
axis,
sweep,
} => (*center, *axis, *sweep),
EdgePath::Straight { .. } => (Vec3::default(), Vec3::default(), 0.0),
};
let straight = matches!(cross.path, EdgePath::Straight { .. });
const SAMPLES: usize = 8;
let partial = std::env::var_os("BREP_SUPPORT_EXTENT_STRICT").is_none();
let mut overrun = [0u8; SAMPLES + 1];
let angular = crate::KernelTolerances::for_solid(solid, 1e-7).angular;
let exits_into_air = |edges: &[&EdgeRecord], station: Vec3, contact: Vec3| {
let span = contact.sub(station);
let to_segment = |point: Vec3| {
let along = (point.sub(station).dot(span) / span.dot(span)).clamp(0.0, 1.0);
point.sub(station.add(span.scale(along))).length()
};
let exit = edges
.iter()
.filter(|border| border.id != edge_id)
.map(|border| {
let nearest = (0..=64)
.filter_map(|step| {
let t = border.t0 + (border.t1 - border.t0) * step as f64 / 64.0;
border.curve.evaluate(t).ok().map(to_segment)
})
.fold(f64::INFINITY, f64::min);
(border.id, nearest)
})
.min_by(|a, b| a.1.total_cmp(&b.1));
exit.is_some_and(|(exit_id, _)| {
scan_dihedral(solid, exit_id)
.is_ok_and(|profile| profile.samples > 0 && profile.max_angle < std::f64::consts::PI - angular)
})
};
let mut first_refusal: Option<KernelRefusal> = None;
let mut overrun_faces = [0u8; SAMPLES + 1];
for (support, (face, into)) in
[(faces[0], cross.into_first), (faces[1], cross.into_second)].into_iter().enumerate()
{
let mut boundary: Vec<Vec3> = Vec::new();
let mut boundary_edges = Vec::new();
let mut sample_face = |sampled: &FaceRecord, boundary: &mut Vec<Vec3>| {
for loop_record in &sampled.loops {
for coedge in &loop_record.coedges {
let Some(border) = solid.edges.iter().find(|e| e.id == coedge.edge_id) else {
continue;
};
boundary_edges.push(border);
for step in 0..=SAMPLES {
let t = border.t0 + (border.t1 - border.t0) * step as f64 / SAMPLES as f64;
if let Ok(point) = border.curve.evaluate(t) {
boundary.push(point);
}
}
}
}
};
sample_face(face, &mut boundary);
for neighbour in tangent_neighbours(solid, face) {
if !neighbour_carries_ball(solid, face, neighbour, size) {
continue;
}
sample_face(neighbour, &mut boundary);
}
if boundary.is_empty() {
continue;
}
for step in 0..=SAMPLES {
let fraction = step as f64 / SAMPLES as f64;
let (station, direction) = match &cross.path {
EdgePath::Straight { direction, length } if straight => {
(cross.start.add(direction.scale(length * fraction)), into)
}
_ => {
let angle = sweep * fraction;
(
center.add(rotate_about(cross.start.sub(center), axis, angle)),
rotate_about(into, axis, angle),
)
}
};
let extent = boundary
.iter()
.map(|point| point.sub(station).dot(direction))
.fold(f64::NEG_INFINITY, f64::max);
if !extent.is_finite() {
continue;
}
if reach <= extent + crate::blend::consumed_band(solid) {
continue;
}
let required = reach - crate::blend::consumed_band(solid);
let extent = boundary_edges
.iter()
.map(|border| match crate::step::edge_subcurve(border) {
Ok(curve) => boundary_extent_bound(&curve, station, direction, required, 0),
Err(_) => f64::INFINITY,
})
.fold(f64::NEG_INFINITY, f64::max);
if extent >= required {
continue;
}
let refusal = KernelRefusal::input(
KernelStage::Classify,
"support_extent",
format!(
"{entry}: {size} is too large for the faces it must lie on — the blend would \
meet face {} at {reach:.6} from the edge (reach = size·cot(φ/2), φ = {:.3}°), \
but that face only reaches {extent:.6} there. A rolling ball that cannot touch \
both supports is tangent to neither, so what would be built is not a blend of \
this edge: reduce the size, or select the neighbouring faces' edges too so the \
blend has somewhere to run.",
face.id,
phi.to_degrees(),
),
);
if !partial {
return Err(refusal);
}
overrun_faces[step] |= 1 << support;
let contact = station.add(direction.scale(reach));
overrun[step] += if exits_into_air(&boundary_edges, station, contact) { 1 } else { 2 };
first_refusal.get_or_insert(refusal);
}
}
let Some(refusal) = first_refusal else {
return Ok(SupportExtent::Fits);
};
if !cross.convex && crate::blend::taper_runout::taper_runout_on() && crate::blend::relief::relief_enabled() {
if let Some(edge) = solid.edges.iter().find(|edge| edge.id == edge_id) {
let tapered = (0..=SAMPLES).all(|step| {
let bits = overrun_faces[step];
if bits == 0 {
return true;
}
if 2 * step == SAMPLES {
return false;
}
let vertex = if 2 * step < SAMPLES { edge.start_vertex_id } else { edge.end_vertex_id };
(0..2).filter(|support| bits & (1 << support) != 0).all(|support| {
crate::blend::taper_runout::cusped_at(solid, edge, faces[support], vertex)
})
});
if tapered {
return Ok(SupportExtent::Fits);
}
}
}
if cross.convex {
return if overrun.contains(&2) { Err(refusal) } else { Ok(SupportExtent::Fits) };
}
let ran_off = overrun_faces.iter().fold(0u8, |all, bits| all | bits);
let support = match ran_off {
0b01 => 0,
0b10 => 1,
_ => return Err(refusal),
};
match concave_clip_outline(solid, faces[support], edge_id, angular) {
Some(_) => Ok(SupportExtent::ConcaveClip { face: faces[support].id, refusal }),
None => Err(refusal),
}
}
pub(super) fn concave_clip_outline(
solid: &BrepSolid,
face: &FaceRecord,
edge_id: u64,
angular: f64,
) -> Option<Vec<usize>> {
if !matches!(face.surface.analytic(), Some(crate::AnalyticSurface::Plane { .. })) {
return None;
}
let mut loops: Vec<usize> = (0..face.loops.len())
.filter(|&index| face.loops[index].coedges.iter().all(|coedge| coedge.edge_id != edge_id))
.collect();
if loops.is_empty() || loops.len() + 1 != face.loops.len() {
return None;
}
let anchor = solid.edges.iter().find(|edge| edge.id == edge_id)?.curve.evaluate(0.0).ok()?;
let (_, normal) = face_plane_normal(face, anchor).ok()?;
let mut areas = Vec::with_capacity(loops.len());
for &index in &loops {
let mut samples = Vec::new();
for coedge in &face.loops[index].coedges {
let (border, mates) = edge_and_faces(solid, coedge.edge_id).ok()?;
let side = mates.iter().find(|mate| mate.id != face.id)?;
let wall = match side.surface.analytic() {
Some(crate::AnalyticSurface::Plane { .. }) => {
let (_, side_normal) = face_plane_normal(side, border.curve.evaluate(border.t0).ok()?).ok()?;
side_normal.dot(normal).abs() <= angular
}
Some(crate::AnalyticSurface::RuledRevolution { frame, rho0, rho1, .. }) => {
let scale = rho0.abs().max(rho1.abs()).max(1.0);
(rho0 - rho1).abs() <= 1e-9 * scale && frame.axis.cross(normal).length() <= angular
}
_ => false,
};
if !wall {
return None;
}
let sharp = scan_dihedral(solid, border.id)
.is_ok_and(|profile| profile.samples > 0 && profile.max_angle < std::f64::consts::PI - angular);
if !sharp {
return None;
}
let curve = crate::step::edge_subcurve(border).ok()?;
let [t0, t1] = curve.domain().ok()?;
for step in 0..16 {
let fraction = if coedge.forward { step as f64 / 16.0 } else { 1.0 - step as f64 / 16.0 };
samples.push(curve.evaluate(t0 + (t1 - t0) * fraction).ok()?);
}
}
areas.push(crate::polygon::newell_normal(&samples).dot(normal).abs());
}
let outer = (0..loops.len()).max_by(|&a, &b| areas[a].total_cmp(&areas[b]))?;
loops.swap(0, outer);
Some(loops)
}
pub(crate) fn interference_revolution_cert_on() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| !matches!(std::env::var("BREP_INTERFERENCE_REVOLUTION_CERT").as_deref(), Ok("0") | Ok("off")))
}
fn revolution_band(surface: &crate::NurbsSurface) -> Option<(Vec3, Vec3, [f64; 4], Vec3)> {
const DELTA: f64 = 1e-12;
let net = &surface.control_points;
if surface.degree_u != 2 || net.len() != 9 || net.iter().any(|row| row.len() != net[0].len()) || net[0].len() < 2 {
return None;
}
let at = |i: usize| net[i][0].point().ok();
let (first, quarter, opposite) = (at(0)?, at(2)?, at(4)?);
let origin = first.add(opposite).scale(0.5);
let axis = first.sub(origin).cross(quarter.sub(origin)).normalized().ok()?;
let generatrix = crate::NurbsCurve::new(surface.degree_v, surface.knots_v.clone(), net[0].clone()).ok()?;
let rebuilt = crate::make_revolution(origin, axis, &generatrix, std::f64::consts::TAU).ok()?;
let same_bits = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits());
if !same_bits(&surface.knots_u, &rebuilt.knots_u) || !same_bits(&surface.knots_v, &rebuilt.knots_v) || rebuilt.degree_u != 2 {
return None;
}
let size = net.iter().flatten().filter_map(|c| c.point().ok()).fold(0f64, |m, q| m.max(q.x.abs()).max(q.y.abs()).max(q.z.abs()));
if !(size > 0.) || rebuilt.control_points.len() != net.len() || rebuilt.control_points.iter().zip(net).any(|(a, b)| a.len() != b.len()) {
return None;
}
let close = net.iter().flatten().zip(rebuilt.control_points.iter().flatten()).all(|(a, b)| {
a.w > 0.
&& b.w > 0.
&& (a.w - b.w).abs() <= DELTA * a.w.abs().max(b.w.abs())
&& (a.x / a.w - b.x / b.w).abs() <= DELTA * size
&& (a.y / a.w - b.y / b.w).abs() <= DELTA * size
&& (a.z / a.w - b.z / b.w).abs() <= DELTA * size
});
if !close {
return None;
}
let radial_of = |q: Vec3| {
let d = q.sub(origin);
d.sub(axis.scale(d.dot(axis)))
};
let x_axis = radial_of(first).normalized().ok()?;
let normal = axis.cross(x_axis);
let mut bounds = [f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY];
for control in &net[0] {
let q = control.point().ok()?;
let d = q.sub(origin);
if d.dot(normal).abs() > DELTA * size {
return None;
}
let (x, z) = (d.dot(x_axis), d.dot(axis));
if !(x >= 0.) {
return None;
}
bounds = [bounds[0].min(x), bounds[1].max(x), bounds[2].min(z), bounds[3].max(z)];
}
if !(bounds[1] > bounds[0] || bounds[3] > bounds[2]) {
return None;
}
let interior = origin
.add(x_axis.scale(0.5 * (bounds[0] + bounds[1])))
.add(axis.scale(0.5 * (bounds[2] + bounds[3])));
Some((origin, axis, bounds, interior))
}
fn revolution_box_clear(original: &BrepSolid, origin: Vec3, axis: Vec3, [rho0, rho1, z0, z1]: [f64; 4], tol: f64) -> bool {
const PARALLEL: f64 = 1e-12;
original.shells.iter().flat_map(|shell| &shell.faces).all(|face| {
let surface = &face.surface;
match surface.analytic() {
Some(crate::AnalyticSurface::Plane { origin: at, u_dir, v_dir, .. }) => {
if let Ok(normal) = u_dir.cross(*v_dir).normalized() {
if normal.cross(axis).length() <= PARALLEL {
let level = at.sub(origin).dot(axis);
if level >= z1 - tol || level <= z0 + tol {
return true;
}
}
}
}
Some(crate::AnalyticSurface::RuledRevolution { frame, rho0: a, rho1: b, .. }) if a == b => {
let offset = frame.origin.sub(origin);
let off_axis = offset.sub(axis.scale(offset.dot(axis))).length();
if frame.axis.cross(axis).length() <= PARALLEL && off_axis <= tol && (*a >= rho1 - tol || *a <= rho0 + tol) {
return true;
}
}
_ => {}
}
let (mut low, mut high, mut far) = (f64::INFINITY, f64::NEG_INFINITY, 0f64);
for control in surface.control_points.iter().flatten() {
let Ok(q) = control.point() else { return false };
if !(control.w > 0.) {
return false;
}
let d = q.sub(origin);
let z = d.dot(axis);
low = low.min(z);
high = high.max(z);
far = far.max(d.sub(axis.scale(z)).length());
}
high <= z0 + tol || low >= z1 - tol || far <= rho0 + tol
})
}
fn revolution_interference_certificate(
original: &BrepSolid,
result: &BrepSolid,
grown: &[&FaceRecord],
fresh_edges: &[u64],
forbidden: PointClass,
classifier: &SolidClassifier,
tol: f64,
) -> Option<bool> {
if grown.is_empty() {
return None;
}
let mut banded: Vec<u64> = Vec::with_capacity(grown.len());
for face in grown {
let (origin, axis, bounds, interior) = revolution_band(&face.surface)?;
if !revolution_box_clear(original, origin, axis, bounds, tol) {
return None;
}
let class = classifier.classify(interior).ok()?.class;
if class == forbidden || class == PointClass::On {
return None;
}
banded.push(face.id);
}
let on_band = |edge_id: u64| {
result.shells.iter().flat_map(|shell| &shell.faces).filter(|face| banded.contains(&face.id)).any(|face| {
face.loops.iter().flat_map(|l| &l.coedges).any(|coedge| coedge.edge_id == edge_id)
})
};
fresh_edges.iter().all(|&edge_id| on_band(edge_id)).then_some(true)
}
pub(super) fn check_blend_interference(
original: &BrepSolid,
result: &BrepSolid,
edge_ids: &[u64],
entry: &str,
) -> Result<(), KernelRefusal> {
let _timing = crate::blend::BlendTimer::start("timing.interference_ms");
let key = interference_memo_key(original, result, edge_ids, entry);
if key.is_some_and(|key| INTERFERENCE_MEMO.with(|memo| memo.borrow().contains(&key))) {
INTERFERENCE_HITS.with(|hits| hits.set(hits.get() + 1));
crate::blend::record_count("count.interference.memo_hits", 1);
return Ok(());
}
if key.is_some() {
INTERFERENCE_READS.with(|reads| reads.set(reads.get() + 1));
crate::blend::record_count("count.interference.memo_reads", 1);
}
let mut complete = true;
let verdict = check_blend_interference_uncached(original, result, edge_ids, entry, &mut complete);
if verdict.is_ok() && complete {
if let Some(key) = key {
INTERFERENCE_MEMO.with(|memo| {
let mut memo = memo.borrow_mut();
if memo.len() >= 32 { memo.clear(); }
memo.insert(key);
});
}
}
verdict
}
thread_local! {
static INTERFERENCE_MEMO: std::cell::RefCell<rustc_hash::FxHashSet<[u64; 2]>> = std::cell::RefCell::new(rustc_hash::FxHashSet::default());
static INTERFERENCE_HITS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
static INTERFERENCE_READS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
fn interference_memo_key(original: &BrepSolid, result: &BrepSolid, edges: &[u64], entry: &str) -> Option<[u64; 2]> {
let policy = crate::soundness::network_acceptance_memo::policy()?;
let (a, b) = original.validate_memo_key()?;
let (c, d) = result.validate_memo_key()?;
let mut hash = crate::mass_properties::TwoLane::new();
for word in [a, b, c, d, policy[0], policy[1]] { hash.u64(word); }
hash.usize(edges.len());
for &edge in edges { hash.u64(edge); }
hash.usize(entry.len());
for byte in entry.bytes() { hash.u64(u64::from(byte)); }
Some(hash.finish())
}
fn check_blend_interference_uncached(
original: &BrepSolid,
result: &BrepSolid,
edge_ids: &[u64],
entry: &str,
complete: &mut bool,
) -> Result<(), KernelRefusal> {
crate::classification::classify_profile_begin();
if edge_ids.is_empty() {
return Ok(());
}
let validate_timing = crate::blend::BlendTimer::start("timing.interference.validate_ms");
let original_invalid = !original.validate_memoised().is_empty();
validate_timing.stop();
if original_invalid {
*complete = false;
return Ok(());
}
let (mut any_convex, mut any_concave) = (false, false);
for &edge_id in edge_ids {
let Ok(profile) = scan_dihedral(original, edge_id) else {
*complete = false;
return Ok(());
};
if profile.is_mixed() {
*complete = false;
return Ok(());
}
any_convex |= profile.any_convex;
any_concave |= profile.any_concave;
}
let forbidden = match (any_convex, any_concave) {
(true, false) => PointClass::Out,
(false, true) => PointClass::In,
_ => return Ok(()),
};
let existing: Vec<u64> = original
.shells
.iter()
.flat_map(|shell| &shell.faces)
.map(|face| face.id)
.collect();
let grown: Vec<&FaceRecord> = result
.shells
.iter()
.flat_map(|shell| &shell.faces)
.filter(|face| !existing.contains(&face.id))
.collect();
if grown.is_empty() && result.edges.len() <= original.edges.len() {
return Ok(());
}
let policy = crate::KernelTolerances::for_solid(original, 1e-7);
let report = |label: String, violations: &[Vec3], sampled: usize, what: &str| -> KernelRefusal {
let side = if forbidden == PointClass::Out {
"outside the material it was cut from"
} else {
"inside material that was already there"
};
let sample = violations[0];
KernelRefusal::unsupported(
KernelStage::Validate,
"blend_interference",
format!(
"{entry}: the blend is not trimmed where it meets the rest of the solid — {} of {} \
samples {what} {label} lie {side} (e.g. ({:.6}, {:.6}, {:.6})). The rolling-ball \
surgery re-trims only the blend's two MATING faces, so a third face crossing the \
swept volume leaves the blend running through it and the result self-intersecting. \
Those faces have to be split along their intersection and the fragments in the \
void discarded.",
violations.len(),
sampled,
sample.x,
sample.y,
sample.z,
),
)
};
const EDGE_SAMPLES: usize = 24;
let edges_timing = crate::blend::BlendTimer::start("timing.interference.edges_ms");
let inherited: Vec<u64> = original.edges.iter().map(|edge| edge.id).collect();
let fresh: Vec<_> = result
.edges
.iter()
.filter(|edge| !inherited.contains(&edge.id) && !edge.degenerate && edge.t1 != edge.t0)
.collect();
if interference_revolution_cert_on() {
let cert_timing = crate::blend::BlendTimer::start("timing.interference.revolution_cert_ms");
let band = policy.pcurve_consistency.min(policy.intersection_fit) / 10.0;
let fresh_ids: Vec<u64> = fresh.iter().map(|edge| edge.id).collect();
let certified = SolidClassifier::new(original, policy.pcurve_consistency / 10.0)
.ok()
.and_then(|classifier| revolution_interference_certificate(original, result, &grown, &fresh_ids, forbidden, &classifier, band));
cert_timing.stop();
if certified == Some(true) {
crate::blend::record_count("count.interference.revolution_certified", 1);
edges_timing.stop();
crate::classification::classify_profile_report("blend.interference.edges");
return Ok(());
}
crate::blend::record_count("count.interference.revolution_declined", 1);
}
let edge_build_timing = crate::blend::BlendTimer::start("timing.interference.edges.build_ms");
let skin = match fresh.is_empty() {
true => None,
false => Some((
SolidClassifier::new(original, policy.pcurve_consistency / 10.0)
.or_refuse(KernelStage::Validate, "edge_classifier")?,
NearestFaceSide::new(original, policy.pcurve_consistency / 10.0)
.or_refuse(KernelStage::Validate, "edge_nearest_face")?,
)),
};
edge_build_timing.stop();
for edge in fresh {
let Some((skin, nearest)) = skin.as_ref() else { break };
let mut sampled = 0usize;
let mut violations: Vec<Vec3> = Vec::new();
crate::blend::record_count("count.interference.edges", 1);
for step in 1..EDGE_SAMPLES {
let t = edge.t0 + (edge.t1 - edge.t0) * step as f64 / EDGE_SAMPLES as f64;
let Ok(point) = edge.curve.evaluate(t) else {
*complete = false;
continue;
};
sampled += 1;
crate::blend::record_count("count.interference.edge_samples", 1);
let class = match nearest.side(point) {
Some(class) => class,
None => {
let Ok(classification) = skin.classify(point) else {
crate::blend::record_count("count.interference.classify_err", 1);
*complete = false;
continue;
};
classification.class
}
};
if class == forbidden {
violations.push(point);
}
}
if violations.len() >= 2 {
return Err(report(
format!("edge {}", edge.id),
&violations,
sampled,
"along the grown",
));
}
}
edges_timing.stop();
crate::classification::classify_profile_report("blend.interference.edges");
if grown.is_empty() {
return Ok(());
}
let faces_timing = crate::blend::BlendTimer::start("timing.interference.faces_ms");
crate::classification::classify_profile_begin();
crate::classification::containment_profile_begin();
let face_build_timing = crate::blend::BlendTimer::start("timing.interference.faces.build_ms");
let classifier = SolidClassifier::new(original, policy.intersection_fit)
.or_refuse(KernelStage::Validate, "face_classifier")?;
let nearest = NearestFaceSide::new(original, policy.intersection_fit)
.or_refuse(KernelStage::Validate, "face_nearest_face")?;
face_build_timing.stop();
const GRID: usize = 9;
for face in grown {
crate::blend::record_count("count.interference.faces", 1);
let mut sampled = 0usize;
let mut violations: Vec<Vec3> = Vec::new();
let band_at = |uv: Vec2| Ok(face_sample_uv_band(face, uv.x, uv.y, policy.model));
for (uv, class) in face_grid_classes(face, GRID, &band_at) {
let Some(Ok(PolygonClass::Inside)) = class else {
if matches!(class, Some(Err(_))) { *complete = false; }
continue;
};
let Ok(point) = face.surface.evaluate(uv.x, uv.y) else {
*complete = false;
continue;
};
sampled += 1;
crate::blend::record_count("count.interference.face_samples", 1);
let class = match nearest.side(point) {
Some(class) => class,
None => {
let Ok(classification) = classifier.classify(point) else {
crate::blend::record_count("count.interference.classify_err", 1);
*complete = false;
continue;
};
classification.class
}
};
if class == forbidden {
violations.push(point);
}
}
if violations.len() < 2 {
continue;
}
let label = face
.name
.clone()
.unwrap_or_else(|| format!("face {}", face.id));
return Err(report(label, &violations, sampled, "inside the grown face"));
}
faces_timing.stop();
crate::classification::classify_profile_report("blend.interference.faces");
crate::classification::containment_profile_report("blend.interference.faces");
Ok(())
}
pub(crate) fn face_grid_classes(
face: &FaceRecord,
grid: usize,
band_at: &dyn Fn(Vec2) -> Result<f64, String>,
) -> Vec<(Vec2, Option<Result<PolygonClass, String>>)> {
let (Ok([u0, u1]), Ok([v0, v1])) = (face.surface.domain_u(), face.surface.domain_v()) else {
return Vec::new();
};
let nodes: Vec<(Vec2, Result<f64, String>)> = (1..grid)
.flat_map(|iu| (1..grid).map(move |iv| (iu, iv)))
.map(|(iu, iv)| {
let uv = Vec2 {
x: u0 + (u1 - u0) * iu as f64 / grid as f64,
y: v0 + (v1 - v0) * iv as f64 / grid as f64,
};
(uv, band_at(uv))
})
.collect();
let ask = |uv: Vec2, band: &Result<f64, String>| {
crate::blend::record_count("count.grid.nodes_asked", 1);
match band {
Ok(band) => parameter_point_in_face(face, uv, *band),
Err(error) => Err(error.clone()),
}
};
let every = |nodes: &[(Vec2, Result<f64, String>)]| nodes.iter().map(|(uv, band)| (*uv, Some(ask(*uv, band)))).collect::<Vec<_>>();
let mut hull: Option<[f64; 4]> = None;
for coedge in face.loops.iter().flat_map(|record| record.coedges.iter()) {
for control in &coedge.pcurve.control_points {
if !(control.w > 0.0) {
return every(&nodes);
}
let (x, y) = (control.x / control.w, control.y / control.w);
if !(x.is_finite() && y.is_finite()) {
return every(&nodes);
}
hull = Some(match hull {
None => [x, x, y, y],
Some([a, b, c, d]) => [a.min(x), b.max(x), c.min(y), d.max(y)],
});
}
}
let Some([ua, ub, va, vb]) = hull else {
return every(&nodes);
};
let far = |uv: Vec2, band: &Result<f64, String>| -> Option<(f64, (i8, i8))> {
let Ok(band) = band else { return None };
let band = *band;
let du = if uv.x < ua { ua - uv.x } else if uv.x > ub { uv.x - ub } else { 0.0 };
let dv = if uv.y < va { va - uv.y } else if uv.y > vb { uv.y - vb } else { 0.0 };
let distance = du.max(dv);
if !(distance > band) {
return None;
}
let side_u = if uv.x < ua { -1 } else if uv.x > ub { 1 } else { 0 };
let side_v = if uv.y < va { -1 } else if uv.y > vb { 1 } else { 0 };
Some((distance, (side_u, side_v)))
};
let spans_u = ua <= u0 && ub >= u1;
let spans_v = va <= v0 && vb >= v1;
let far_nodes: Vec<(usize, f64, (i8, i8))> = nodes
.iter()
.enumerate()
.filter_map(|(index, (uv, band))| far(*uv, band).map(|(distance, sides)| (index, distance, sides)))
.collect();
crate::blend::record_count("count.grid.nodes_far", far_nodes.len() as u64);
if far_nodes.is_empty() {
return every(&nodes);
}
let one_piece = (!spans_u || far_nodes.iter().all(|(_, _, sides)| sides.1 == far_nodes[0].2 .1))
&& (!spans_v || far_nodes.iter().all(|(_, _, sides)| sides.0 == far_nodes[0].2 .0));
if !one_piece {
crate::blend::record_count("count.grid.far_split", 1);
return every(&nodes);
}
let &(farthest, _, _) = far_nodes
.iter()
.max_by(|a, b| a.1.total_cmp(&b.1))
.expect("a far node");
let representative = ask(nodes[farthest].0, &nodes[farthest].1);
if !matches!(representative, Ok(PolygonClass::Outside)) {
crate::blend::record_count("count.grid.far_not_outside", 1);
return every(&nodes);
}
let is_far: std::collections::HashSet<usize> = far_nodes.iter().map(|(index, _, _)| *index).collect();
nodes
.iter()
.enumerate()
.map(|(index, (uv, band))| {
if index == farthest {
(*uv, Some(Ok(PolygonClass::Outside)))
} else if is_far.contains(&index) {
(*uv, None)
} else {
(*uv, Some(ask(*uv, band)))
}
})
.collect()
}
fn nearest_face_analytic_only() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| std::env::var("BREP_NEAREST_FACE_ANALYTIC_ONLY").as_deref() == Ok("1"))
}
pub(crate) fn nearest_side_edge_piece_on() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| !matches!(std::env::var("BREP_NEAREST_SIDE_EDGE_PIECE").as_deref(), Ok("0") | Ok("off")))
}
pub(crate) fn edge_piece(curve: &NurbsCurve, low: f64, high: f64) -> Option<NurbsCurve> {
let [start, end] = curve.domain().ok()?;
let slack = 1e-12 * (end - start).abs().max(1.0);
if !(low >= start - slack && high <= end + slack && high > low) {
return None;
}
let tail = if low <= start + slack { curve.clone() } else { curve.split(low).ok()?.1 };
if high >= end - slack {
return Some(tail);
}
tail.split(high).ok().map(|(piece, _)| piece)
}
pub(crate) struct NearestFaceSide<'a> {
solid: &'a BrepSolid,
faces: Vec<&'a FaceRecord>,
boxes: Vec<crate::Aabb>,
bounds: crate::Aabb,
on_band: f64,
}
impl<'a> NearestFaceSide<'a> {
pub(crate) fn new(solid: &'a BrepSolid, tolerance: f64) -> Result<Self, String> {
let faces: Vec<&FaceRecord> = solid.shells.iter().flat_map(|shell| &shell.faces).collect();
let boxes = faces.iter().map(|face| crate::Aabb::from_surface_controls(&face.surface)).collect::<Result<Vec<_>, _>>()?;
let mut bounds = crate::Aabb::empty();
for face_box in &boxes {
bounds.include(*face_box);
}
Ok(Self { solid, faces, boxes, bounds, on_band: tolerance * 10.0 })
}
fn box_distance(face_box: &crate::Aabb, point: Vec3) -> f64 {
let axis = |value: f64, low: f64, high: f64| if value < low { low - value } else if value > high { value - high } else { 0.0 };
let dx = axis(point.x, face_box.minimum.x, face_box.maximum.x);
let dy = axis(point.y, face_box.minimum.y, face_box.maximum.y);
let dz = axis(point.z, face_box.minimum.z, face_box.maximum.z);
(dx * dx + dy * dy + dz * dz).sqrt()
}
pub(crate) fn side(&self, point: Vec3) -> Option<PointClass> {
match self.side_or_reason(point) {
Ok(class) => Some(class),
Err(reason) => {
if std::env::var_os("BREP_DEBUG_NEAREST_SIDE").is_some() {
eprintln!("nearest-face side: ray for ({:.6}, {:.6}, {:.6}): {reason}", point.x, point.y, point.z);
}
crate::blend::record_count(reason_counter(reason), 1);
None
}
}
}
fn side_or_reason(&self, point: Vec3) -> Result<PointClass, &'static str> {
if !self.bounds.expanded(self.on_band).contains(point) {
return Err("outside the body's box");
}
let mut order: Vec<(f64, usize)> = self.boxes.iter().enumerate().map(|(index, face_box)| (Self::box_distance(face_box, point), index)).collect();
order.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut nearest: Option<(f64, usize, SurfaceProjection)> = None;
let mut exterior: Vec<(f64, usize)> = Vec::new();
for &(lower_bound, index) in &order {
if nearest.as_ref().is_some_and(|(distance, _, _)| lower_bound >= *distance) {
break;
}
let face = self.faces[index];
if nearest_face_analytic_only() && face.surface.analytic().is_none() {
return Err("a general carrier within reach (analytic-only switch)");
}
let projection = project_point_to_surface(&face.surface, point).map_err(|_| "a carrier projection failed")?;
if !projection.distance.is_finite() {
return Err("a carrier projection read a non-finite distance");
}
let band = crate::classification::surface_uv_band(&face.surface, projection.u, projection.v, self.on_band);
let inside = parameter_point_in_face(face, Vec2 { x: projection.u, y: projection.v }, band).map_err(|_| "a trim test failed")?;
if inside == PolygonClass::Inside {
if nearest.as_ref().map_or(true, |(distance, _, _)| projection.distance < *distance) {
nearest = Some((projection.distance, index, projection));
}
} else {
exterior.push((projection.distance, index));
}
}
let (distance, index, projection) = nearest.ok_or("no foot inside a face's trims")?;
if !(distance > self.on_band) {
return Ok(PointClass::On);
}
for &(carrier_distance, exterior_index) in &exterior {
if carrier_distance >= distance {
continue;
}
let face = self.faces[exterior_index];
let mut boundary = f64::INFINITY;
let mut boundary_exact = true;
let piece_fix = nearest_side_edge_piece_on();
for loop_record in &face.loops {
for coedge in &loop_record.coedges {
let Some(edge) = self.solid.edges.iter().find(|edge| edge.id == coedge.edge_id) else {
return Err("a boundary edge is missing");
};
let (low, high) = (edge.t0.min(edge.t1), edge.t0.max(edge.t1));
let piece = if piece_fix {
match edge_piece(&edge.curve, low, high) {
Some(piece) => piece,
None => {
boundary_exact = false;
edge.curve.clone()
}
}
} else {
edge
.curve
.split(low)
.and_then(|(_, rest)| rest.split(high))
.map(|(piece, _)| piece)
.unwrap_or_else(|_| edge.curve.clone())
};
let projection = crate::project_point_to_curve(&piece, point).map_err(|_| "a boundary curve projection failed")?;
if !projection.distance.is_finite() {
return Err("a boundary curve projection read a non-finite distance");
}
boundary = boundary.min(projection.distance);
}
}
if !(boundary > self.on_band) {
if !boundary_exact {
return Err("an exterior face's boundary edge could not be cut to its range");
}
return Ok(PointClass::On);
}
if !(boundary > distance) {
if std::env::var_os("BREP_DEBUG_NEAREST_SIDE").is_some() {
eprintln!(
"nearest-face side: exterior face {} ({}) carrier {:.3e} boundary {:.3e} vs nearest face {} ({}) at {:.3e}",
face.id, face.name.as_deref().unwrap_or("-"), carrier_distance, boundary,
self.faces[index].id, self.faces[index].name.as_deref().unwrap_or("-"), distance
);
}
return Err("an exterior foot's carrier is nearer than the nearest face point");
}
}
let face = self.faces[index];
let normal = face.surface.normal(projection.u, projection.v).map_err(|_| "the normal failed")?;
let normal = if face.same_sense { normal } else { normal.scale(-1.0) };
let along = point.sub(projection.point).dot(normal);
if !(along.abs() >= 0.5 * distance) {
return Err("the offset does not run along the normal");
}
Ok(if along > 0.0 { PointClass::Out } else { PointClass::In })
}
}
fn reason_counter(reason: &'static str) -> &'static str {
match reason {
"outside the body's box" => "count.nearest_side.ray.outside_box",
"no foot inside a face's trims" => "count.nearest_side.ray.no_interior_foot",
"within the On band" => "count.nearest_side.ray.on_band",
"an exterior foot's carrier is nearer than the nearest face point" => "count.nearest_side.ray.exterior_nearer",
"the offset does not run along the normal" => "count.nearest_side.ray.off_normal",
_ => "count.nearest_side.ray.failed_query",
}
}
pub(crate) fn face_sample_uv_band(face: &FaceRecord, u: f64, v: f64, spatial: f64) -> f64 {
let Ok(derivatives) = face.surface.derivatives(u, v, 1) else {
return spatial;
};
let band = crate::tolerance::surface_uv_tolerance(
spatial,
derivatives[1][0].length(),
derivatives[0][1].length(),
);
let cap = match (face.surface.domain_u(), face.surface.domain_v()) {
(Ok([a0, a1]), Ok([b0, b1])) => ((a1 - a0).min(b1 - b0) * 0.05).max(1e-12),
_ => f64::INFINITY,
};
band.min(cap)
}
thread_local! {
pub(super) static CLEAN_LOOP_FACES: std::cell::RefCell<std::collections::HashSet<Vec<u8>>> =
std::cell::RefCell::new(std::collections::HashSet::new());
}
const CLEAN_LOOP_FACES_CAP: usize = 8192;
pub(super) fn loop_self_crossings_reusing_clean_faces(result: &BrepSolid) -> crate::LoopCrossingReport {
if std::env::var("BREP_BLEND_BOWTIE_FACE_MEMO").as_deref() == Ok("0") {
return crate::loop_self_crossings(result);
}
let scale = crate::tolerance::solid_scale(result).to_bits().to_le_bytes();
let key = |face: &crate::topology::FaceRecord| -> Option<Vec<u8>> {
let mut bytes = serde_json::to_vec(face).ok()?;
bytes.extend_from_slice(&scale);
Some(bytes)
};
let mut reduced = result.clone();
let mut pending: Vec<(u64, Option<Vec<u8>>)> = Vec::new();
let mut skipped = 0u64;
CLEAN_LOOP_FACES.with(|clean| {
let clean = clean.borrow();
for shell in &mut reduced.shells {
shell.faces.retain(|face| {
let key = key(face);
if key.as_ref().is_some_and(|key| clean.contains(key)) {
skipped += 1;
return false;
}
pending.push((face.id, key));
true
});
}
});
crate::blend::record_count("count.bowtie.faces_reused", skipped);
crate::blend::record_count("count.bowtie.faces_scanned", pending.len() as u64);
let report = crate::loop_self_crossings(&reduced);
if report.unreadable.is_empty() {
CLEAN_LOOP_FACES.with(|clean| {
let mut clean = clean.borrow_mut();
for (id, key) in pending {
let Some(key) = key else { continue };
if report.crossings.iter().any(|crossing| crossing.face == id) {
continue;
}
if clean.len() >= CLEAN_LOOP_FACES_CAP {
clean.clear();
}
clean.insert(key);
}
});
}
report
}
pub(super) fn check_loop_self_crossings(
result: &BrepSolid,
entry: &str,
) -> Result<(), KernelRefusal> {
let _timing = crate::blend::BlendTimer::start("timing.bowtie_ms");
let report = loop_self_crossings_reusing_clean_faces(result);
let Some(crossing) = report.crossings.first() else {
return Ok(());
};
Err(KernelRefusal::unsupported(
KernelStage::Validate,
"loop_self_crossing",
format!(
"{entry}: the result's face {}{} has a loop that CROSSES ITSELF at \
({:.6}, {:.6}, {:.6}) — its edges {} and {} cross in the face's own parameter \
domain{}. A blend wall whose contact rail runs off the end of its own face is not \
trimmed to it, and the face that comes back is wrong in AREA where its volume is \
not; `validate()` cannot see it. Blend the edges that need the runout separately.",
crossing.face,
crossing
.face_name
.as_ref()
.map(|name| format!(" '{name}'"))
.unwrap_or_default(),
crossing.point.x,
crossing.point.y,
crossing.point.z,
crossing.edge_a,
crossing.edge_b,
if report.crossings.len() > 1 {
format!(" ({} crossings in all)", report.crossings.len())
} else {
String::new()
},
),
))
}
const WALL_LOOP_SAMPLES: usize = 512;
const WALL_LOOP_LINES: usize = 9;
const WALL_LOOP_WINDOW: usize = 8;
thread_local! {
static LOOP_FREE_FACES: std::cell::RefCell<rustc_hash::FxHashSet<[u64; 2]>> =
std::cell::RefCell::new(rustc_hash::FxHashSet::default());
}
const LOOP_FREE_FACES_CAP: usize = 8192;
pub(super) fn check_blend_wall_loops(result: &BrepSolid, entry: &str) -> Result<(), KernelRefusal> {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
if !*ENABLED.get_or_init(|| std::env::var("BREP_BLEND_WALL_LOOP_CHECK").as_deref() != Ok("0")) {
return Ok(());
}
let _timing = crate::blend::BlendTimer::start("timing.wall_loops_ms");
let scale = crate::solid_scale(result);
let bar = 1.0e-7 * (1.0 + scale);
for face in result.shells.iter().flat_map(|shell| shell.faces.iter()) {
if face.surface.analytic().is_some() {
continue;
}
let key = {
let mut lane = crate::mass_properties::TwoLane::new();
crate::mass_properties::hash_face(&mut lane, face);
lane.f64(scale);
lane.finish()
};
if LOOP_FREE_FACES.with(|set| set.borrow().contains(&key)) {
continue;
}
crate::blend::record_count("count.wall_loops.faces_read", 1);
let Ok([u0, u1]) = face.surface.domain_u() else { continue };
let Ok([v0, v1]) = face.surface.domain_v() else { continue };
for (along_u, lines_from, lines_to, from, to) in [(true, v0, v1, u0, u1), (false, u0, u1, v0, v1)] {
for l in 0..WALL_LOOP_LINES {
let line = lines_from + (lines_to - lines_from) * (l as f64 + 0.5) / WALL_LOOP_LINES as f64;
let mut points = Vec::with_capacity(WALL_LOOP_SAMPLES + 1);
for k in 0..=WALL_LOOP_SAMPLES {
let t = from + (to - from) * k as f64 / WALL_LOOP_SAMPLES as f64;
let point = if along_u { face.surface.evaluate(t, line) } else { face.surface.evaluate(line, t) };
let Ok(point) = point else { break };
points.push((t, point));
}
if points.len() != WALL_LOOP_SAMPLES + 1 {
continue;
}
for k in WALL_LOOP_WINDOW..WALL_LOOP_SAMPLES - WALL_LOOP_WINDOW {
let step = points[k + 1].1.sub(points[k].1);
let window = points[k + WALL_LOOP_WINDOW].1.sub(points[k - WALL_LOOP_WINDOW].1);
let (step_length, window_length) = (step.length(), window.length());
if step_length <= bar || window_length <= bar {
continue;
}
if step.dot(window) < -bar * window_length {
let uv_band = crate::tolerance::surface_uv_tolerance(bar, 1.0, 1.0).max(1e-9);
let uv_of = |t: f64| if along_u { Vec2 { x: t, y: line } } else { Vec2 { x: line, y: t } };
let material = [points[k].0, points[k + 1].0].iter().all(|&t| {
matches!(parameter_point_in_face(face, uv_of(t), uv_band), Ok(PolygonClass::Inside))
});
if !material {
continue;
}
let (which, other) = if along_u { ("row (u)", "v") } else { ("column (v)", "u") };
return Err(crate::blend::wall_loop_refusal(
face.id,
format!(
"{} {entry}: the result's face {}{} passes back through itself along its {which} \
line at {other} = {line:.6}: between parameters {:.6} and {:.6} the line moves \
{step_length:.3e} against its own direction over the surrounding window \
({:.6}..{:.6}), so the wall carries two parameters on one point there. A \
fitted wall that loops is wrong in shape, not in area; nothing downstream can \
trim it right.",
crate::blend::WALL_FOLDS,
face.id,
face.name.as_ref().map(|name| format!(" '{name}'")).unwrap_or_default(),
points[k].0,
points[k + 1].0,
points[k - WALL_LOOP_WINDOW].0,
points[k + WALL_LOOP_WINDOW].0,
),
));
}
}
}
}
LOOP_FREE_FACES.with(|set| {
let mut set = set.borrow_mut();
if set.len() >= LOOP_FREE_FACES_CAP {
set.clear();
}
set.insert(key);
});
}
Ok(())
}