use super::*;
fn offset_ruled_carrier(
surface: &NurbsSurface,
signed_distance: f64,
tolerance: f64,
) -> Result<NurbsSurface, String> {
let Some(AnalyticSurface::RuledRevolution {
frame,
rho0,
rho1,
height,
}) = surface.analytic()
else {
return Err("offset_ruled_carrier: face carrier is not a ruled revolution".into());
};
let (frame, rho0, rho1, height) = (frame.clone(), *rho0, *rho1, *height);
let slope = (rho1 - rho0) / height;
let grow = signed_distance * (1.0 + slope * slope).sqrt();
let rho0_new = rho0 + grow;
let rho1_new = rho1 + grow;
if rho0_new <= tolerance || rho1_new <= tolerance {
return Err(
"offset (push): the ruled carrier collapses to or past its axis — refusing".into(),
);
}
let start = frame.origin.add(frame.x_axis.scale(rho0_new));
let end = frame
.origin
.add(frame.axis.scale(height))
.add(frame.x_axis.scale(rho1_new));
let generatrix = make_line(start, end)?;
make_revolution(frame.origin, frame.axis, &generatrix, std::f64::consts::TAU)
}
fn outward_normal_mid(face: &FaceRecord) -> Result<(Vec3, Vec3), String> {
let [u0, u1] = face.surface.domain_u()?;
let [v0, v1] = face.surface.domain_v()?;
let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
let point = face.surface.evaluate(um, vm)?;
let mut normal = face.surface.normal(um, vm)?;
if !face.same_sense {
normal = normal.scale(-1.0);
}
Ok((point, normal))
}
pub fn offset_ruled_face(
solid: &BrepSolid,
face_id: u64,
distance: f64,
) -> Result<BrepSolid, String> {
if !distance.is_finite() {
return Err("offset_ruled_face: distance must be finite".into());
}
let scale = solid_model_scale(solid);
let tolerance = (scale * 1e-7).max(1e-9);
let plane_tolerance = (scale * 1e-6).max(1e-7);
let (pshell, pface) =
find_face(solid, face_id).ok_or_else(|| format!("offset_ruled_face: no face {face_id}"))?;
let pushed = &solid.shells[pshell].faces[pface];
let Some(AnalyticSurface::RuledRevolution { frame, .. }) = pushed.surface.analytic() else {
return Err("offset_ruled_face: the pushed face is not a cylinder or cone".into());
};
let (frame_origin, frame_axis) = (frame.origin, frame.axis);
let (mid_point, mid_normal) = outward_normal_mid(pushed)?;
let radial = {
let d = mid_point.sub(frame_origin);
d.sub(frame_axis.scale(d.dot(frame_axis)))
};
if radial.length() <= tolerance {
return Err("offset_ruled_face: degenerate radial direction (face on the axis)".into());
}
let outward_sign = if mid_normal.dot(radial) >= 0.0 { 1.0 } else { -1.0 };
let signed_distance = distance * outward_sign;
let s_prime = offset_ruled_carrier(&pushed.surface, signed_distance, tolerance)?;
let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
for shell in &solid.shells {
for face in &shell.faces {
for loop_record in &face.loops {
for coedge in &loop_record.coedges {
faces_of_edge
.entry(coedge.edge_id)
.or_default()
.push(face.id);
}
}
}
}
let edge_by_id: HashMap<u64, &EdgeRecord> =
solid.edges.iter().map(|edge| (edge.id, edge)).collect();
let mut new_curve: HashMap<u64, NurbsCurve> = HashMap::default();
let mut new_vertex: HashMap<u64, Vec3> = HashMap::default();
let mut cap_faces: HashSet<u64> = HashSet::default();
let mut ruled_faces: HashSet<u64> = HashSet::default();
let mut curved_faces: HashSet<u64> = HashSet::default();
let mut seam_edges: Vec<u64> = Vec::new();
let vertex_pos: HashMap<u64, Vec3> =
solid.vertices.iter().map(|v| (v.id, v.point)).collect();
let mut open_rims: Vec<OpenRim> = Vec::new();
let mut rim_edges: Vec<u64> = Vec::new();
let mut hole_edges: HashSet<u64> = HashSet::default();
for loop_record in &pushed.loops {
let Some(hole) = single_neighbour_hole(
loop_record,
face_id,
solid,
&faces_of_edge,
&edge_by_id,
frame_origin,
frame_axis,
scale,
)?
else {
continue;
};
rebuild_single_neighbour_hole(
solid,
&s_prime,
&hole,
&edge_by_id,
&vertex_pos,
tolerance,
scale,
&mut new_curve,
&mut new_vertex,
)?;
for edge_id in &hole.edge_ids {
hole_edges.insert(*edge_id);
if !rim_edges.contains(edge_id) {
rim_edges.push(*edge_id);
}
}
curved_faces.insert(hole.neighbour);
}
for loop_record in &pushed.loops {
let coedge_count = loop_record.coedges.len();
for coedge_index in 0..coedge_count {
let coedge = &loop_record.coedges[coedge_index];
let edge = *edge_by_id
.get(&coedge.edge_id)
.ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
if hole_edges.contains(&edge.id) {
continue; }
let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
let is_seam = incident.iter().all(|f| *f == face_id);
if is_seam {
if !seam_edges.contains(&edge.id) {
seam_edges.push(edge.id);
}
continue;
}
let neighbour = *incident
.iter()
.find(|f| **f != face_id)
.ok_or_else(|| format!("offset_ruled_face: edge {} has no neighbour", edge.id))?;
let (nshell, nface) = find_face(solid, neighbour)
.ok_or_else(|| format!("offset_ruled_face: missing neighbour {neighbour}"))?;
let neighbour_surface = &solid.shells[nshell].faces[nface].surface;
let is_plane =
matches!(neighbour_surface.analytic(), Some(AnalyticSurface::Plane { .. }));
let is_coaxial_ruled =
ruled_neighbour_is_coaxial(neighbour_surface, frame_origin, frame_axis, scale);
let is_curved = !is_plane && !is_coaxial_ruled;
let separated = || {
"offset_ruled_face: the pushed carrier no longer meets a neighbour \
(the push separated them, or the rim left the neighbour's domain) — refusing"
.to_string()
};
let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
let mut marched = false;
let curves = if is_curved {
if edge.start_vertex_id != edge.end_vertex_id {
return Err(format!(
"offset_ruled_face: the rim against curved neighbour {neighbour} is an \
OPEN arc (edge {}); an arc's corner is a triple point solved against a \
PLANE only — deferred (refusing)",
edge.id
));
}
let policy = MarchPolicy {
tolerance,
residual_tolerance: (scale * 5e-4).max(5e-6),
seeds: edge_seeds(edge, 9)?,
};
match reintersect_carriers(&s_prime, neighbour_surface, &policy) {
Ok(found) => {
marched = found.lane == RimLane::Marched;
if std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok() {
eprintln!(
"RIM neighbour {neighbour}: lane {:?}, {} branch(es), \
residual {:.3e} (gate {:.3e})",
found.lane,
found.sections.len(),
found.residual,
policy.residual_tolerance
);
}
found.curves()
}
Err(ReintersectRefusal::Separated) => return Err(separated()),
Err(other) => {
return Err(format!("offset_ruled_face: {}", other.describe()));
}
}
} else {
intersect_analytic_pair(&s_prime, neighbour_surface, tolerance)
.filter(|curves| !curves.is_empty())
.ok_or_else(separated)?
};
let rim = nearest_curve(&curves, old_mid)?;
if !rim_edges.contains(&edge.id) {
rim_edges.push(edge.id);
}
if edge.start_vertex_id == edge.end_vertex_id {
let rim = if marched {
match_marched_rim_direction(rim, edge)?
} else {
match_closed_rim_direction(rim, edge)?
};
let seam_point = if marched {
let [d0, _] = rim.domain()?;
rim.evaluate(d0)?
} else {
rim.evaluate(0.0)?
};
new_vertex.insert(edge.start_vertex_id, seam_point);
new_vertex.insert(edge.end_vertex_id, seam_point);
new_curve.insert(edge.id, rim);
} else {
let previous =
&loop_record.coedges[(coedge_index + coedge_count - 1) % coedge_count];
let next = &loop_record.coedges[(coedge_index + 1) % coedge_count];
let (at_start, at_end) = if coedge.forward {
(previous, next)
} else {
(next, previous)
};
let mut resolve = |adjacent_coedge: &CoedgeRecord,
vertex_id: u64|
-> Result<RimEnd, String> {
let adjacent = *edge_by_id.get(&adjacent_coedge.edge_id).ok_or_else(|| {
format!(
"offset_ruled_face: missing edge {}",
adjacent_coedge.edge_id
)
})?;
let old_point = vertex_pos
.get(&vertex_id)
.copied()
.ok_or_else(|| format!("offset_ruled_face: missing vertex {vertex_id}"))?;
let (end, point) = resolve_open_rim_end(
solid,
face_id,
&faces_of_edge,
&rim,
adjacent,
old_point,
plane_tolerance,
)?;
match new_vertex.get(&vertex_id).copied() {
Some(existing) if existing.sub(point).length() > plane_tolerance => {
return Err(
"offset_ruled_face: the two rims meeting at a multi-loop corner \
disagree on its new position — refusing"
.into(),
)
}
Some(_) => {}
None => {
new_vertex.insert(vertex_id, point);
}
}
Ok(end)
};
let start = resolve(at_start, edge.start_vertex_id)?;
let end = resolve(at_end, edge.end_vertex_id)?;
open_rims.push(OpenRim {
edge_id: edge.id,
conic: rim,
start,
end,
old_mid,
start_vertex_id: edge.start_vertex_id,
end_vertex_id: edge.end_vertex_id,
});
}
if is_plane {
cap_faces.insert(neighbour);
} else if is_coaxial_ruled {
ruled_faces.insert(neighbour);
} else {
curved_faces.insert(neighbour);
}
}
}
for rim in &open_rims {
let [d0, d1] = rim.conic.domain()?;
let middle = project_point_to_curve(&rim.conic, rim.old_mid)?.u;
let (from, to) = match (rim.start, rim.end) {
(RimEnd::Corner(a), RimEnd::Corner(b)) => {
let (low, high) = if a <= b { (a, b) } else { (b, a) };
if middle < low || middle > high {
return Err(
"offset_ruled_face: a multi-loop rim arc wraps the pushed carrier's \
periodic seam — deferred (refusing)"
.into(),
);
}
(low, high)
}
(RimEnd::Seam, RimEnd::Corner(corner)) | (RimEnd::Corner(corner), RimEnd::Seam) => {
if middle < corner {
(d0, corner)
} else {
(corner, d1)
}
}
(RimEnd::Seam, RimEnd::Seam) => {
return Err(
"offset_ruled_face: a multi-loop rim arc ends on the seam at BOTH ends — \
refusing"
.into(),
)
}
};
let mut trimmed = subcurve(&rim.conic, from, to)?;
let start_point = *new_vertex.get(&rim.start_vertex_id).ok_or_else(|| {
"offset_ruled_face: a multi-loop rim corner was not relocated — refusing".to_string()
})?;
let end_point = *new_vertex.get(&rim.end_vertex_id).ok_or_else(|| {
"offset_ruled_face: a multi-loop rim corner was not relocated — refusing".to_string()
})?;
let [t0, _] = trimmed.domain()?;
let head = trimmed.evaluate(t0)?;
if head.sub(start_point).length() > head.sub(end_point).length() {
trimmed = trimmed.reversed()?;
}
new_curve.insert(rim.edge_id, trimmed);
}
let pos = |vid: u64| -> Vec3 {
new_vertex
.get(&vid)
.copied()
.unwrap_or_else(|| vertex_pos[&vid])
};
for seam_id in &seam_edges {
let seam = *edge_by_id
.get(seam_id)
.ok_or_else(|| format!("offset_ruled_face: missing seam edge {seam_id}"))?;
let start = *new_vertex.get(&seam.start_vertex_id).ok_or_else(|| {
"offset_ruled_face: seam endpoint was not relocated by a rim — refusing".to_string()
})?;
let end = *new_vertex.get(&seam.end_vertex_id).ok_or_else(|| {
"offset_ruled_face: seam endpoint was not relocated by a rim — refusing".to_string()
})?;
new_curve.insert(*seam_id, make_line(start, end)?);
}
for ruled in &ruled_faces {
let (nshell, nface) = find_face(solid, *ruled)
.ok_or_else(|| format!("offset_ruled_face: missing ruled neighbour {ruled}"))?;
for loop_record in &solid.shells[nshell].faces[nface].loops {
for coedge in &loop_record.coedges {
let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
format!("offset_ruled_face: missing edge {}", coedge.edge_id)
})?;
let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
let is_seam = incident.iter().all(|f| *f == *ruled);
let touches_moved = new_vertex.contains_key(&edge.start_vertex_id)
|| new_vertex.contains_key(&edge.end_vertex_id);
if is_seam && touches_moved && !new_curve.contains_key(&edge.id) {
new_curve.insert(
edge.id,
make_line(pos(edge.start_vertex_id), pos(edge.end_vertex_id))?,
);
}
}
}
}
let mut new_range: HashMap<u64, (f64, f64)> = HashMap::default();
for curved in &curved_faces {
let (nshell, nface) = find_face(solid, *curved)
.ok_or_else(|| format!("offset_ruled_face: missing curved neighbour {curved}"))?;
for loop_record in &solid.shells[nshell].faces[nface].loops {
for coedge in &loop_record.coedges {
let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
format!("offset_ruled_face: missing edge {}", coedge.edge_id)
})?;
if new_curve.contains_key(&edge.id) || new_range.contains_key(&edge.id) {
continue;
}
let start_moved = new_vertex.get(&edge.start_vertex_id).copied();
let end_moved = new_vertex.get(&edge.end_vertex_id).copied();
if start_moved.is_none() && end_moved.is_none() {
continue;
}
if edge.start_vertex_id == edge.end_vertex_id {
return Err(format!(
"offset_ruled_face: the push moved the vertex of curved neighbour \
{curved}'s CLOSED edge {} — deferred (refusing)",
edge.id
));
}
let mut range = (edge.t0, edge.t1);
for (moved, slot) in [(start_moved, 0usize), (end_moved, 1usize)] {
let Some(point) = moved else { continue };
let projection = project_point_to_curve(&edge.curve, point)?;
if projection.distance > plane_tolerance {
return Err(format!(
"offset_ruled_face: a relocated rim vertex left curved neighbour \
{curved}'s edge {} (off by {:.3e}) — refusing",
edge.id, projection.distance
));
}
if slot == 0 {
range.0 = projection.u;
} else {
range.1 = projection.u;
}
}
new_range.insert(edge.id, range);
}
}
}
if !curved_faces.is_empty() {
for edge in &solid.edges {
if new_curve.contains_key(&edge.id) || new_range.contains_key(&edge.id) {
continue;
}
if new_vertex.contains_key(&edge.start_vertex_id)
|| new_vertex.contains_key(&edge.end_vertex_id)
{
return Err(format!(
"offset_ruled_face: relocating a curved neighbour's rim moved the end of \
edge {}, which this push does not rebuild — refusing",
edge.id
));
}
}
}
if !open_rims.is_empty() {
let mut corner_edges: Vec<(u64, NurbsCurve)> = Vec::new();
for edge in &solid.edges {
if new_curve.contains_key(&edge.id) {
continue;
}
if !new_vertex.contains_key(&edge.start_vertex_id)
&& !new_vertex.contains_key(&edge.end_vertex_id)
{
continue;
}
if edge.curve.degree != 1 || edge.curve.control_points.len() != 2 {
return Err(
"offset_ruled_face: the push moved the end of a CURVED edge that is not a \
rebuilt rim — refusing"
.into(),
);
}
let start = pos(edge.start_vertex_id);
let end = pos(edge.end_vertex_id);
for neighbour in faces_of_edge.get(&edge.id).cloned().unwrap_or_default() {
if !cap_faces.contains(&neighbour) {
return Err(
"offset_ruled_face: a relocated corner borders a face this push does not \
re-trim — refusing"
.into(),
);
}
let (nshell, nface) = find_face(solid, neighbour).ok_or_else(|| {
format!("offset_ruled_face: missing neighbour {neighbour}")
})?;
let plane = plane_of_surface(
&solid.shells[nshell].faces[nface].surface,
plane_tolerance,
"offset_ruled_face",
)?;
for point in [start, end] {
if point.sub(plane.origin).dot(plane.normal).abs() > plane_tolerance {
return Err(
"offset_ruled_face: a relocated corner left one of its fixed \
neighbour planes — refusing"
.into(),
);
}
}
}
corner_edges.push((edge.id, make_line(start, end)?));
}
for (edge_id, curve) in corner_edges {
new_curve.insert(edge_id, curve);
}
}
let changed_edges: HashSet<u64> = new_curve
.keys()
.chain(new_range.keys())
.copied()
.collect();
let mut result = solid.clone();
for edge in &mut result.edges {
if let Some(curve) = new_curve.get(&edge.id) {
let [d0, d1] = curve.domain()?;
edge.curve = curve.clone();
edge.t0 = d0;
edge.t1 = d1;
} else if let Some((t0, t1)) = new_range.get(&edge.id) {
edge.t0 = *t0;
edge.t1 = *t1;
}
}
for vertex in &mut result.vertices {
if let Some(point) = new_vertex.get(&vertex.id) {
vertex.point = *point;
}
}
result.shells[pshell].faces[pface].surface = s_prime;
let final_edges: HashMap<u64, EdgeRecord> =
result.edges.iter().map(|e| (e.id, e.clone())).collect();
if !ruled_faces.is_empty() {
retrim_offset_ruled_face(&mut result, face_id, &final_edges, tolerance)?;
for ruled in &ruled_faces {
retrim_offset_ruled_face(&mut result, *ruled, &final_edges, tolerance)?;
}
for curved in &curved_faces {
refit_changed_pcurves(&mut result, *curved, &final_edges, &changed_edges, tolerance)?;
}
} else if !curved_faces.is_empty() {
let (gshell, gface) = find_face(&result, face_id)
.ok_or_else(|| format!("offset_ruled_face: missing face {face_id}"))?;
let samples = boundary_samples(
&result.shells[gshell].faces[gface],
&final_edges,
"offset_ruled_face",
)?;
extend_ruled_neighbour_over(&mut result, face_id, &samples, tolerance)?;
refit_changed_pcurves(&mut result, face_id, &final_edges, &changed_edges, tolerance)?;
for curved in &curved_faces {
refit_changed_pcurves(&mut result, *curved, &final_edges, &changed_edges, tolerance)?;
}
} else if !open_rims.is_empty() {
let rebuilt: HashSet<u64> = rim_edges.iter().copied().collect();
let surface = result.shells[pshell].faces[pface].surface.clone();
let [u_start, u_end] = surface.domain_u()?;
let u_period = u_end - u_start;
for loop_record in &mut result.shells[pshell].faces[pface].loops {
for coedge in &mut loop_record.coedges {
if !rebuilt.contains(&coedge.edge_id) {
continue;
}
let edge = final_edges
.get(&coedge.edge_id)
.ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
let mut pcurve = build_pcurve_on_surface(&surface, &edge.curve)?;
if !coedge.forward {
pcurve = pcurve.reversed()?;
}
coedge.pcurve = reanchor_pcurve_u(&pcurve, &coedge.pcurve, u_period)?;
}
}
}
for cap in &cap_faces {
let (cshell, cface) = find_face(&result, *cap)
.ok_or_else(|| format!("offset_ruled_face: missing cap {cap}"))?;
let plane = plane_of_surface(
&result.shells[cshell].faces[cface].surface,
plane_tolerance,
"offset_ruled_face",
)?;
retrim_planar_face(
&mut result.shells[cshell].faces[cface],
&plane,
&final_edges,
scale,
"offset_ruled_face",
)?;
}
let issues = result.validate();
if !issues.is_empty() {
return Err(format!(
"offset_ruled_face: pushed solid failed validation: {issues:?}"
));
}
if let (Ok(before), Ok(after)) =
(solid_signed_volume(solid), solid_signed_volume(&result))
{
if before * after <= 0.0 {
return Err("offset_ruled_face: the push inverts the solid — refusing".into());
}
}
Ok(result)
}
#[derive(Clone, Copy)]
enum RimEnd {
Corner(f64),
Seam,
}
struct OpenRim {
edge_id: u64,
conic: NurbsCurve,
start: RimEnd,
end: RimEnd,
old_mid: Vec3,
start_vertex_id: u64,
end_vertex_id: u64,
}
fn resolve_open_rim_end(
solid: &BrepSolid,
face_id: u64,
faces_of_edge: &HashMap<u64, Vec<u64>>,
conic: &NurbsCurve,
adjacent: &EdgeRecord,
old_point: Vec3,
plane_tolerance: f64,
) -> Result<(RimEnd, Vec3), String> {
let incident = faces_of_edge.get(&adjacent.id).cloned().unwrap_or_default();
if incident.iter().all(|f| *f == face_id) {
let [d0, _] = conic.domain()?;
return Ok((RimEnd::Seam, conic.evaluate(d0)?));
}
let other = *incident
.iter()
.find(|f| **f != face_id)
.ok_or_else(|| format!("offset_ruled_face: edge {} has no neighbour", adjacent.id))?;
let (nshell, nface) = find_face(solid, other)
.ok_or_else(|| format!("offset_ruled_face: missing neighbour {other}"))?;
let plane = plane_of_surface(
&solid.shells[nshell].faces[nface].surface,
plane_tolerance,
"offset_ruled_face",
)?;
if curve_lies_in_plane(conic, &plane, plane_tolerance)? {
let projection = project_point_to_curve(conic, old_point)?;
return Ok((RimEnd::Corner(projection.u), conic.evaluate(projection.u)?));
}
let mut best: Option<(f64, f64)> = None;
for parameter in plane_crossing_params(conic, &plane)? {
let distance = conic.evaluate(parameter)?.sub(old_point).length();
if best.map(|(best, _)| distance < best).unwrap_or(true) {
best = Some((distance, parameter));
}
}
let (_, parameter) = best.ok_or_else(|| {
"offset_ruled_face: a multi-loop rim corner no longer meets its adjacent neighbour \
(the push pulled the window off it) — refusing"
.to_string()
})?;
Ok((RimEnd::Corner(parameter), conic.evaluate(parameter)?))
}
fn curve_lies_in_plane(
curve: &NurbsCurve,
plane: &Plane,
plane_tolerance: f64,
) -> Result<bool, String> {
let [d0, d1] = curve.domain()?;
for step in 0..=16 {
let t = d0 + (d1 - d0) * step as f64 / 16.0;
if curve
.evaluate(t)?
.sub(plane.origin)
.dot(plane.normal)
.abs()
> plane_tolerance
{
return Ok(false);
}
}
Ok(true)
}
fn plane_crossing_params(curve: &NurbsCurve, plane: &Plane) -> Result<Vec<f64>, String> {
let [d0, d1] = curve.domain()?;
let signed = |t: f64| -> Result<f64, String> {
Ok(curve.evaluate(t)?.sub(plane.origin).dot(plane.normal))
};
const SAMPLES: usize = 512;
let mut roots = Vec::new();
let mut previous = (d0, signed(d0)?);
for index in 1..=SAMPLES {
let t = d0 + (d1 - d0) * index as f64 / SAMPLES as f64;
let value = signed(t)?;
if previous.1 == 0.0 {
roots.push(previous.0);
} else if (previous.1 < 0.0) != (value < 0.0) {
let (mut low, mut high) = (previous.0, t);
let mut low_value = previous.1;
for _ in 0..100 {
let middle = 0.5 * (low + high);
if middle <= low || middle >= high {
break;
}
let middle_value = signed(middle)?;
if (low_value < 0.0) != (middle_value < 0.0) {
high = middle;
} else {
low = middle;
low_value = middle_value;
}
}
roots.push(0.5 * (low + high));
}
previous = (t, value);
}
if previous.1 == 0.0 {
roots.push(previous.0);
}
Ok(roots)
}
fn subcurve(curve: &NurbsCurve, from: f64, to: f64) -> Result<NurbsCurve, String> {
let [d0, d1] = curve.domain()?;
let span = (d1 - d0).max(1e-12);
if to - from <= 1e-9 * span {
return Err("offset_ruled_face: a rebuilt rim arc collapsed to a point — refusing".into());
}
let mut trimmed = curve.clone();
if to < d1 - 1e-9 * span {
trimmed = trimmed.split(to)?.0;
}
if from > d0 + 1e-9 * span {
trimmed = trimmed.split(from)?.1;
}
Ok(trimmed)
}
fn match_closed_rim_direction(
rim: NurbsCurve,
previous: &EdgeRecord,
) -> Result<NurbsCurve, String> {
let [d0, _] = rim.domain()?;
let incoming = previous.curve.derivatives(previous.t0, 1)?;
let rebuilt = rim.derivatives(d0, 1)?;
if incoming[1].dot(rebuilt[1]) < 0.0 {
return rim.reversed();
}
Ok(rim)
}
fn reanchor_pcurve_u(
pcurve: &NurbsCurve,
previous: &NurbsCurve,
u_period: f64,
) -> Result<NurbsCurve, String> {
if !(u_period.is_finite() && u_period > 0.0) {
return Ok(pcurve.clone());
}
let [a0, a1] = pcurve.domain()?;
let [b0, b1] = previous.domain()?;
let shift = ((previous.evaluate(b0)?.x - pcurve.evaluate(a0)?.x) / u_period).round() * u_period;
let shifted = if shift == 0.0 {
pcurve.clone()
} else {
let controls = pcurve
.control_points
.iter()
.map(|control| {
let mut point = control.point()?;
point.x += shift;
Ok(crate::Vec4::from_point(point, control.w))
})
.collect::<Result<Vec<_>, String>>()?;
NurbsCurve::new(pcurve.degree, pcurve.knots.clone(), controls)?
};
let end_drift = (shifted.evaluate(a1)?.x - previous.evaluate(b1)?.x).abs();
if end_drift > 0.25 * u_period {
return Err(format!(
"offset_ruled_face: a rebuilt rim traverses the carrier's periodic parameter \
differently from the edge it replaces (end drift {end_drift}) — refusing"
));
}
Ok(shifted)
}
pub(super) fn ruled_neighbour_is_coaxial(
neighbour: &NurbsSurface,
axis_origin: Vec3,
axis_dir: Vec3,
scale: f64,
) -> bool {
let Some(AnalyticSurface::RuledRevolution { frame, .. }) = neighbour.analytic() else {
return false;
};
if frame.axis.dot(axis_dir).abs() < 1.0 - 1e-9 {
return false;
}
let offset = frame.origin.sub(axis_origin);
let perpendicular = offset.sub(axis_dir.scale(offset.dot(axis_dir)));
perpendicular.length() <= 1e-9 * scale.max(1.0)
}
pub(super) fn retrim_offset_ruled_face(
result: &mut BrepSolid,
face_id: u64,
final_edges: &HashMap<u64, EdgeRecord>,
tolerance: f64,
) -> Result<(), String> {
let (shell, face_pos) = find_face(result, face_id)
.ok_or_else(|| format!("offset_ruled_face: missing ruled face {face_id}"))?;
retrim_face_in_solid(
result,
shell,
face_pos,
final_edges,
|solid, points| extend_ruled_neighbour_over(solid, face_id, points, tolerance),
PcurveFit::SubrangeAware { tolerance },
"offset_ruled_face",
)
}
struct SingleNeighbourHole {
neighbour: u64,
edge_ids: Vec<u64>,
pinned: Vec<(u64, u64)>,
}
fn seam_axial_plane(surface: &NurbsSurface, seam: &EdgeRecord) -> Option<Plane> {
let structure = crate::revolution_structure(surface)?;
let midpoint = seam
.curve
.evaluate(0.5 * (seam.t0 + seam.t1))
.ok()?;
let offset = midpoint.sub(structure.frame.origin);
let radial = offset
.sub(structure.frame.axis.scale(offset.dot(structure.frame.axis)))
.normalized()
.ok()?;
let normal = structure.frame.axis.cross(radial).normalized().ok()?;
Some(Plane {
origin: structure.frame.origin,
u_dir: structure.frame.axis,
v_dir: radial,
normal,
})
}
#[allow(clippy::too_many_arguments)]
fn single_neighbour_hole(
loop_record: &crate::topology::LoopRecord,
face_id: u64,
solid: &BrepSolid,
faces_of_edge: &HashMap<u64, Vec<u64>>,
edge_by_id: &HashMap<u64, &EdgeRecord>,
frame_origin: Vec3,
frame_axis: Vec3,
scale: f64,
) -> Result<Option<SingleNeighbourHole>, String> {
let debug = std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok();
let mut neighbour: Option<u64> = None;
let mut edge_ids: Vec<u64> = Vec::new();
for coedge in &loop_record.coedges {
let edge = *edge_by_id
.get(&coedge.edge_id)
.ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
if edge.start_vertex_id == edge.end_vertex_id {
if debug { eprintln!("HOLE reject: edge {} is closed", edge.id); }
return Ok(None); }
let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
let others: Vec<u64> = incident.into_iter().filter(|f| *f != face_id).collect();
if others.len() != 1 {
if debug { eprintln!("HOLE reject: edge {} has {} others", edge.id, others.len()); }
return Ok(None); }
match neighbour {
Some(known) if known != others[0] => {
if debug { eprintln!("HOLE reject: mixed neighbours {known} / {}", others[0]); }
return Ok(None);
}
Some(_) => {}
None => neighbour = Some(others[0]),
}
if !edge_ids.contains(&edge.id) {
edge_ids.push(edge.id);
}
}
let Some(neighbour) = neighbour else {
return Ok(None);
};
if edge_ids.len() < 2 {
if debug { eprintln!("HOLE reject: only {} edges", edge_ids.len()); }
return Ok(None);
}
let (nshell, nface) = find_face(solid, neighbour)
.ok_or_else(|| format!("offset_ruled_face: missing neighbour {neighbour}"))?;
let surface = &solid.shells[nshell].faces[nface].surface;
if matches!(surface.analytic(), Some(AnalyticSurface::Plane { .. }))
|| ruled_neighbour_is_coaxial(surface, frame_origin, frame_axis, scale)
{
if debug { eprintln!("HOLE reject: neighbour {neighbour} is planar/coaxial"); }
return Ok(None); }
let neighbour_edges: HashSet<u64> = solid.shells[nshell].faces[nface]
.loops
.iter()
.flat_map(|loop_record| &loop_record.coedges)
.map(|coedge| coedge.edge_id)
.collect();
let mut pinned: Vec<(u64, u64)> = Vec::new();
for edge_id in &edge_ids {
let edge = *edge_by_id
.get(edge_id)
.ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
for other in &solid.edges {
if other.start_vertex_id != vertex_id && other.end_vertex_id != vertex_id {
continue;
}
if edge_ids.contains(&other.id) {
continue;
}
if !neighbour_edges.contains(&other.id) {
if debug {
eprintln!("HOLE reject: vertex {vertex_id} also on foreign edge {}", other.id);
}
return Ok(None);
}
if pinned
.iter()
.any(|(known, pin)| *known == vertex_id && *pin != other.id)
{
if debug {
eprintln!("HOLE reject: vertex {vertex_id} pinned by two neighbour edges");
}
return Ok(None);
}
if !pinned.iter().any(|(known, _)| *known == vertex_id) {
pinned.push((vertex_id, other.id));
}
}
}
}
if debug {
eprintln!("HOLE accept: neighbour {neighbour} edges {edge_ids:?} pinned {pinned:?}");
}
Ok(Some(SingleNeighbourHole {
neighbour,
edge_ids,
pinned,
}))
}
#[allow(clippy::too_many_arguments)]
fn rebuild_single_neighbour_hole(
solid: &BrepSolid,
s_prime: &NurbsSurface,
hole: &SingleNeighbourHole,
edge_by_id: &HashMap<u64, &EdgeRecord>,
vertex_pos: &HashMap<u64, Vec3>,
tolerance: f64,
scale: f64,
new_curve: &mut HashMap<u64, NurbsCurve>,
new_vertex: &mut HashMap<u64, Vec3>,
) -> Result<(), String> {
let (nshell, nface) = find_face(solid, hole.neighbour)
.ok_or_else(|| format!("offset_ruled_face: missing neighbour {}", hole.neighbour))?;
let neighbour_surface = &solid.shells[nshell].faces[nface].surface;
let mut seeds: Vec<Vec3> = Vec::new();
let mut reference: Vec<Vec3> = Vec::new();
for edge_id in &hole.edge_ids {
let edge = *edge_by_id
.get(edge_id)
.ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
let samples = edge_seeds(edge, 9)?;
reference.extend(samples.iter().copied());
seeds.extend(samples);
}
let policy = MarchPolicy {
tolerance,
residual_tolerance: (scale * 5e-4).max(5e-6),
seeds,
};
let found = match reintersect_carriers(s_prime, neighbour_surface, &policy) {
Ok(found) => found,
Err(ReintersectRefusal::Separated) => {
return Err(
"offset_ruled_face: the pushed carrier no longer meets a neighbour \
(the push separated them, or the rim left the neighbour's domain) — refusing"
.into(),
)
}
Err(other) => return Err(format!("offset_ruled_face: {}", other.describe())),
};
if found.lane != RimLane::Marched {
return Err(format!(
"offset_ruled_face: the window bounded by face {} re-intersects in CLOSED FORM, \
whose arc rebuild is the analytic lane's — deferred (refusing)",
hole.neighbour
));
}
if std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok() {
eprintln!(
"HOLE rebuild neighbour {}: lane {:?}, {} branch(es), residual {:.3e} \
(gate {:.3e})",
hole.neighbour,
found.lane,
found.sections.len(),
found.residual,
policy.residual_tolerance
);
}
let section = found
.nearest_section(&reference)
.map_err(|error| format!("offset_ruled_face: {error}"))?;
for edge_id in &hole.edge_ids {
let edge = *edge_by_id
.get(edge_id)
.ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
if new_vertex.contains_key(&vertex_id) {
continue;
}
let old = *vertex_pos
.get(&vertex_id)
.ok_or_else(|| format!("offset_ruled_face: missing vertex {vertex_id}"))?;
let point = match hole
.pinned
.iter()
.find(|(known, _)| *known == vertex_id)
.map(|(_, pin)| *pin)
{
Some(pin) => {
let seam = *edge_by_id
.get(&pin)
.ok_or_else(|| format!("offset_ruled_face: missing edge {pin}"))?;
let plane = seam_axial_plane(neighbour_surface, seam).ok_or_else(|| {
format!(
"offset_ruled_face: the window's corner is pinned to edge {pin} of a \
neighbour that is not a surface of revolution — refusing"
)
})?;
let reach = reference
.iter()
.map(|point| point.sub(old).length())
.fold(0.0f64, f64::max)
.max(tolerance * 100.0);
curve_plane_crossing_near(§ion.curve, &plane, old, reach)
.map(|(_, point)| point)
.ok_or_else(|| {
format!(
"offset_ruled_face: the rebuilt window never crosses the seam \
(edge {pin}) its corner is pinned to — refusing"
)
})?
}
None => section_corner(section, old)
.map_err(|error| format!("offset_ruled_face: {error}"))?,
};
new_vertex.insert(vertex_id, point);
}
}
for edge_id in &hole.edge_ids {
let edge = *edge_by_id
.get(edge_id)
.ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
let from = new_vertex[&edge.start_vertex_id];
let to = new_vertex[&edge.end_vertex_id];
let through = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
let arc = arc_of_section(section, from, to, through, tolerance)
.map_err(|error| format!("offset_ruled_face: {error}"))?;
new_curve.insert(edge.id, arc);
}
Ok(())
}
fn refit_changed_pcurves(
result: &mut BrepSolid,
face_id: u64,
final_edges: &HashMap<u64, EdgeRecord>,
changed: &HashSet<u64>,
tolerance: f64,
) -> Result<(), String> {
let (shell, face_pos) = find_face(result, face_id)
.ok_or_else(|| format!("offset_ruled_face: missing face {face_id}"))?;
let surface = result.shells[shell].faces[face_pos].surface.clone();
let [u_start, u_end] = surface.domain_u()?;
let u_period = u_end - u_start;
for loop_record in &mut result.shells[shell].faces[face_pos].loops {
for coedge in &mut loop_record.coedges {
if !changed.contains(&coedge.edge_id) {
continue;
}
let edge = final_edges
.get(&coedge.edge_id)
.ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
let [d0, d1] = edge.curve.domain()?;
let span = (d1 - d0).max(1e-12);
let is_subrange =
(edge.t0 - d0).abs() > 1e-9 * span || (edge.t1 - d1).abs() > 1e-9 * span;
let pcurve = if is_subrange {
build_pcurve_on_surface_range(
&surface,
&edge.curve,
edge.t0,
edge.t1,
coedge.forward,
tolerance,
)?
} else {
let mut pcurve = build_pcurve_on_surface(&surface, &edge.curve)?;
if !coedge.forward {
pcurve = pcurve.reversed()?;
}
pcurve
};
coedge.pcurve = reanchor_pcurve_u(&pcurve, &coedge.pcurve, u_period)?;
}
}
Ok(())
}
fn nearest_curve(curves: &[NurbsCurve], reference: Vec3) -> Result<NurbsCurve, String> {
let mut best: Option<(f64, &NurbsCurve)> = None;
for curve in curves {
let mid = curve.evaluate(0.5)?;
let d = mid.sub(reference).length();
if best.map(|(best_d, _)| d < best_d).unwrap_or(true) {
best = Some((d, curve));
}
}
best.map(|(_, curve)| curve.clone())
.ok_or_else(|| "offset_ruled_face: empty intersection".into())
}
#[cfg(test)]
mod probe_tests {
use super::*;
use crate::{make_cone_brep, make_cylinder_brep};
fn radial(point: Vec3, origin: Vec3, axis: Vec3) -> f64 {
let d = point.sub(origin);
d.sub(axis.scale(d.dot(axis))).length()
}
fn side_surface(solid: &BrepSolid) -> NurbsSurface {
solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.find(|face| matches!(face.surface.analytic(), Some(AnalyticSurface::RuledRevolution { .. })))
.expect("a ruled side face")
.surface
.clone()
}
fn side_face_id(solid: &BrepSolid) -> u64 {
solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.find(|face| matches!(face.surface.analytic(), Some(AnalyticSurface::RuledRevolution { .. })))
.expect("a ruled side face")
.id
}
#[test]
fn push_solid_cylinder_side_changes_radius() {
let axis = Vec3::new(0.0, 0.0, 1.0);
for (d, r_new) in [(2.0_f64, 7.0_f64), (-2.0, 3.0)] {
let cyl = make_cylinder_brep(Vec3::default(), axis, 5.0, 10.0).unwrap();
let side = side_face_id(&cyl);
let pushed = offset_ruled_face(&cyl, side, d)
.unwrap_or_else(|e| panic!("push cylinder side by {d}: {e}"));
assert!(pushed.validate().is_empty(), "validate {d}: {:?}", pushed.validate());
let got = solid_signed_volume(&pushed).unwrap().abs();
let expected = std::f64::consts::PI * r_new * r_new * 10.0;
assert!((got - expected).abs() < 1e-3, "push {d}: vol {got}, want {expected}");
}
}
#[test]
fn push_drilled_hole_wall_resizes_the_hole() {
let plate = crate::make_box_brep(Vec3::new(0.0, 0.0, 0.0), 20.0, 20.0, 4.0).unwrap();
let cutter = make_cylinder_brep(Vec3::new(10.0, 10.0, -1.0), Vec3::new(0.0, 0.0, 1.0), 3.0, 6.0)
.unwrap();
let options = crate::BooleanOptions {
merge_coplanar_faces: true,
..crate::BooleanOptions::default()
};
let drilled =
crate::boolean_operation(&plate, &cutter, crate::BooleanOperation::Subtract, &options)
.unwrap();
let v0 = solid_signed_volume(&drilled).unwrap().abs();
let wall = side_face_id(&drilled);
let pushed = offset_ruled_face(&drilled, wall, 1.0)
.unwrap_or_else(|e| panic!("push drilled-hole wall: {e}"));
assert!(pushed.validate().is_empty(), "validate: {:?}", pushed.validate());
let got = solid_signed_volume(&pushed).unwrap().abs();
let expected = std::f64::consts::PI * (9.0 - 4.0) * 4.0;
assert!(
(got - v0 - expected).abs() < 1e-3,
"hole-shrink volume delta {}, expected {expected}",
got - v0
);
}
#[test]
fn push_cylinder_side_through_axis_refuses() {
let cyl = make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 10.0).unwrap();
let side = side_face_id(&cyl);
let err = offset_ruled_face(&cyl, side, -5.0).expect_err("collapse must refuse");
assert!(err.contains("axis") || err.contains("refus"), "unexpected: {err}");
}
fn slotted_cylinder() -> BrepSolid {
let cyl = make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 10.0).unwrap();
let slot = crate::make_box_brep(Vec3::new(-1.5, -9.0, 3.5), 3.0, 18.0, 3.0).unwrap();
let options = crate::BooleanOptions {
merge_coplanar_faces: true,
..crate::BooleanOptions::default()
};
crate::boolean_operation(&cyl, &slot, crate::BooleanOperation::Subtract, &options).unwrap()
}
fn slotted_wall_id(solid: &BrepSolid) -> u64 {
solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.filter(|face| {
matches!(face.surface.analytic(), Some(AnalyticSurface::RuledRevolution { .. }))
})
.max_by_key(|face| face.loops.len())
.expect("a ruled side face")
.id
}
fn slotted_cylinder_volume(radius: f64) -> f64 {
let a = 1.5_f64;
let strip =
2.0 * (a * (radius * radius - a * a).sqrt() + radius * radius * (a / radius).asin());
std::f64::consts::PI * radius * radius * 10.0 - 3.0 * strip
}
#[test]
fn push_multiloop_cylinder_wall_resizes() {
let slotted = slotted_cylinder();
assert!(slotted.validate().is_empty(), "fixture: {:?}", slotted.validate());
let wall_id = slotted_wall_id(&slotted);
let before: Vec<usize> = slotted
.shells
.iter()
.flat_map(|shell| &shell.faces)
.find(|face| face.id == wall_id)
.expect("the wall")
.loops
.iter()
.map(|loop_record| loop_record.coedges.len())
.collect();
assert!(
before.len() >= 2,
"fixture must give the wall multiple loops, got {before:?}"
);
let base = solid_signed_volume(&slotted).unwrap().abs();
assert!(
(base - slotted_cylinder_volume(5.0)).abs() < 1e-3,
"fixture volume {base}, want {}",
slotted_cylinder_volume(5.0)
);
for (d, r_new) in [(1.0_f64, 6.0_f64), (-1.0, 4.0)] {
let pushed = offset_ruled_face(&slotted, wall_id, d)
.unwrap_or_else(|e| panic!("multi-loop wall push {d}: {e}"));
assert!(pushed.validate().is_empty(), "validate {d}: {:?}", pushed.validate());
let wall = pushed
.shells
.iter()
.flat_map(|shell| &shell.faces)
.find(|face| face.id == wall_id)
.expect("the pushed wall");
let Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. }) =
wall.surface.analytic()
else {
panic!("the pushed wall is no longer a ruled revolution");
};
assert!(
(rho0 - r_new).abs() < 1e-9 && (rho1 - r_new).abs() < 1e-9,
"push {d}: wall radii {rho0}/{rho1}, want {r_new}"
);
let after: Vec<usize> = wall
.loops
.iter()
.map(|loop_record| loop_record.coedges.len())
.collect();
assert_eq!(after, before, "push {d} must preserve the wall's loop structure");
let corner_y = (r_new * r_new - 2.25_f64).sqrt();
let mut corners = 0;
for vertex in &pushed.vertices {
let on_slot_plane =
(vertex.point.z - 3.5).abs() < 1e-9 || (vertex.point.z - 6.5).abs() < 1e-9;
if !on_slot_plane || (vertex.point.x.abs() - 1.5).abs() > 1e-9 {
continue;
}
assert!(
(vertex.point.y.abs() - corner_y).abs() < 1e-6,
"push {d}: slot corner {:?} should sit at |y| = {corner_y}",
vertex.point
);
corners += 1;
}
assert_eq!(corners, 8, "push {d}: the slot has eight corner vertices");
let expected = slotted_cylinder_volume(r_new);
let got = solid_signed_volume(&pushed).unwrap().abs();
assert!(
(got - expected).abs() < 1e-3,
"push {d}: volume {got}, want {expected} (delta {}, want {})",
got - base,
expected - base
);
}
assert!(slotted.validate().is_empty());
}
#[test]
fn push_multiloop_cylinder_wall_collapsing_the_slot_refuses() {
let slotted = slotted_cylinder();
let wall_id = slotted_wall_id(&slotted);
for d in [-3.6_f64, -5.0] {
let err = offset_ruled_face(&slotted, wall_id, d)
.expect_err("a push that collapses the slot must refuse");
assert!(
err.contains("refus") || err.contains("validation"),
"push {d} must refuse with a typed error, got: {err}"
);
}
assert!(slotted.validate().is_empty());
}
#[test]
fn push_multiloop_cone_wall_is_fail_safe() {
let frustum =
make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 6.0, 3.0, 10.0).unwrap();
let slot = crate::make_box_brep(Vec3::new(-1.5, -9.0, 3.5), 3.0, 18.0, 3.0).unwrap();
let options = crate::BooleanOptions {
merge_coplanar_faces: true,
..crate::BooleanOptions::default()
};
let slotted =
crate::boolean_operation(&frustum, &slot, crate::BooleanOperation::Subtract, &options)
.unwrap();
assert!(slotted.validate().is_empty(), "fixture: {:?}", slotted.validate());
let wall = slotted_wall_id(&slotted);
for d in [1.0_f64, -1.0] {
match offset_ruled_face(&slotted, wall, d) {
Err(error) => assert!(
error.contains("refus"),
"push {d} must refuse with a typed error, got: {error}"
),
Ok(good) => assert!(
good.validate().is_empty(),
"if the cone multi-loop push is accepted it must be valid: {:?}",
good.validate()
),
}
}
assert!(slotted.validate().is_empty());
}
#[test]
fn push_cylinder_wall_with_a_radial_bore_still_refuses() {
let cyl = make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 10.0).unwrap();
let bore =
make_cylinder_brep(Vec3::new(0.0, -9.0, 5.0), Vec3::new(0.0, 1.0, 0.0), 1.5, 18.0)
.unwrap();
let options = crate::BooleanOptions {
merge_coplanar_faces: true,
..crate::BooleanOptions::default()
};
let bored =
crate::boolean_operation(&cyl, &bore, crate::BooleanOperation::Subtract, &options)
.unwrap();
assert!(bored.validate().is_empty(), "fixture: {:?}", bored.validate());
let wall = cylinder_side_id(&bored, Vec3::new(0.0, 0.0, 1.0));
let err = offset_ruled_face(&bored, wall, 1.0)
.expect_err("a radial bore's seam-straddling hole must still refuse");
assert!(
err.contains("seam") || err.contains("OPEN arc"),
"expected the seam-straddling refusal, got: {err}"
);
assert!(bored.validate().is_empty());
}
#[test]
fn push_frustum_side_grows_both_radii() {
let axis = Vec3::new(0.0, 0.0, 1.0);
let (r_b, r_t, h, d) = (4.0_f64, 2.0_f64, 6.0_f64, 1.0_f64);
let frustum = make_cone_brep(Vec3::default(), axis, r_b, r_t, h).unwrap();
let side = side_face_id(&frustum);
let pushed = offset_ruled_face(&frustum, side, d)
.unwrap_or_else(|e| panic!("push frustum side: {e}"));
assert!(pushed.validate().is_empty(), "validate: {:?}", pushed.validate());
let slope = (r_t - r_b) / h;
let grow = d * (1.0 + slope * slope).sqrt();
let (rb, rt) = (r_b + grow, r_t + grow);
let expected = std::f64::consts::PI * h / 3.0 * (rb * rb + rb * rt + rt * rt);
let got = solid_signed_volume(&pushed).unwrap().abs();
assert!((got - expected).abs() < 1e-3, "frustum push vol {got}, want {expected}");
}
fn cap_surface_at(solid: &BrepSolid, axis: Vec3, target_axial: f64) -> NurbsSurface {
solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.filter(|face| matches!(face.surface.analytic(), Some(AnalyticSurface::Plane { .. })))
.find(|face| {
let (u, v) = (0.5, 0.5);
let p = face.surface.evaluate(u, v).unwrap();
(p.dot(axis) - target_axial).abs() < 1e-6
})
.expect("a planar cap at the target height")
.surface
.clone()
}
#[test]
fn offset_carrier_recognizes_and_reintersects_the_caps() {
let axis = Vec3::new(0.0, 0.0, 1.0);
let tol = 1e-9;
let cyl = make_cylinder_brep(Vec3::default(), axis, 5.0, 10.0).unwrap();
let s_prime = offset_ruled_carrier(&side_surface(&cyl), 2.0, tol).unwrap();
match s_prime.analytic() {
Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. }) => {
assert!((rho0 - 7.0).abs() < 1e-6 && (rho1 - 7.0).abs() < 1e-6, "cyl rho {rho0},{rho1}");
}
other => panic!("cylinder offset must re-recognize as ruled, got {other:?}"),
}
for (z, want_r) in [(0.0, 7.0), (10.0, 7.0)] {
let cap = cap_surface_at(&cyl, axis, z);
let curves = intersect_analytic_pair(&s_prime, &cap, tol)
.unwrap_or_else(|| panic!("no analytic intersection at z={z}"));
assert_eq!(curves.len(), 1, "one rim circle at z={z}");
for step in 0..=8 {
let p = curves[0].evaluate(step as f64 / 8.0).unwrap();
assert!((radial(p, Vec3::default(), axis) - want_r).abs() < 1e-6, "cyl rim r at z={z}");
assert!((p.z - z).abs() < 1e-6, "cyl rim z");
}
}
let grow = (1.0 + (1.0f64 / 3.0).powi(2)).sqrt();
let frustum = make_cone_brep(Vec3::default(), axis, 4.0, 2.0, 6.0).unwrap();
let s_prime = offset_ruled_carrier(&side_surface(&frustum), 1.0, tol).unwrap();
assert!(
matches!(s_prime.analytic(), Some(AnalyticSurface::RuledRevolution { .. })),
"frustum offset must re-recognize as ruled"
);
for (z, base_r) in [(0.0, 4.0), (6.0, 2.0)] {
let cap = cap_surface_at(&frustum, axis, z);
let curves = intersect_analytic_pair(&s_prime, &cap, tol)
.unwrap_or_else(|| panic!("no frustum intersection at z={z}"));
assert_eq!(curves.len(), 1, "one frustum rim circle at z={z}");
let want_r = base_r + grow;
for step in 0..=8 {
let p = curves[0].evaluate(step as f64 / 8.0).unwrap();
assert!(
(radial(p, Vec3::default(), axis) - want_r).abs() < 1e-6,
"frustum rim r at z={z}: got {}, want {want_r}",
radial(p, Vec3::default(), axis)
);
assert!((p.z - z).abs() < 1e-6, "frustum rim z");
}
}
}
fn coaxial_cone_cylinder_boss() -> BrepSolid {
let axis = Vec3::new(0.0, 0.0, 1.0);
let cone = make_cone_brep(Vec3::default(), axis, 5.0, 3.0, 4.0).unwrap();
let cyl = make_cylinder_brep(Vec3::default(), axis, 3.0, 10.0).unwrap();
let options = crate::BooleanOptions {
merge_coplanar_faces: true,
..crate::BooleanOptions::default()
};
crate::boolean_operation(&cone, &cyl, crate::BooleanOperation::Union, &options).unwrap()
}
fn cylinder_side_id(solid: &BrepSolid, axis: Vec3) -> u64 {
solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.find(|face| match face.surface.analytic() {
Some(AnalyticSurface::RuledRevolution { frame, rho0, rho1, .. }) => {
(rho0 - rho1).abs() < 1e-9 && frame.axis.dot(axis).abs() > 1.0 - 1e-9
}
_ => false,
})
.expect("a cylinder side face")
.id
}
fn counts(solid: &BrepSolid) -> (usize, usize, usize) {
let faces = solid.shells.iter().map(|s| s.faces.len()).sum();
(solid.vertices.len(), solid.edges.len(), faces)
}
#[test]
fn push_cylinder_side_against_coaxial_cone_reintersects() {
let axis = Vec3::new(0.0, 0.0, 1.0);
let solid = coaxial_cone_cylinder_boss();
assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
let before_counts = counts(&solid);
let before_vol = solid_signed_volume(&solid).unwrap();
let cyl = cylinder_side_id(&solid, axis);
let pushed = offset_ruled_face(&solid, cyl, 1.0)
.unwrap_or_else(|e| panic!("push cylinder side against coaxial cone: {e}"));
assert!(pushed.validate().is_empty(), "validate: {:?}", pushed.validate());
assert_eq!(counts(&pushed), before_counts, "topology counts changed");
let expected =
std::f64::consts::PI * (2.0 / 3.0 * (25.0 + 20.0 + 16.0) + 16.0 * 8.0);
let got = solid_signed_volume(&pushed).unwrap().abs();
assert!((got - expected).abs() < 1e-2, "vol {got}, want {expected}");
assert!(
before_vol * solid_signed_volume(&pushed).unwrap() > 0.0,
"volume sign flipped"
);
let cyl_face = pushed
.shells
.iter()
.flat_map(|s| &s.faces)
.find(|f| f.id == cyl)
.unwrap();
match cyl_face.surface.analytic() {
Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. }) => {
assert!((rho0 - 4.0).abs() < 1e-6 && (rho1 - 4.0).abs() < 1e-6, "cyl r {rho0}");
}
other => panic!("pushed face must stay ruled, got {other:?}"),
}
let shared = pushed
.edges
.iter()
.find(|e| {
let m = e.curve.evaluate(0.5 * (e.t0 + e.t1)).unwrap();
(radial(m, Vec3::default(), axis) - 4.0).abs() < 1e-6 && (m.z - 2.0).abs() < 1e-6
})
.expect("relocated shared rim at z=2, r=4");
assert!(shared.start_vertex_id == shared.end_vertex_id, "shared rim is a closed circle");
}
#[test]
fn push_cylinder_side_against_a_crossing_pipe_matches_the_rebuilt_tee() {
let vertical =
make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 10.0).unwrap();
let horizontal =
make_cylinder_brep(Vec3::new(-6.0, 0.0, 5.0), Vec3::new(1.0, 0.0, 0.0), 1.5, 12.0)
.unwrap();
let options = crate::BooleanOptions {
merge_coplanar_faces: true,
..crate::BooleanOptions::default()
};
let solid = crate::boolean_operation(
&vertical,
&horizontal,
crate::BooleanOperation::Union,
&options,
)
.unwrap();
assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
let wall = cylinder_side_id(&solid, Vec3::new(0.0, 0.0, 1.0));
let pushed = offset_ruled_face(&solid, wall, 1.0)
.unwrap_or_else(|error| panic!("push a wall against a crossing pipe: {error}"));
let issues = pushed.validate();
assert!(issues.is_empty(), "validate: {issues:?}");
let oracle = {
let grown =
make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 4.0, 10.0).unwrap();
crate::boolean_operation(
&grown,
&horizontal,
crate::BooleanOperation::Union,
&options,
)
.unwrap()
};
let got = solid_signed_volume(&pushed).unwrap().abs();
let want = solid_signed_volume(&oracle).unwrap().abs();
assert!(
(got - want).abs() <= 1e-4 * want,
"pushed volume {got} against the independently built {want} (relative {:.3e})",
(got - want).abs() / want
);
assert_eq!(
(
pushed.shells.iter().map(|s| s.faces.len()).sum::<usize>(),
pushed.edges.len(),
pushed.vertices.len()
),
(
oracle.shells.iter().map(|s| s.faces.len()).sum::<usize>(),
oracle.edges.len(),
oracle.vertices.len()
),
"the push must reproduce the rebuilt tee's topology"
);
let radii: Vec<f64> = pushed
.shells
.iter()
.flat_map(|shell| &shell.faces)
.filter_map(|face| match face.surface.analytic() {
Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. })
if (rho0 - rho1).abs() < 1e-9 =>
{
Some(*rho0)
}
_ => None,
})
.collect();
assert!(
radii.iter().any(|r| (r - 4.0).abs() < 1e-9),
"the pushed wall must be at radius 4, got {radii:?}"
);
assert_eq!(
radii.iter().filter(|r| (**r - 1.5).abs() < 1e-9).count(),
2,
"both stubs of the crossing pipe keep radius 1.5, got {radii:?}"
);
let mut window_samples = 0usize;
for edge in &pushed.edges {
for step in 0..=8 {
let point = edge
.curve
.evaluate(edge.t0 + (edge.t1 - edge.t0) * step as f64 / 8.0)
.unwrap();
let on_wall = ((point.x * point.x + point.y * point.y).sqrt() - 4.0).abs();
let on_pipe = ((point.y * point.y + (point.z - 5.0) * (point.z - 5.0)).sqrt()
- 1.5)
.abs();
if on_wall < 1e-3 && on_pipe < 1e-3 {
window_samples += 1;
assert!(
on_wall < 1e-5 && on_pipe < 1e-5,
"a window arc drifts off its carriers: {on_wall:.3e} / {on_pipe:.3e}"
);
}
}
}
assert!(
window_samples >= 18,
"the two window arcs must be present and sampled, got {window_samples}"
);
}
#[test]
fn a_window_straddling_the_pushed_seam_still_refuses() {
let axis = Vec3::new(0.0, 0.0, 1.0);
let vertical = make_cylinder_brep(Vec3::default(), axis, 3.0, 10.0).unwrap();
let crossing =
make_cylinder_brep(Vec3::new(0.0, -6.0, 5.0), Vec3::new(0.0, 1.0, 0.0), 1.5, 12.0)
.unwrap();
let options = crate::BooleanOptions {
merge_coplanar_faces: true,
..crate::BooleanOptions::default()
};
let solid = crate::boolean_operation(
&vertical,
&crossing,
crate::BooleanOperation::Union,
&options,
)
.unwrap();
assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
let wall = cylinder_side_id(&solid, axis);
let error = offset_ruled_face(&solid, wall, 1.0)
.expect_err("a seam-straddling window must refuse");
assert!(
error.contains("OPEN arc") || error.contains("seam"),
"unexpected refusal: {error}"
);
assert!(solid.validate().is_empty(), "the source is never mutated");
}
#[test]
fn push_cylinder_side_coaxial_inward_refuses_cleanly() {
let axis = Vec3::new(0.0, 0.0, 1.0);
let solid = coaxial_cone_cylinder_boss();
let cyl = cylinder_side_id(&solid, axis);
let result = offset_ruled_face(&solid, cyl, -1.0);
match result {
Err(_) => {}
Ok(good) => assert!(
good.validate().is_empty(),
"if inward is accepted it must still be valid: {:?}",
good.validate()
),
}
}
#[test]
fn push_cylinder_wall_against_a_spherical_dome_matches_the_rebuilt_solid() {
let ball_capped = |radius: f64| {
let rod =
make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), radius, 10.0)
.unwrap();
let ball =
crate::make_sphere_brep(Vec3::new(0.0, 0.0, 10.0), 4.0, Vec3::new(0.0, 0.0, 1.0))
.unwrap();
crate::boolean_operation(
&rod,
&ball,
crate::BooleanOperation::Union,
&crate::BooleanOptions {
merge_coplanar_faces: true,
..crate::BooleanOptions::default()
},
)
.unwrap()
};
let source = ball_capped(3.0);
let wall = cylinder_side_id(&source, Vec3::new(0.0, 0.0, 1.0));
let pushed = offset_ruled_face(&source, wall, 0.5)
.unwrap_or_else(|error| panic!("push a wall against a dome: {error}"));
let issues = pushed.validate();
assert!(issues.is_empty(), "validate: {issues:?}");
let oracle = ball_capped(3.5);
let got = solid_signed_volume(&pushed).unwrap().abs();
let want = solid_signed_volume(&oracle).unwrap().abs();
assert!(
(got - want).abs() <= 1e-9 * want,
"pushed volume {got} against the independently built {want}"
);
assert_eq!(
(
pushed.shells.iter().map(|s| s.faces.len()).sum::<usize>(),
pushed.edges.len(),
pushed.vertices.len()
),
(
oracle.shells.iter().map(|s| s.faces.len()).sum::<usize>(),
oracle.edges.len(),
oracle.vertices.len()
),
"the push must reproduce the rebuilt solid's topology, not merely its volume"
);
}
#[test]
fn push_cylinder_wall_against_a_toroidal_groove_moves_the_rim_onto_the_tube() {
let axis = Vec3::new(0.0, 0.0, 1.0);
let shaft = make_cylinder_brep(Vec3::default(), axis, 5.0, 10.0).unwrap();
let groove = crate::make_torus_brep(Vec3::new(0.0, 0.0, 5.0), axis, 5.0, 1.5).unwrap();
let grooved = crate::boolean_operation(
&shaft,
&groove,
crate::BooleanOperation::Subtract,
&crate::BooleanOptions {
merge_coplanar_faces: true,
..crate::BooleanOptions::default()
},
)
.unwrap();
assert!(grooved.validate().is_empty(), "fixture: {:?}", grooved.validate());
let before = solid_signed_volume(&grooved).unwrap().abs();
let band = cylinder_side_id(&grooved, axis);
let pushed = offset_ruled_face(&grooved, band, 0.5)
.unwrap_or_else(|error| panic!("push a wall band against a groove: {error}"));
let issues = pushed.validate();
assert!(issues.is_empty(), "validate: {issues:?}");
let rim_z = 5.0 + 2.0_f64.sqrt();
let after = solid_signed_volume(&pushed).unwrap().abs();
assert!(after > before, "an outward push must grow the solid");
let rim = pushed
.edges
.iter()
.find(|edge| {
let Ok(mid) = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)) else {
return false;
};
(mid.z - rim_z).abs() < 1e-9
&& ((mid.x * mid.x + mid.y * mid.y).sqrt() - 5.5).abs() < 1e-9
})
.expect("a rebuilt rim circle of radius 5.5 at z = 5 + √2");
for step in 0..=8 {
let point = rim
.curve
.evaluate(rim.t0 + (rim.t1 - rim.t0) * step as f64 / 8.0)
.unwrap();
assert!(
((point.x * point.x + point.y * point.y).sqrt() - 5.5).abs() < 1e-9
&& (point.z - rim_z).abs() < 1e-9,
"the rim must be the exact circle, not a fitted approximation: {point:?}"
);
}
let radii: Vec<f64> = pushed
.shells
.iter()
.flat_map(|shell| &shell.faces)
.filter_map(|face| match face.surface.analytic() {
Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. })
if (rho0 - rho1).abs() < 1e-9 =>
{
Some(*rho0)
}
_ => None,
})
.collect();
assert!(
radii.iter().any(|r| (r - 5.5).abs() < 1e-9)
&& radii.iter().any(|r| (r - 5.0).abs() < 1e-9),
"expected one band at 5.5 and one still at 5.0, got {radii:?}"
);
assert!(
pushed
.shells
.iter()
.flat_map(|shell| &shell.faces)
.any(|face| matches!(
face.surface.analytic(),
Some(AnalyticSurface::Torus { major_radius, minor_radius, .. })
if (major_radius - 5.0).abs() < 1e-12
&& (minor_radius - 1.5).abs() < 1e-12
)),
"the groove's torus must be untouched — only its trim moved"
);
}
#[test]
fn push_that_pulls_a_wall_out_of_its_curved_neighbour_refuses() {
let rod =
make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 10.0).unwrap();
let ball =
crate::make_sphere_brep(Vec3::new(0.0, 0.0, 10.0), 4.0, Vec3::new(0.0, 0.0, 1.0))
.unwrap();
let capped = crate::boolean_operation(
&rod,
&ball,
crate::BooleanOperation::Union,
&crate::BooleanOptions {
merge_coplanar_faces: true,
..crate::BooleanOptions::default()
},
)
.unwrap();
let wall = cylinder_side_id(&capped, Vec3::new(0.0, 0.0, 1.0));
let error = offset_ruled_face(&capped, wall, 1.5)
.expect_err("a wall pushed clear of its dome must refuse");
assert!(
error.contains("no longer meets") || error.contains("refus"),
"unexpected refusal: {error}"
);
assert!(capped.validate().is_empty(), "the source is never mutated");
}
}