use super::*;
fn edge_and_faces<'a>(
solid: &'a BrepSolid,
edge_id: u64,
) -> Result<(&'a EdgeRecord, Vec<&'a FaceRecord>), String> {
let edge = solid
.edges
.iter()
.find(|edge| edge.id == edge_id)
.ok_or_else(|| 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))
}
fn face_plane_normal(face: &FaceRecord, near: Vec3) -> Result<(Vec3, Vec3), String> {
let projection = crate::project_point_to_surface(&face.surface, near)?;
let normal = face.surface.normal(projection.u, projection.v)?;
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, String> {
let (_, probe_outward) = face_plane_normal(face, probe_point)?;
let probe_candidate = probe_outward.cross(probe_tangent).normalized()?;
let (_, construct_outward) = face_plane_normal(face, construct_point)?;
let construct_candidate = construct_outward.cross(construct_tangent).normalized()?;
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)?;
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,
)?;
if class == crate::PolygonClass::Inside {
return Ok(construct_candidate.scale(sign));
}
}
}
Err(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), String>,
probe_step: f64,
) -> Result<Vec3, String> {
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(|| {
format!(
"fillet: could not orient the support direction into face {}",
face.id
)
}))
}
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, String> {
if !(radius > 0.0) || !radius.is_finite() {
return Err("fillet: radius must be positive".into());
}
let (edge, faces) = edge_and_faces(solid, edge_id)?;
if faces.len() != 2 {
return Err(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("fillet: spherical meridians require the general blend surgery".into());
}
let start = edge.curve.evaluate(edge.t0)?;
let end = edge.curve.evaluate(edge.t1)?;
let middle = edge.curve.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 = edge.curve.evaluate(edge.t0 + (edge.t1 - edge.t0) * 0.25)?;
let center = crate::analytic_surface::circumcenter(start, quarter, middle)
.ok_or("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(|_| "fillet: circular edge axis is degenerate".to_string())?;
for index in 0..=16 {
let point = edge
.curve
.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("fillet: edge is neither straight nor circular".into());
}
}
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(
"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)"
.into(),
);
}
}
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("fillet: faces are tangent along the edge".into());
}
let tangent_at = |point: Vec3| -> Result<Vec3, String> {
match &path {
EdgePath::Straight { direction, .. } => Ok(*direction),
EdgePath::Circular { center, axis, .. } => axis.cross(point.sub(*center)).normalized(),
}
};
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), String> {
let point = edge
.curve
.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, String> {
let (edge, faces) = edge_and_faces(solid, edge_id)?;
if faces.len() != 2 {
return Err(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, String> {
edge.curve.derivatives_extended(t, 1)?[1].normalized()
};
let middle_t = edge.t0 + span * 0.5;
let middle = edge.curve.evaluate(middle_t)?;
let middle_tangent = tangent_at(middle_t)?;
let scale = edge
.curve
.evaluate(edge.t0)?
.sub(edge.curve.evaluate(edge.t1)?)
.length()
.max(1.0);
let probe_step = (scale * 0.05).max(1e-6);
let station = |fraction: f64| -> Result<(Vec3, Vec3), String> {
let t = edge.t0 + span * fraction;
Ok((edge.curve.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()?;
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 / 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(format!(
"dihedral scan: only {} of {} stations on edge {edge_id} could be evaluated",
profile.samples,
SAMPLES + 1
));
}
Ok(profile)
}
pub(super) fn check_mixed_concavity(
solid: &BrepSolid,
edge_id: u64,
entry: &str,
) -> Result<(), String> {
let Ok(profile) = scan_dihedral(solid, edge_id) else {
return Ok(());
};
if !profile.is_mixed() {
return Ok(());
}
Err(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
}
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
}
pub(super) fn check_support_extent(
solid: &BrepSolid,
edge_id: u64,
size: f64,
entry: &str,
) -> Result<(), String> {
let Ok(cross) = analyze_edge(solid, edge_id, size) else {
return Ok(());
};
let Ok((_, faces)) = edge_and_faces(solid, edge_id) else {
return Ok(());
};
if faces.len() != 2 {
return Ok(());
}
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(());
}
let reach = size / (phi * 0.5).tan();
if !reach.is_finite() || reach <= 0.0 {
return Ok(());
}
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;
for (face, into) in [
(faces[0], cross.into_first),
(faces[1], cross.into_second),
] {
let mut boundary: Vec<Vec3> = Vec::new();
let 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;
};
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 + 1e-6 * (1.0 + extent.abs()) {
continue;
}
return Err(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(),
));
}
}
Ok(())
}
pub(super) fn check_blend_interference(
original: &BrepSolid,
result: &BrepSolid,
edge_ids: &[u64],
entry: &str,
) -> Result<(), String> {
use crate::{parameter_point_in_face, PointClass, PolygonClass, SolidClassifier, Vec2};
if edge_ids.is_empty() || !original.validate().is_empty() {
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 {
return Ok(());
};
if profile.is_mixed() {
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| -> String {
let side = if forbidden == PointClass::Out {
"outside the material it was cut from"
} else {
"inside material that was already there"
};
let sample = violations[0];
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 inherited: Vec<u64> = original.edges.iter().map(|edge| edge.id).collect();
let fresh: Vec<_> = result
.edges
.iter()
.filter(|edge| !inherited.contains(&edge.id))
.collect();
let skin = match fresh.is_empty() {
true => None,
false => Some(SolidClassifier::new(original, policy.pcurve_consistency / 10.0)?),
};
for edge in fresh {
let Some(skin) = skin.as_ref() else { break };
let mut sampled = 0usize;
let mut violations: Vec<Vec3> = Vec::new();
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 {
continue;
};
sampled += 1;
let Ok(classification) = skin.classify(point) else {
continue;
};
if classification.class == forbidden {
violations.push(point);
}
}
if violations.len() >= 2 {
return Err(report(
format!("edge {}", edge.id),
&violations,
sampled,
"along the grown",
));
}
}
if grown.is_empty() {
return Ok(());
}
let classifier = SolidClassifier::new(original, policy.intersection_fit)?;
const GRID: usize = 9;
for face in grown {
let (Ok([u0, u1]), Ok([v0, v1])) = (face.surface.domain_u(), face.surface.domain_v())
else {
continue;
};
let mut sampled = 0usize;
let mut violations: Vec<Vec3> = Vec::new();
for iu in 1..GRID {
for iv in 1..GRID {
let uv = Vec2 {
x: u0 + (u1 - u0) * iu as f64 / GRID as f64,
y: v0 + (v1 - v0) * iv as f64 / GRID as f64,
};
let band = face_sample_uv_band(face, uv.x, uv.y, policy.model);
let Ok(PolygonClass::Inside) = parameter_point_in_face(face, uv, band) else {
continue;
};
let Ok(point) = face.surface.evaluate(uv.x, uv.y) else {
continue;
};
sampled += 1;
let Ok(classification) = classifier.classify(point) else {
continue;
};
if classification.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"));
}
Ok(())
}
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)
}