use super::*;
use crate::{ExtendRefusal, KernelRefusal, KernelStage, OrRefuse};
pub fn offset_revolution_face(
solid: &BrepSolid,
face_id: u64,
distance: f64,
) -> Result<BrepSolid, KernelRefusal> {
if !distance.is_finite() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"distance",
"offset_revolution_face: distance must be finite",
));
}
let scale = solid_model_scale(solid);
let tolerance = (scale * 1e-7).max(1e-9);
let residual_tolerance = (scale * 1e-4).max(1e-6);
let fit_tolerance = crate::KernelTolerances::for_scale(scale, 1e-7).intersection_fit;
let (shell_index, face_index) = find_face(solid, face_id).ok_or_else(|| {
KernelRefusal::input(
KernelStage::Collect,
"face_id",
format!("offset_revolution_face: no face {face_id}"),
)
})?;
let face = &solid.shells[shell_index].faces[face_index];
if !matches!(face.surface.analytic(), Some(AnalyticSurface::Revolution { .. })) {
return Err(KernelRefusal::input(
KernelStage::Collect,
"not_revolution",
"offset_revolution_face: the pushed face is not a general revolution",
));
}
if let Some(result) = offset_partial_cylinder_face(
solid,
face_id,
distance,
tolerance,
residual_tolerance,
scale,
)? {
return Ok(result);
}
let trace = BoundaryTrace::open(solid, face_id, distance);
let outcome = offset_general_revolution_face(
solid,
face_id,
distance,
tolerance,
residual_tolerance,
fit_tolerance,
scale,
&trace,
);
trace.outcome(&outcome);
outcome
}
#[allow(clippy::too_many_arguments)]
fn offset_general_revolution_face(
solid: &BrepSolid,
face_id: u64,
distance: f64,
tolerance: f64,
residual_tolerance: f64,
fit_tolerance: f64,
scale: f64,
trace: &BoundaryTrace,
) -> Result<BrepSolid, KernelRefusal> {
let (shell_index, face_index) = find_face(solid, face_id).ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Collect,
"face_lookup",
format!("offset_revolution_face: no face {face_id}"),
)
})?;
let face = &solid.shells[shell_index].faces[face_index];
let Some(AnalyticSurface::Revolution { sweep, .. }) = face.surface.analytic() else {
return Err(KernelRefusal::internal(
KernelStage::Collect,
"carrier_kind",
"offset_revolution_face: the pushed face is not a general revolution",
));
};
let source_structure = crate::revolution_structure(&face.surface).ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Classify,
"revolution_structure",
"offset_revolution_face: source carrier lost its revolution structure",
)
})?;
let [u0, u1] = face.surface.domain_u().or_refuse(KernelStage::Classify, "domain_u")?;
let [v0, v1] = face.surface.domain_v().or_refuse(KernelStage::Classify, "domain_v")?;
if (v0.abs() + (v1 - 1.0).abs()) > 1e-9 {
return Err(KernelRefusal::unsupported(
KernelStage::Classify,
"generatrix_domain",
"offset_revolution_face: a non-normalized generatrix domain is deferred",
));
}
let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
for shell in &solid.shells {
for candidate in &shell.faces {
for loop_record in &candidate.loops {
for coedge in &loop_record.coedges {
faces_of_edge
.entry(coedge.edge_id)
.or_default()
.push(candidate.id);
}
}
}
}
let edge_by_id: HashMap<u64, &EdgeRecord> =
solid.edges.iter().map(|edge| (edge.id, edge)).collect();
let u_probe = u0 + (u1 - u0) * 0.137;
let span = solve_rim_span(
solid,
face,
face_id,
&faces_of_edge,
&source_structure.frame,
u_probe,
distance,
residual_tolerance,
)?;
let source_surface = span.surface.as_ref().unwrap_or(&face.surface);
let offsets = OffsetEvaluator::new(
"push_revolution",
source_surface,
OffsetNormal::Face {
same_sense: face.same_sense,
},
);
let [s0, s1] = span.new;
let source_parameter = |w: f64| s0 + (s1 - s0) * w;
let mut meridian_points = Vec::with_capacity(33);
let mut meridian_parameters = Vec::with_capacity(33);
for index in 0..=32 {
let parameter = index as f64 / 32.0;
let v = source_parameter(v0 + (v1 - v0) * parameter);
let target_point = offsets.at(u_probe, v, distance).or_refuse(KernelStage::Refine, "offset_at")?.point;
let relative = target_point.sub(source_structure.frame.origin);
let axial = relative.dot(source_structure.frame.axis);
let radial = relative
.sub(source_structure.frame.axis.scale(axial))
.length();
meridian_points.push(
source_structure
.frame
.origin
.add(source_structure.frame.x_axis.scale(radial))
.add(source_structure.frame.axis.scale(axial)),
);
meridian_parameters.push(parameter);
}
let offset_generatrix = crate::interpolate_curve(&meridian_points, 3, &meridian_parameters).or_refuse(KernelStage::Refine, "interpolate_curve")?;
let offset = crate::make_revolution(
source_structure.frame.origin,
source_structure.frame.axis,
&offset_generatrix,
*sweep,
).or_refuse(KernelStage::Refine, "make_revolution")?;
let structure = crate::revolution_structure(&offset).ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Refine,
"offset_structure",
"offset_revolution_face: offset carrier lost its revolution structure — refusing",
)
})?;
if !matches!(offset.analytic(), Some(AnalyticSurface::Revolution { .. })) {
return Err(KernelRefusal::internal(
KernelStage::Refine,
"offset_kind",
"offset_revolution_face: offset carrier changed analytic kind — refusing",
));
}
let target = distance.abs();
let (mut worst_distance, mut worst_normal) = (0.0f64, 0.0f64);
for iu in 0..13 {
for iv in 0..13 {
let u = u0 + (u1 - u0) * (iu as f64 + 0.37) / 13.0;
let w = v0 + (v1 - v0) * (iv as f64 + 0.41) / 13.0;
let v = source_parameter(w);
let source_point = source_surface.evaluate(u, v).or_refuse(KernelStage::Refine, "evaluate")?;
let offset_point = offset.evaluate(u, w).or_refuse(KernelStage::Refine, "evaluate")?;
let delta = offset_point.sub(source_point);
let normal = offsets
.normal(u, v)
.map_err(|error| {
KernelRefusal::internal(
KernelStage::Refine,
"sample_normal",
format!("offset_revolution_face: sample normal: {error}"),
)
})?;
let distance_error = (delta.length() - target).abs();
let normal_error = if delta.length() > tolerance {
1.0 - delta
.normalized()
.map_err(|error| {
KernelRefusal::internal(
KernelStage::Refine,
"sample_delta",
format!("offset_revolution_face: sample delta: {error}"),
)
})?
.dot(normal)
.abs()
} else {
0.0
};
if distance_error > residual_tolerance || normal_error > 5e-4 {
return Err(KernelRefusal::non_convergence(
KernelStage::Refine,
"offset_fit",
format!(
"offset_revolution_face: offset fit exceeds tolerance \
(distance error {distance_error:.3e}, normal error {normal_error:.3e})"
),
));
}
worst_distance = worst_distance.max(distance_error);
worst_normal = worst_normal.max(normal_error);
}
}
trace.write(format!(
"carrier\trims_old={:?}\trims_new={:?}\textended={}\t\
fit_distance_error={worst_distance:.3e}\tfit_normal_error={worst_normal:.3e}",
span.old,
span.new,
span.surface.is_some()
));
let [g0, g1] = structure.generatrix.domain().or_refuse(KernelStage::Refine, "domain")?;
let mut rim_candidates = Vec::new();
for parameter in [g0, g1] {
let point = structure.generatrix.evaluate(parameter).or_refuse(KernelStage::Refine, "evaluate")?;
let relative = point.sub(structure.frame.origin);
let axial = relative.dot(structure.frame.axis);
let radial = relative.sub(structure.frame.axis.scale(axial));
let radius = radial.length();
if radius <= tolerance {
return Err(KernelRefusal::input(
KernelStage::Refine,
"rim_collapse",
format!(
"offset_revolution_face: an offset rim collapses onto the axis \
(point {point:?}, frame {:?}) — refusing",
structure.frame
),
));
}
rim_candidates.push(crate::make_arc(
structure
.frame
.origin
.add(structure.frame.axis.scale(axial)),
structure.frame.x_axis,
structure.frame.y_axis,
radius,
0.0,
structure.sweep,
).or_refuse(KernelStage::Refine, "make_arc")?);
}
let uv_tolerance = 1e-7;
let pcurves = face
.loops
.iter()
.map(|loop_record| {
loop_record
.coedges
.iter()
.map(|coedge| match iso_kind(&coedge.pcurve, uv_tolerance)? {
Some(_) => span.carry_pcurve(&coedge.pcurve),
None => span.transfer_pcurve(&coedge.pcurve),
})
.collect::<Result<Vec<_>, KernelRefusal>>()
})
.collect::<Result<Vec<_>, KernelRefusal>>()?;
let floor = crate::pcurve::PCURVE_REFINEMENT_TOLERANCE;
let mut new_curves: HashMap<u64, NurbsCurve> = HashMap::default();
let mut native_iso_pcurves: HashMap<u64, NurbsCurve> = HashMap::default();
let mut new_vertices: HashMap<u64, Vec3> = HashMap::default();
let mut planar_neighbours: HashSet<u64> = HashSet::default();
for (loop_record, loop_pcurves) in face.loops.iter().zip(&pcurves) {
for (coedge, pcurve) in loop_record.coedges.iter().zip(loop_pcurves) {
if new_curves.contains_key(&coedge.edge_id) {
continue;
}
let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Classify,
"missing_edge",
format!("offset_revolution_face: missing edge {}", coedge.edge_id),
)
})?;
let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
let neighbour = incident.iter().find(|candidate| **candidate != face_id);
let mut neighbour_plane = None;
if let Some(neighbour) = neighbour {
let (ns, nf) = find_face(solid, *neighbour).ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Classify,
"missing_neighbour",
format!("offset_revolution_face: missing neighbour {neighbour}"),
)
})?;
match plane_of_surface(
&solid.shells[ns].faces[nf].surface,
residual_tolerance,
"offset_revolution_face",
) {
Err(_) => {
return Err(KernelRefusal::unsupported(
KernelStage::Classify,
"curved_neighbour",
"offset_revolution_face: only planar neighbours are supported \
for a general revolution — refusing",
));
}
Ok(plane) => neighbour_plane = Some(plane),
}
planar_neighbours.insert(*neighbour);
}
let mut general_image = false;
let mut meridian = false;
let mut curve = match iso_kind(&coedge.pcurve, uv_tolerance)? {
Some(IsoKind::ConstantV) => {
let [q0, _] = pcurve.domain().or_refuse(KernelStage::Refine, "domain")?;
let w = pcurve.evaluate(q0).or_refuse(KernelStage::Refine, "evaluate")?.y;
if w.abs() <= uv_tolerance {
rim_candidates[0].clone()
} else if (w - 1.0).abs() <= uv_tolerance {
rim_candidates[1].clone()
} else {
return Err(KernelRefusal::unsupported(
KernelStage::Classify,
"interior_rim",
format!(
"offset_revolution_face: constant-v boundary edge {} sits at v = {w} \
between the face's two rims — refusing",
edge.id
),
));
}
}
Some(IsoKind::ConstantU(u)) => {
meridian = true;
offset.iso_curve_u(u).or_refuse(KernelStage::Refine, "iso_curve_u")?
}
None => {
general_image = true;
let image = crate::image_curve(
&offset,
pcurve,
fit_tolerance,
"offset_revolution_face",
).or_refuse(KernelStage::Refine, "image_curve")?;
if coedge.forward {
image.curve
} else {
image.curve.reversed().or_refuse(KernelStage::Refine, "reversed")?
}
}
};
let kind = if general_image {
"general"
} else if meridian {
"meridian"
} else {
"rim"
};
trace.edge(kind, edge, neighbour, neighbour_plane.as_ref(), &curve);
if let Some(plane) = &neighbour_plane {
let [g0, g1] = curve.domain().or_refuse(KernelStage::Refine, "domain")?;
let mut worst = 0.0f64;
for index in 0..=32 {
let t = g0 + (g1 - g0) * index as f64 / 32.0;
let point = curve.evaluate(t).or_refuse(KernelStage::Refine, "evaluate")?;
worst = worst.max(point.sub(plane.origin).dot(plane.normal).abs());
}
if !(general_image || meridian) && !(worst <= floor) {
return Err(KernelRefusal::unsupported(
KernelStage::Refine,
"rim_off_cap",
format!(
"offset_revolution_face: the rebuilt rim of boundary edge {} leaves the \
plane of neighbour face {} by {worst:.3e} (pcurve floor {floor:.0e}) — \
refusing",
edge.id,
neighbour.copied().unwrap_or_default()
),
));
}
if (general_image || meridian) && worst > residual_tolerance {
let lane = if meridian {
"meridian at its old azimuth"
} else {
"general image"
};
return Err(KernelRefusal::unsupported(
KernelStage::Refine,
"transfer_off_neighbour",
format!(
"offset_revolution_face: the {lane} of boundary edge {} \
leaves its fixed planar neighbour by {worst:.3e} (limit \
{residual_tolerance:.3e}) — re-intersecting the offset carrier \
with a fixed neighbour is a separate capability; refusing",
edge.id
),
));
}
}
let mut reversed_curve = false;
if !general_image {
let [c0, c1] = curve.domain().or_refuse(KernelStage::Refine, "domain")?;
let old_start = edge.curve.evaluate(edge.t0).or_refuse(KernelStage::Refine, "evaluate")?;
if curve.evaluate(c0).or_refuse(KernelStage::Refine, "evaluate")?.sub(old_start).length()
> curve.evaluate(c1).or_refuse(KernelStage::Refine, "evaluate")?.sub(old_start).length()
{
curve = curve.reversed().or_refuse(KernelStage::Refine, "reversed")?;
reversed_curve = true;
}
}
let native = match iso_kind(&coedge.pcurve, uv_tolerance)? {
Some(IsoKind::ConstantU(u)) => {
let [v0, v1] = offset.domain_v().or_refuse(KernelStage::Refine, "domain_v")?;
Some(crate::sweep_topology::parameter_line(u, v0, u, v1)
.or_refuse(KernelStage::Refine, "parameter_line")?)
}
Some(IsoKind::ConstantV) => {
let [q0, _] = pcurve.domain().or_refuse(KernelStage::Refine, "domain")?;
let w = pcurve.evaluate(q0).or_refuse(KernelStage::Refine, "evaluate")?.y;
let [v0, v1] = offset.domain_v().or_refuse(KernelStage::Refine, "domain_v")?;
let v = if w.abs() <= uv_tolerance { v0 } else { v1 };
let [u0, u1] = offset.domain_u().or_refuse(KernelStage::Refine, "domain_u")?;
Some(crate::sweep_topology::parameter_line(u0, v, u1, v)
.or_refuse(KernelStage::Refine, "parameter_line")?)
}
None => None,
};
if let Some(native) = native {
native_iso_pcurves.insert(edge.id, if reversed_curve {
native.reversed().or_refuse(KernelStage::Refine, "reversed")?
} else { native });
}
let [d0, d1] = curve.domain().or_refuse(KernelStage::Refine, "domain")?;
let ends = [
(edge.start_vertex_id, curve.evaluate(d0).or_refuse(KernelStage::Refine, "evaluate")?),
(edge.end_vertex_id, curve.evaluate(d1).or_refuse(KernelStage::Refine, "evaluate")?),
];
for (vertex_id, point) in ends {
if let Some(previous) = new_vertices.insert(vertex_id, point) {
let gap = previous.sub(point).length();
if !(gap <= floor) {
return Err(KernelRefusal::internal(
KernelStage::Fragment,
"vertex_agreement",
format!(
"offset_revolution_face: boundary edge {} rebuilds vertex {vertex_id} \
{gap:.3e} from where another boundary edge put it (pcurve floor \
{floor:.0e}) — refusing",
edge.id
),
));
}
}
}
new_curves.insert(edge.id, curve);
}
}
let mut result = solid.clone();
let pushed = &mut result.shells[shell_index].faces[face_index];
for (loop_record, loop_pcurves) in pushed.loops.iter_mut().zip(pcurves) {
for (coedge, pcurve) in loop_record.coedges.iter_mut().zip(loop_pcurves) {
coedge.pcurve = if let Some(native) = native_iso_pcurves.get(&coedge.edge_id) {
if coedge.forward { native.clone() }
else { native.reversed().or_refuse(KernelStage::Refine, "reversed")? }
} else { pcurve };
}
}
pushed.surface = offset;
retrim_planar_neighbours(
solid,
result,
&edge_by_id,
&new_curves,
&new_vertices,
planar_neighbours,
residual_tolerance,
scale,
)
}
struct RimSpan {
old: [f64; 2],
new: [f64; 2],
surface: Option<NurbsSurface>,
}
impl RimSpan {
fn carry_pcurve(&self, pcurve: &NurbsCurve) -> Result<NurbsCurve, KernelRefusal> {
Self::map_v(pcurve, self.old)
}
fn transfer_pcurve(&self, pcurve: &NurbsCurve) -> Result<NurbsCurve, KernelRefusal> {
Self::map_v(pcurve, self.new)
}
fn map_v(pcurve: &NurbsCurve, [a0, a1]: [f64; 2]) -> Result<NurbsCurve, KernelRefusal> {
if [a0, a1] == [0.0, 1.0] {
return Ok(pcurve.clone());
}
let mut carried = pcurve.clone();
for control in &mut carried.control_points {
let point = control.point().or_refuse(KernelStage::Classify, "point")?;
*control = crate::Vec4::from_point(
Vec3::new(point.x, (point.y - a0) / (a1 - a0), point.z),
control.w,
);
}
Ok(carried)
}
}
#[allow(clippy::too_many_arguments)]
fn solve_rim_span(
solid: &BrepSolid,
face: &FaceRecord,
face_id: u64,
faces_of_edge: &HashMap<u64, Vec<u64>>,
frame: &crate::RevolutionFrame,
u: f64,
distance: f64,
residual_tolerance: f64,
) -> Result<RimSpan, KernelRefusal> {
let op = "offset_revolution_face";
let uv_tolerance = 1e-7;
let mut rims: Vec<(f64, f64, u64)> = Vec::new();
let (mut v_min, mut v_max) = (f64::INFINITY, f64::NEG_INFINITY);
for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
let [q0, q1] = coedge.pcurve.domain().or_refuse(KernelStage::Classify, "domain")?;
let uv0 = coedge.pcurve.evaluate(q0).or_refuse(KernelStage::Classify, "evaluate")?;
let uv1 = coedge.pcurve.evaluate(q1).or_refuse(KernelStage::Classify, "evaluate")?;
let uvm = coedge.pcurve.evaluate(0.5 * (q0 + q1)).or_refuse(KernelStage::Classify, "evaluate")?;
for uv in [uv0, uv1, uvm] {
v_min = v_min.min(uv.y);
v_max = v_max.max(uv.y);
}
if !matches!(iso_kind(&coedge.pcurve, uv_tolerance)?, Some(IsoKind::ConstantV)) {
continue;
}
let incident = faces_of_edge.get(&coedge.edge_id).cloned().unwrap_or_default();
let Some(neighbour) = incident.iter().find(|candidate| **candidate != face_id) else {
continue;
};
let (ns, nf) = find_face(solid, *neighbour).ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Classify,
"rim_neighbour",
format!("{op}: missing neighbour {neighbour}"),
)
})?;
let Ok(plane) =
plane_of_surface(&solid.shells[ns].faces[nf].surface, residual_tolerance, op)
else {
continue;
};
let station =
plane.origin.sub(frame.origin).dot(plane.normal) / frame.axis.dot(plane.normal);
rims.push((uv0.y, station, *neighbour));
}
if rims.is_empty() {
return Ok(RimSpan {
old: [0.0, 1.0],
new: [0.0, 1.0],
surface: None,
});
}
let mut ends: [Option<(f64, f64, u64)>; 2] = [None, None];
for rim in &rims {
let slot = if (rim.0 - v_min).abs() <= (rim.0 - v_max).abs() { 0 } else { 1 };
match ends[slot] {
None => ends[slot] = Some(*rim),
Some(first) if (first.0 - rim.0).abs() > uv_tolerance => {
return Err(KernelRefusal::unsupported(
KernelStage::Classify,
"two_rims_one_side",
format!(
"{op}: face {face_id} has constant-v boundaries at v = {} and v = {} on one \
side of its trim — refusing",
first.0, rim.0
),
));
}
Some(_) => {}
}
}
let same_sense = face.same_sense;
let mut surface: Option<NurbsSurface> = None;
let mut old = [0.0, 1.0];
let mut new = [0.0, 1.0];
for (slot, end) in ends.iter().enumerate() {
let Some((start, station, neighbour)) = *end else {
continue;
};
old[slot] = start;
let axial_miss = |carrier: &NurbsSurface, s: f64| -> Result<f64, KernelRefusal> {
let convention = OffsetNormal::Face { same_sense };
let point = OffsetEvaluator::new("push_revolution", carrier, convention)
.at(u, s, distance).or_refuse(KernelStage::Refine, "offset_at")?
.point;
Ok(point.sub(frame.origin).dot(frame.axis) - station)
};
let base = surface.clone().unwrap_or_else(|| face.surface.clone());
let drift = axial_miss(&base, start)?;
if drift == 0.0 {
new[slot] = start;
continue;
}
let rate = face.surface.derivatives(u, start, 1).or_refuse(KernelStage::Refine, "derivatives")?[0][1].dot(frame.axis);
let step = -drift / rate;
if !step.is_finite() {
return Err(KernelRefusal::ill_posed(
KernelStage::Refine,
"tangent_rim",
format!(
"{op}: the generatrix of face {face_id} meets the plane of neighbour face \
{neighbour} tangentially at its rim, which the push leaves {drift:.3e} off \
that plane — refusing"
),
));
}
let mut bracket = None;
let mut reach = 2.0 * step;
for _ in 0..4 {
if !reach.is_finite() {
break;
}
let far = start + reach;
let carrier = extend_to(&base, far)
.map_err(|refusal| {
let message = {
let refusal = refusal.describe();
format!(
"{op}: the rim of face {face_id} against neighbour face {neighbour} lies \
{drift:.3e} off that cap, and the carrier cannot be extended to reach \
it: {refusal} — refusing"
)
};
match refusal {
ExtendRefusal::Failed(_) => KernelRefusal::internal(
KernelStage::Refine,
"carrier_extension_failed",
message,
),
_ => KernelRefusal::unsupported(
KernelStage::Refine,
"carrier_extension",
message,
),
}
})?;
let far_miss = axial_miss(carrier.as_ref().unwrap_or(&base), far)?;
if far_miss == 0.0 || far_miss.signum() != drift.signum() {
bracket = Some((far, carrier));
break;
}
reach *= 2.0;
}
let Some((far, carrier)) = bracket else {
return Err(KernelRefusal::non_convergence(
KernelStage::Refine,
"rim_bracket",
format!(
"{op}: the offset meridian of face {face_id} does not reach the plane of \
neighbour face {neighbour} near its rim, which the push leaves {drift:.3e} off \
that plane — refusing"
),
));
};
let carrier_ref = carrier.as_ref().unwrap_or(&base);
let (mut near, mut far) = (start, far);
for _ in 0..200 {
let middle = 0.5 * (near + far);
if middle == near || middle == far {
break;
}
if axial_miss(carrier_ref, middle)?.signum() == drift.signum() {
near = middle;
} else {
far = middle;
}
}
new[slot] = 0.5 * (near + far);
if carrier.is_some() {
surface = carrier;
}
}
if !(new[1] > new[0]) {
return Err(KernelRefusal::input(
KernelStage::Refine,
"rims_crossed",
format!(
"{op}: pushing face {face_id} by {distance} crosses its two rims over (source \
parameters {} and {}) — refusing",
new[0], new[1]
),
));
}
Ok(RimSpan { old, new, surface })
}
enum IsoKind {
ConstantV,
ConstantU(f64),
}
fn iso_kind(pcurve: &NurbsCurve, uv_tolerance: f64) -> Result<Option<IsoKind>, KernelRefusal> {
let [q0, q1] = pcurve.domain().or_refuse(KernelStage::Classify, "domain")?;
let uv0 = pcurve.evaluate(q0).or_refuse(KernelStage::Classify, "evaluate")?;
let uv1 = pcurve.evaluate(q1).or_refuse(KernelStage::Classify, "evaluate")?;
let uvm = pcurve.evaluate(0.5 * (q0 + q1)).or_refuse(KernelStage::Classify, "evaluate")?;
Ok(
if (uv0.y - uv1.y).abs() <= uv_tolerance && (uv0.y - uvm.y).abs() <= uv_tolerance {
Some(IsoKind::ConstantV)
} else if (uv0.x - uv1.x).abs() <= uv_tolerance && (uv0.x - uvm.x).abs() <= uv_tolerance {
Some(IsoKind::ConstantU(uv0.x))
} else {
None
},
)
}
fn extend_to(surface: &NurbsSurface, parameter: f64) -> Result<Option<NurbsSurface>, ExtendRefusal> {
let [v0, v1] = surface.domain_v().map_err(ExtendRefusal::Failed)?;
let (side, delta) = if parameter < v0 {
(crate::SurfaceSide::VMin, v0 - parameter)
} else if parameter > v1 {
(crate::SurfaceSide::VMax, parameter - v1)
} else {
return Ok(None);
};
surface
.extend_natural(side, delta.max(2.0 * crate::curve::KNOT_IDENTITY_TOL))
.map(Some)
}
struct Meridian<'a> {
edge: &'a EdgeRecord,
neighbour: u64,
azimuth: f64,
plane_origin: Vec3,
plane_normal: Vec3,
}
fn offset_partial_cylinder_face(
solid: &BrepSolid,
face_id: u64,
distance: f64,
tolerance: f64,
residual_tolerance: f64,
scale: f64,
) -> Result<Option<BrepSolid>, KernelRefusal> {
use std::f64::consts::TAU;
let op = "offset_revolution_face";
let (shell_index, face_index) = find_face(solid, face_id).ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Collect,
"partial_face_lookup",
format!("{op}: no face {face_id}"),
)
})?;
let face = &solid.shells[shell_index].faces[face_index];
let Some(AnalyticSurface::Revolution {
frame, generatrix, ..
}) = face.surface.analytic()
else {
return Ok(None);
};
if generatrix.degree != 1 || generatrix.control_points.len() != 2 {
return Ok(None);
}
let (origin, axis, x_axis, y_axis) = (frame.origin, frame.axis, frame.x_axis, frame.y_axis);
let cylindrical = |point: Vec3| {
let relative = point.sub(origin);
let axial = relative.dot(axis);
(axial, relative.sub(axis.scale(axial)))
};
let azimuth_of = |radial: Vec3| radial.dot(y_axis).atan2(radial.dot(x_axis));
let (generatrix_z0, generatrix_radial0) = cylindrical(generatrix.control_points[0].point().or_refuse(KernelStage::Classify, "point")?);
let (generatrix_z1, generatrix_radial1) = cylindrical(generatrix.control_points[1].point().or_refuse(KernelStage::Classify, "point")?);
if generatrix_radial0.sub(generatrix_radial1).length() > residual_tolerance {
return Ok(None); }
let radius = generatrix_radial0.length();
let span = (generatrix_z1 - generatrix_z0).abs();
if face.loops.len() != 1 || face.loops[0].coedges.len() != 4 {
return Ok(None);
}
let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
for shell in &solid.shells {
for candidate in &shell.faces {
for loop_record in &candidate.loops {
for coedge in &loop_record.coedges {
faces_of_edge
.entry(coedge.edge_id)
.or_default()
.push(candidate.id);
}
}
}
}
let edge_by_id: HashMap<u64, &EdgeRecord> =
solid.edges.iter().map(|edge| (edge.id, edge)).collect();
let mut meridians: Vec<Meridian> = Vec::new();
let mut rims: Vec<(&EdgeRecord, f64)> = Vec::new();
let mut planar_neighbours: HashSet<u64> = HashSet::default();
for coedge in &face.loops[0].coedges {
let edge = *edge_by_id
.get(&coedge.edge_id)
.ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Classify,
"partial_missing_edge",
format!("{op}: missing edge {}", coedge.edge_id),
)
})?;
let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
if incident.len() != 2 || incident.iter().filter(|f| **f == face_id).count() != 1 {
return Ok(None);
}
let neighbour = *incident.iter().find(|f| **f != face_id).expect("two incidences");
let (ns, nf) = find_face(solid, neighbour).ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Classify,
"partial_missing_neighbour",
format!("{op}: missing neighbour {neighbour}"),
)
})?;
let Ok(plane) =
plane_of_surface(&solid.shells[ns].faces[nf].surface, residual_tolerance, op)
else {
return Ok(None);
};
let (z_start, radial_start) = cylindrical(edge.curve.evaluate(edge.t0).or_refuse(KernelStage::Classify, "evaluate")?);
let (z_mid, radial_mid) = cylindrical(edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).or_refuse(KernelStage::Classify, "evaluate")?);
let (z_end, radial_end) = cylindrical(edge.curve.evaluate(edge.t1).or_refuse(KernelStage::Classify, "evaluate")?);
if radial_start.sub(radial_mid).length() <= residual_tolerance
&& radial_start.sub(radial_end).length() <= residual_tolerance
{
if plane.normal.dot(axis).abs() * span > residual_tolerance {
return Ok(None); }
meridians.push(Meridian {
edge,
neighbour,
azimuth: azimuth_of(radial_start),
plane_origin: plane.origin,
plane_normal: plane.normal,
});
} else if (z_start - z_mid).abs() <= residual_tolerance
&& (z_start - z_end).abs() <= residual_tolerance
{
if plane.normal.cross(axis).length() * radius > residual_tolerance {
return Ok(None); }
rims.push((edge, z_start));
} else {
return Ok(None);
}
planar_neighbours.insert(neighbour);
}
if meridians.len() != 2 || rims.len() != 2 || (rims[0].1 - rims[1].1).abs() <= residual_tolerance
{
return Ok(None);
}
let mut stations: HashMap<u64, f64> = HashMap::default();
for meridian in &meridians {
for vertex_id in [meridian.edge.start_vertex_id, meridian.edge.end_vertex_id] {
let vertex = solid
.vertices
.iter()
.find(|vertex| vertex.id == vertex_id)
.ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Classify,
"partial_missing_vertex",
format!("{op}: missing vertex {vertex_id}"),
)
})?;
let (axial, _) = cylindrical(vertex.point);
let Some(station) = rims
.iter()
.map(|(_, station)| *station)
.find(|station| (axial - station).abs() <= residual_tolerance)
else {
return Ok(None);
};
stations.insert(vertex_id, station);
}
}
let boundary: HashSet<u64> =
face.loops[0].coedges.iter().map(|coedge| coedge.edge_id).collect();
for edge in &solid.edges {
let at_corner = stations.contains_key(&edge.start_vertex_id)
|| stations.contains_key(&edge.end_vertex_id);
if !at_corner {
continue;
}
if boundary.contains(&edge.id) {
if !(stations.contains_key(&edge.start_vertex_id)
&& stations.contains_key(&edge.end_vertex_id))
{
return Ok(None);
}
continue;
}
let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
if edge.degenerate
|| edge.curve.degree != 1
|| !incident.iter().all(|f| planar_neighbours.contains(f))
{
return Ok(None);
}
}
let [u0, u1] = face.surface.domain_u().or_refuse(KernelStage::Refine, "domain_u")?;
let [v0, v1] = face.surface.domain_v().or_refuse(KernelStage::Refine, "domain_v")?;
let (u_mid, v_mid) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
let offsets = OffsetEvaluator::new(
"push_revolution",
&face.surface,
OffsetNormal::Face {
same_sense: face.same_sense,
},
);
let sample = offsets.at(u_mid, v_mid, distance).or_refuse(KernelStage::Refine, "offset_at")?;
let outward = cylindrical(sample.source).1.normalized().or_refuse(KernelStage::Refine, "normalized")?;
let new_radius = cylindrical(sample.point).1.dot(outward);
if new_radius <= tolerance {
return Err(KernelRefusal::input(
KernelStage::Refine,
"radius_collapse",
format!(
"{op}: pushing partial cylinder face {face_id} by {distance} collapses its radius \
{radius:.6} onto the axis — refusing"
),
));
}
let ccw = |from: f64, to: f64| (to - from).rem_euclid(TAU);
let rim_mid = rims[0].0.curve.evaluate(0.5 * (rims[0].0.t0 + rims[0].0.t1)).or_refuse(KernelStage::Refine, "evaluate")?;
let rim_azimuth = azimuth_of(cylindrical(rim_mid).1);
let (start, end) = if ccw(meridians[0].azimuth, rim_azimuth)
< ccw(meridians[0].azimuth, meridians[1].azimuth)
{
(&meridians[0], &meridians[1])
} else {
(&meridians[1], &meridians[0])
};
let solve = |meridian: &Meridian, sense: f64| -> Result<(f64, [f64; 2]), KernelRefusal> {
let normal = meridian.plane_normal;
let (nx, ny) = (normal.dot(x_axis), normal.dot(y_axis));
let reach = nx.hypot(ny);
let phi = ny.atan2(nx);
let offset = meridian.plane_origin.sub(origin).dot(normal);
let lean = (phi - meridian.azimuth).sin();
let side = if radius * reach * lean.abs() > residual_tolerance {
sense * lean
} else {
-offset
};
let shortfall = offset.abs() - new_radius * reach;
if shortfall > tolerance {
return Err(KernelRefusal::unsupported(
KernelStage::Refine,
"partial_separated",
format!(
"{op}: pushing partial cylinder face {face_id} by {distance} separates it from \
neighbour face {} — the radius {new_radius:.6} carrier stops {shortfall:.3e} \
short of that plane (refusing)",
meridian.neighbour
),
));
}
let half = (offset / (new_radius * reach)).clamp(-1.0, 1.0).acos();
let azimuth = if sense * side > 0.0 { phi - half } else { phi + half };
Ok((azimuth, [phi - half, phi + half]))
};
let (start_azimuth, start_crossings) = solve(start, 1.0)?;
let (end_azimuth, end_crossings) = solve(end, -1.0)?;
let new_sweep = ccw(start_azimuth, end_azimuth);
let angular_tolerance = tolerance / new_radius;
if new_sweep <= angular_tolerance || new_sweep >= TAU - angular_tolerance {
return Err(KernelRefusal::input(
KernelStage::Refine,
"arc_closed",
format!(
"{op}: pushing partial cylinder face {face_id} by {distance} closes its arc between \
neighbour faces {} and {} — refusing",
start.neighbour, end.neighbour
),
));
}
let crossing_margin = residual_tolerance / new_radius;
for (meridian, crossings) in [(start, start_crossings), (end, end_crossings)] {
for crossing in crossings {
let along = ccw(start_azimuth, crossing);
if along > crossing_margin && along < new_sweep - crossing_margin {
return Err(KernelRefusal::unsupported(
KernelStage::Refine,
"arc_through_neighbour",
format!(
"{op}: pushing partial cylinder face {face_id} by {distance} carries its arc \
through the plane of neighbour face {} — refusing",
meridian.neighbour
),
));
}
}
}
let direction = |azimuth: f64| x_axis.scale(azimuth.cos()).add(y_axis.scale(azimuth.sin()));
let mut new_vertices: HashMap<u64, Vec3> = HashMap::default();
let mut new_curves: HashMap<u64, NurbsCurve> = HashMap::default();
for (meridian, azimuth) in [(start, start_azimuth), (end, end_azimuth)] {
let edge = meridian.edge;
let mut ends = [Vec3::default(); 2];
for (slot, vertex_id) in [edge.start_vertex_id, edge.end_vertex_id].into_iter().enumerate()
{
ends[slot] = origin
.add(axis.scale(stations[&vertex_id]))
.add(direction(azimuth).scale(new_radius));
new_vertices.insert(vertex_id, ends[slot]);
}
new_curves.insert(edge.id, make_line(ends[0], ends[1]).or_refuse(KernelStage::Fragment, "make_line")?);
}
let new_x = direction(start_azimuth);
let new_y = axis.cross(new_x);
for (edge, station) in &rims {
let (head, tail) = (new_vertices[&edge.start_vertex_id], new_vertices[&edge.end_vertex_id]);
let mut arc = crate::make_arc(
origin.add(axis.scale(*station)),
new_x,
new_y,
new_radius,
0.0,
new_sweep,
).or_refuse(KernelStage::Fragment, "make_arc")?;
let [a0, _] = arc.domain().or_refuse(KernelStage::Fragment, "domain")?;
let first = arc.evaluate(a0).or_refuse(KernelStage::Fragment, "evaluate")?;
if first.sub(head).length() > first.sub(tail).length() {
arc = arc.reversed().or_refuse(KernelStage::Fragment, "reversed")?;
}
new_curves.insert(edge.id, arc);
}
for edge in &solid.edges {
if new_curves.contains_key(&edge.id)
|| !(new_vertices.contains_key(&edge.start_vertex_id)
|| new_vertices.contains_key(&edge.end_vertex_id))
{
continue;
}
let position = |vertex_id: u64| {
solid
.vertices
.iter()
.find(|vertex| vertex.id == vertex_id)
.map(|vertex| vertex.point)
.ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Fragment,
"corner_vertex_pos",
format!("{op}: missing vertex {vertex_id}"),
)
})
};
let old_run = position(edge.end_vertex_id)?.sub(position(edge.start_vertex_id)?);
let new_start = match new_vertices.get(&edge.start_vertex_id) {
Some(point) => *point,
None => position(edge.start_vertex_id)?,
};
let new_end = match new_vertices.get(&edge.end_vertex_id) {
Some(point) => *point,
None => position(edge.end_vertex_id)?,
};
let new_run = new_end.sub(new_start);
if new_run.length() <= tolerance || new_run.dot(old_run) <= 0.0 {
return Err(KernelRefusal::unsupported(
KernelStage::Fragment,
"corner_past_edge",
format!(
"{op}: pushing partial cylinder face {face_id} by {distance} runs a corner past \
the far end of edge {} — refusing",
edge.id
),
));
}
}
let carrier = make_revolution(
origin,
axis,
&make_line(
origin
.add(axis.scale(generatrix_z0))
.add(new_x.scale(new_radius)),
origin
.add(axis.scale(generatrix_z1))
.add(new_x.scale(new_radius)),
).or_refuse(KernelStage::Fragment, "make_line")?,
new_sweep,
).or_refuse(KernelStage::Fragment, "make_revolution")?;
let [c0, c1] = carrier.domain_u().or_refuse(KernelStage::Fragment, "domain_u")?;
let [w0, w1] = carrier.domain_v().or_refuse(KernelStage::Fragment, "domain_v")?;
let old_normal = face.surface.normal(u_mid, v_mid).or_refuse(KernelStage::Fragment, "normal")?;
let old_point = face.surface.evaluate(u_mid, v_mid).or_refuse(KernelStage::Fragment, "evaluate")?;
let new_normal = carrier.normal(0.5 * (c0 + c1), 0.5 * (w0 + w1)).or_refuse(KernelStage::Fragment, "normal")?;
let new_point = carrier.evaluate(0.5 * (c0 + c1), 0.5 * (w0 + w1)).or_refuse(KernelStage::Fragment, "evaluate")?;
if old_normal.dot(cylindrical(old_point).1) * new_normal.dot(cylindrical(new_point).1) <= 0.0 {
return Err(KernelRefusal::internal(
KernelStage::Refine,
"carrier_normal",
format!(
"{op}: the offset carrier of partial cylinder face {face_id} flipped its normal — \
refusing"
),
));
}
let mut result = solid.clone();
let pushed = &mut result.shells[shell_index].faces[face_index];
for coedge in &mut pushed.loops[0].coedges {
let mut pcurve = build_pcurve_on_surface(&carrier, &new_curves[&coedge.edge_id]).or_refuse(KernelStage::Sew, "build_pcurve_on_surface")?;
if !coedge.forward {
pcurve = pcurve.reversed().or_refuse(KernelStage::Sew, "reversed")?;
}
coedge.pcurve = pcurve;
}
pushed.surface = carrier;
retrim_planar_neighbours(
solid,
result,
&edge_by_id,
&new_curves,
&new_vertices,
planar_neighbours,
residual_tolerance,
scale,
)
.map(Some)
}
#[allow(clippy::too_many_arguments)]
fn retrim_planar_neighbours(
solid: &BrepSolid,
mut result: BrepSolid,
edge_by_id: &HashMap<u64, &EdgeRecord>,
new_curves: &HashMap<u64, NurbsCurve>,
new_vertices: &HashMap<u64, Vec3>,
planar_neighbours: HashSet<u64>,
residual_tolerance: f64,
scale: f64,
) -> Result<BrepSolid, KernelRefusal> {
for edge in &mut result.edges {
if let Some(curve) = new_curves.get(&edge.id) {
let [d0, d1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
edge.curve = curve.clone();
edge.t0 = d0;
edge.t1 = d1;
}
}
for vertex in &mut result.vertices {
if let Some(point) = new_vertices.get(&vertex.id) {
vertex.point = *point;
}
}
let result_vertices: HashMap<u64, Vec3> = result
.vertices
.iter()
.map(|vertex| (vertex.id, vertex.point))
.collect();
let mut cap_seams = HashSet::default();
for neighbour in &planar_neighbours {
let (ns, nf) = find_face(&result, *neighbour).ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Sew,
"cap_lookup",
format!("offset_revolution_face: missing cap {neighbour}"),
)
})?;
for loop_record in &result.shells[ns].faces[nf].loops {
for coedge in &loop_record.coedges {
let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Sew,
"missing_cap_edge",
format!(
"offset_revolution_face: missing cap edge {}",
coedge.edge_id
),
)
})?;
let touches_moved = new_vertices.contains_key(&edge.start_vertex_id)
|| new_vertices.contains_key(&edge.end_vertex_id);
if touches_moved
&& !new_curves.contains_key(&edge.id)
&& !edge.degenerate
&& edge.curve.degree == 1
{
cap_seams.insert(edge.id);
}
}
}
}
for seam_id in cap_seams {
let source = edge_by_id[&seam_id];
let curve = make_line(
result_vertices[&source.start_vertex_id],
result_vertices[&source.end_vertex_id],
).or_refuse(KernelStage::Sew, "make_line")?;
let [d0, d1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
if let Some(edge) = result.edges.iter_mut().find(|edge| edge.id == seam_id) {
edge.curve = curve;
edge.t0 = d0;
edge.t1 = d1;
}
}
let final_edges: HashMap<u64, EdgeRecord> = result
.edges
.iter()
.map(|edge| (edge.id, edge.clone()))
.collect();
for neighbour in planar_neighbours {
let (ns, nf) = find_face(&result, neighbour).ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Sew,
"retrim_cap",
format!("offset_revolution_face: missing cap {neighbour}"),
)
})?;
let plane = plane_of_surface(
&result.shells[ns].faces[nf].surface,
residual_tolerance,
"offset_revolution_face",
)?;
retrim_planar_face(
&mut result.shells[ns].faces[nf],
&plane,
&final_edges,
scale,
"offset_revolution_face",
)
.map_err(|error| {
KernelRefusal::internal(
KernelStage::Sew,
"cap_retrim",
format!("offset_revolution_face: cap retrim: {error}"),
)
})?;
}
let issues = result.validate();
if !issues.is_empty() {
return Err(KernelRefusal::internal(
KernelStage::Validate,
"validate",
format!(
"offset_revolution_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(KernelRefusal::internal(
KernelStage::Validate,
INVERTED,
"offset_revolution_face: the push inverts the solid — refusing",
));
}
}
Ok(result)
}
struct BoundaryTrace {
path: Option<std::path::PathBuf>,
}
impl BoundaryTrace {
fn open(solid: &BrepSolid, face_id: u64, distance: f64) -> Self {
let path = std::env::var_os("BREP_OFFSET_REVOLUTION_TRACE")
.filter(|value| !value.is_empty() && value != "0")
.map(std::path::PathBuf::from);
let trace = Self { path };
if trace.path.is_some() {
let volume = solid_signed_volume(solid).map_or(f64::NAN, f64::abs);
trace.write(format!(
"call\tface={face_id}\tdistance={distance}\tscale={}\tvolume_before={volume:.15}",
solid_model_scale(solid)
));
}
trace
}
fn write(&self, line: String) {
use std::io::Write;
let Some(path) = &self.path else { return };
let thread = std::thread::current();
let record = format!("{}\t{line}\n", thread.name().unwrap_or("?"));
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = file.write_all(record.as_bytes());
}
}
fn off_plane(
curve: &NurbsCurve,
t0: f64,
t1: f64,
plane: &crate::offset_retrim::Plane,
) -> (f64, f64) {
let distance = |t: f64| {
curve
.evaluate(t)
.map_or(f64::NAN, |point| point.sub(plane.origin).dot(plane.normal).abs())
};
let ends = distance(t0).max(distance(t1));
let samples = (0..=32)
.map(|index| distance(t0 + (t1 - t0) * index as f64 / 32.0))
.fold(0.0f64, f64::max);
(ends, samples)
}
fn edge(
&self,
kind: &str,
edge: &EdgeRecord,
neighbour: Option<&u64>,
plane: Option<&crate::offset_retrim::Plane>,
rebuilt: &NurbsCurve,
) {
if self.path.is_none() {
return;
}
let Some(plane) = plane else {
self.write(format!("edge\t{}\t{kind}\tneighbour=none", edge.id));
return;
};
let (before_ends, before_samples) = Self::off_plane(&edge.curve, edge.t0, edge.t1, plane);
let (after_ends, after_samples) = match rebuilt.domain() {
Ok([r0, r1]) => Self::off_plane(rebuilt, r0, r1, plane),
Err(_) => (f64::NAN, f64::NAN),
};
self.write(format!(
"edge\t{}\t{kind}\tneighbour={}\tbefore_vertices={before_ends:.3e}\t\
before_samples={before_samples:.3e}\tafter_vertices={after_ends:.3e}\t\
after_samples={after_samples:.3e}",
edge.id,
neighbour.map_or("none".to_string(), u64::to_string)
));
}
fn outcome(&self, outcome: &Result<BrepSolid, KernelRefusal>) {
if self.path.is_none() {
return;
}
match outcome {
Ok(result) => {
let volume = solid_signed_volume(result).map_or(f64::NAN, f64::abs);
self.write(format!("ok\tvolume_after={volume:.15}"));
}
Err(error) => self.write(format!("refused\t{error}")),
}
}
}