use super::*;
pub fn offset_sphere_face(
solid: &BrepSolid,
face_id: u64,
distance: f64,
) -> Result<BrepSolid, String> {
if !distance.is_finite() {
return Err("offset_sphere_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_sphere_face: no face {face_id}"))?;
let pushed = &solid.shells[pshell].faces[pface];
let Some(AnalyticSurface::Sphere { frame, radius }) = pushed.surface.analytic() else {
return Err("offset_sphere_face: the pushed face is not a sphere".into());
};
let center = frame.origin;
let axis = frame.axis;
let seam_x = frame.x_axis;
let seam_y = frame.y_axis;
let radius = *radius;
if radius <= tolerance {
return Err("offset_sphere_face: degenerate sphere radius".into());
}
let [u0, u1] = pushed.surface.domain_u()?;
let [v0, v1] = pushed.surface.domain_v()?;
let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
let point = pushed.surface.evaluate(um, vm)?;
let mut normal = pushed.surface.normal(um, vm)?;
if !pushed.same_sense {
normal = normal.scale(-1.0);
}
let radial = point.sub(center);
if radial.length() <= tolerance {
return Err("offset_sphere_face: degenerate radial direction".into());
}
let outward_sign = if normal.dot(radial) >= 0.0 { 1.0 } else { -1.0 };
let radius_new = radius + distance * outward_sign;
if radius_new <= tolerance {
return Err(
"offset_sphere_face: the push collapses the sphere to or past its centre — refusing"
.into(),
);
}
let k = radius_new / radius;
let scale_about_center = AffineTransform::new([
k, 0.0, 0.0, (1.0 - k) * center.x,
0.0, k, 0.0, (1.0 - k) * center.y,
0.0, 0.0, k, (1.0 - k) * center.z,
0.0, 0.0, 0.0, 1.0,
])?;
let s_prime = transform_surface(&pushed.surface, scale_about_center)?;
match s_prime.analytic() {
Some(AnalyticSurface::Sphere { frame, radius }) => {
if frame.origin.sub(center).length() > plane_tolerance
|| (radius - radius_new).abs() > plane_tolerance.max(radius_new * 1e-9)
{
return Err("offset_sphere_face: offset carrier is not the expected \
concentric sphere — refusing"
.into());
}
}
_ => {
return Err(
"offset_sphere_face: offset carrier did not re-recognise as a sphere — refusing"
.into(),
)
}
}
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 [dv0, dv1] = s_prime.domain_v()?;
enum RimKind {
OffCentrePlane(Plane),
NeighbourSphere,
CoaxialRuled,
}
let mut edges_to_scale: HashSet<u64> = HashSet::default();
let mut through_centre_caps: HashSet<u64> = HashSet::default();
let mut own_only_edges: Vec<u64> = Vec::new();
let mut all_boundary_vertices: HashSet<u64> = HashSet::default();
let mut raw_rims: Vec<(u64, u64, RimKind)> = Vec::new();
for loop_record in &pushed.loops {
for coedge in &loop_record.coedges {
let edge = *edge_by_id
.get(&coedge.edge_id)
.ok_or_else(|| format!("offset_sphere_face: missing edge {}", coedge.edge_id))?;
all_boundary_vertices.insert(edge.start_vertex_id);
all_boundary_vertices.insert(edge.end_vertex_id);
let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
if incident.iter().all(|f| *f == face_id) {
own_only_edges.push(edge.id); continue;
}
let neighbour = *incident
.iter()
.find(|f| **f != face_id)
.ok_or_else(|| format!("offset_sphere_face: edge {} has no neighbour", edge.id))?;
let (nshell, nface) = find_face(solid, neighbour)
.ok_or_else(|| format!("offset_sphere_face: missing neighbour {neighbour}"))?;
let neighbour_surface = &solid.shells[nshell].faces[nface].surface;
match neighbour_surface.analytic() {
Some(AnalyticSurface::Plane { .. }) => {
let plane =
plane_of_surface(neighbour_surface, plane_tolerance, "offset_sphere_face")?;
if center.sub(plane.origin).dot(plane.normal).abs() <= plane_tolerance {
through_centre_caps.insert(neighbour);
edges_to_scale.insert(edge.id);
} else {
raw_rims.push((edge.id, neighbour, RimKind::OffCentrePlane(plane)));
}
}
Some(AnalyticSurface::Sphere { .. }) => {
raw_rims.push((edge.id, neighbour, RimKind::NeighbourSphere));
}
Some(AnalyticSurface::RuledRevolution { .. }) => {
if !super::face_offset::ruled_neighbour_is_coaxial(
neighbour_surface,
center,
axis,
scale,
) {
return Err("offset_sphere_face: a cylinder/cone rim neighbour is \
NON-coaxial (only coaxial sphere × ruled intersections are \
supported) — refusing"
.into());
}
raw_rims.push((edge.id, neighbour, RimKind::CoaxialRuled));
}
Some(AnalyticSurface::Torus { .. }) => {
return Err("offset_sphere_face: a toroidal rim neighbour is deferred \
(torus is untouched in this slice)"
.into());
}
_ => {
return Err("offset_sphere_face: a rim neighbour is not a supported plane, \
sphere, or coaxial cylinder/cone (general revolution and \
free-form neighbours are deferred in this slice)"
.into());
}
}
}
}
let mut closure_vertices: HashSet<u64> = HashSet::default();
for (edge_id, _, _) in &raw_rims {
let edge = *edge_by_id.get(edge_id).unwrap();
if edge.start_vertex_id != edge.end_vertex_id {
return Err(
"offset_sphere_face: a recomputed rim is not a single closed edge \
(multi-edge / open rims are deferred in this slice)"
.into(),
);
}
closure_vertices.insert(edge.start_vertex_id);
}
let mut coupled_meridians: HashSet<u64> = HashSet::default();
for own in &own_only_edges {
let e = *edge_by_id.get(own).unwrap();
if e.degenerate {
edges_to_scale.insert(*own);
continue;
}
if closure_vertices.contains(&e.start_vertex_id)
|| closure_vertices.contains(&e.end_vertex_id)
{
coupled_meridians.insert(*own);
} else {
edges_to_scale.insert(*own);
}
}
let vertices_to_scale: HashSet<u64> = all_boundary_vertices
.iter()
.copied()
.filter(|v| !closure_vertices.contains(v))
.collect();
struct RimBuild {
edge_id: u64,
neighbour_id: u64,
neighbour_is_sphere: bool,
closure_vertex: u64,
circle: NurbsCurve,
closure_point: Vec3,
v_rim: f64,
pushed_pcurve: NurbsCurve,
}
let mut rim_builds: Vec<RimBuild> = Vec::new();
for (edge_id, neighbour, kind) in &raw_rims {
let edge = *edge_by_id.get(edge_id).unwrap();
let closure_vertex = edge.start_vertex_id;
let seam_coupled = coupled_meridians.iter().any(|m| {
let e = *edge_by_id.get(m).unwrap();
e.start_vertex_id == closure_vertex || e.end_vertex_id == closure_vertex
});
let mut circle = if seam_coupled {
match kind {
RimKind::OffCentrePlane(plane) => {
if plane.normal.dot(axis).abs() < 1.0 - 1e-6 {
return Err(
"offset_sphere_face: a seam-crossing rim on an OBLIQUE plane is \
deferred in this slice (only axis-perpendicular caps)"
.into(),
);
}
let a = plane.origin.sub(center).dot(axis);
let rr2 = radius_new * radius_new - a * a;
if rr2 <= tolerance * tolerance {
return Err(
"offset_sphere_face: the push pulls the grown sphere off its cap \
plane (the cap vanishes) — refusing"
.into(),
);
}
crate::make_arc(
center.add(axis.scale(a)),
seam_x,
seam_y,
rr2.sqrt(),
0.0,
std::f64::consts::TAU,
)?
}
RimKind::NeighbourSphere => {
return Err(
"offset_sphere_face: a sphere neighbour whose rim crosses the \
pushed sphere's seam is deferred in this slice"
.into(),
)
}
RimKind::CoaxialRuled => {
let (ns, nf) = find_face(solid, *neighbour).unwrap();
let neighbour_surface = &solid.shells[ns].faces[nf].surface;
let curves = intersect_analytic_pair(&s_prime, neighbour_surface, tolerance)
.filter(|curves| !curves.is_empty())
.ok_or_else(|| {
"offset_sphere_face: the grown sphere no longer meets its coaxial \
cylinder/cone neighbour — refusing"
.to_string()
})?;
let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
nearest_circle(&curves, old_mid)?
}
}
} else {
let (ns, nf) = find_face(solid, *neighbour).unwrap();
let neighbour_surface = &solid.shells[ns].faces[nf].surface;
let curves = intersect_analytic_pair(&s_prime, neighbour_surface, tolerance)
.filter(|curves| !curves.is_empty())
.ok_or_else(|| {
"offset_sphere_face: the grown sphere no longer meets a neighbour (the cap \
vanishes / tangent / separated) — refusing"
.to_string()
})?;
let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
nearest_circle(&curves, old_mid)?
};
let closure_old = edge.curve.evaluate(edge.t0)?;
let old_tan = edge
.curve
.evaluate(edge.t0 + 0.01 * (edge.t1 - edge.t0))?
.sub(closure_old);
let [c0, c1] = circle.domain()?;
let new_start = circle.evaluate(c0)?;
let new_tan = circle.evaluate(c0 + 0.01 * (c1 - c0))?.sub(new_start);
if old_tan.dot(new_tan) < 0.0 {
circle = circle.reversed()?;
}
let closure_point = circle.evaluate(circle.domain()?[0])?;
let pushed_pcurve = build_pcurve_on_surface(&s_prime, &circle)?;
let [q0, q1] = pushed_pcurve.domain()?;
let v_rim = pushed_pcurve.evaluate(0.5 * (q0 + q1))?.y;
rim_builds.push(RimBuild {
edge_id: *edge_id,
neighbour_id: *neighbour,
neighbour_is_sphere: matches!(kind, RimKind::NeighbourSphere),
closure_vertex,
circle,
closure_point,
v_rim,
pushed_pcurve,
});
}
let iso_meridian = s_prime.iso_curve_u(0.0)?;
let mut result = solid.clone();
for edge in &mut result.edges {
if edges_to_scale.contains(&edge.id) {
edge.curve = transform_curve(&edge.curve, scale_about_center)?;
}
}
for rb in &rim_builds {
if let Some(edge) = result.edges.iter_mut().find(|e| e.id == rb.edge_id) {
let [d0, d1] = rb.circle.domain()?;
edge.curve = rb.circle.clone();
edge.t0 = d0;
edge.t1 = d1;
}
}
for mer in &coupled_meridians {
let e = *edge_by_id.get(mer).unwrap();
let cv = if closure_vertices.contains(&e.start_vertex_id) {
e.start_vertex_id
} else {
e.end_vertex_id
};
let v_rim = rim_builds
.iter()
.find(|rb| rb.closure_vertex == cv)
.map(|rb| rb.v_rim)
.ok_or_else(|| {
"offset_sphere_face: a seam meridian is not paired with a rim — refusing"
.to_string()
})?;
let closure_at_start = e.start_vertex_id == cv;
let fixed_vertex = if closure_at_start {
e.end_vertex_id
} else {
e.start_vertex_id
};
let fixed_old = solid
.vertices
.iter()
.find(|v| v.id == fixed_vertex)
.map(|v| v.point)
.ok_or_else(|| format!("offset_sphere_face: missing seam vertex {fixed_vertex}"))?;
let fixed_new = scale_about_center.point(fixed_old);
let fixed_projection = project_point_to_curve(&iso_meridian, fixed_new)?;
if fixed_projection.distance > plane_tolerance {
return Err(format!(
"offset_sphere_face: a coupled own-edge is not on the sphere seam \
(off by {:.3e}) — refusing",
fixed_projection.distance
));
}
let v_start = if closure_at_start {
v_rim
} else {
fixed_projection.u
};
let v_end = if closure_at_start {
fixed_projection.u
} else {
v_rim
};
let (curve, t0, t1) = if v_start <= v_end {
(iso_meridian.clone(), v_start, v_end)
} else {
(
iso_meridian.reversed()?,
dv0 + dv1 - v_start,
dv0 + dv1 - v_end,
)
};
if let Some(edge) = result.edges.iter_mut().find(|x| x.id == *mer) {
edge.curve = curve;
edge.t0 = t0;
edge.t1 = t1;
}
}
for vertex in &mut result.vertices {
if vertices_to_scale.contains(&vertex.id) {
vertex.point = scale_about_center.point(vertex.point);
}
}
for rb in &rim_builds {
if let Some(v) = result.vertices.iter_mut().find(|v| v.id == rb.closure_vertex) {
v.point = rb.closure_point;
}
}
{
let pface_rec = &mut result.shells[pshell].faces[pface];
for loop_record in &mut pface_rec.loops {
for coedge in &mut loop_record.coedges {
if let Some(rb) = rim_builds.iter().find(|rb| rb.edge_id == coedge.edge_id) {
let mut pcurve = rb.pushed_pcurve.clone();
if !coedge.forward {
pcurve = pcurve.reversed()?;
}
coedge.pcurve = pcurve;
} else if coupled_meridians.contains(&coedge.edge_id) {
let e = *edge_by_id.get(&coedge.edge_id).unwrap();
let cv = if closure_vertices.contains(&e.start_vertex_id) {
e.start_vertex_id
} else {
e.end_vertex_id
};
let v_rim = rim_builds
.iter()
.find(|rb| rb.closure_vertex == cv)
.map(|rb| rb.v_rim)
.unwrap();
let closure_at_pcurve_start = (e.start_vertex_id == cv) == coedge.forward;
coedge.pcurve =
patch_meridian_pcurve(&coedge.pcurve, v_rim, closure_at_pcurve_start)?;
}
}
}
}
result.shells[pshell].faces[pface].surface = s_prime;
let result_vertex: HashMap<u64, Vec3> =
result.vertices.iter().map(|v| (v.id, v.point)).collect();
let mut ruled_seams: HashSet<u64> = HashSet::default();
for rb in &rim_builds {
let (ns, nf) = find_face(&result, rb.neighbour_id)
.ok_or_else(|| format!("offset_sphere_face: missing neighbour {}", rb.neighbour_id))?;
if !matches!(
result.shells[ns].faces[nf].surface.analytic(),
Some(AnalyticSurface::RuledRevolution { .. })
) {
continue;
}
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(|| {
format!("offset_sphere_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 == rb.neighbour_id);
let touches_rim = closure_vertices.contains(&edge.start_vertex_id)
|| closure_vertices.contains(&edge.end_vertex_id);
if is_seam && touches_rim {
ruled_seams.insert(edge.id);
}
}
}
}
let rebuilt_ruled_seams: HashSet<u64> = ruled_seams.iter().copied().collect();
for seam_id in ruled_seams {
let edge = *edge_by_id
.get(&seam_id)
.ok_or_else(|| format!("offset_sphere_face: missing ruled seam {seam_id}"))?;
let curve = make_line(
result_vertex[&edge.start_vertex_id],
result_vertex[&edge.end_vertex_id],
)?;
if let Some(out) = result
.edges
.iter_mut()
.find(|candidate| candidate.id == seam_id)
{
let [t0, t1] = curve.domain()?;
out.curve = curve;
out.t0 = t0;
out.t1 = t1;
}
}
let mut relocated: HashMap<u64, Vec3> = HashMap::default();
for vertex in &result.vertices {
let Some(old) = solid.vertices.iter().find(|o| o.id == vertex.id) else {
continue;
};
if vertex.point.sub(old.point).length() > tolerance {
relocated.insert(vertex.id, vertex.point);
}
}
let already_rebuilt: HashSet<u64> = edges_to_scale
.iter()
.copied()
.chain(rim_builds.iter().map(|rb| rb.edge_id))
.chain(coupled_meridians.iter().copied())
.chain(rebuilt_ruled_seams.iter().copied())
.collect();
let mut resized_edges: HashSet<u64> = HashSet::default();
for edge in &mut result.edges {
if edge.degenerate || already_rebuilt.contains(&edge.id) {
continue;
}
for at_start in [true, false] {
let vertex_id = if at_start {
edge.start_vertex_id
} else {
edge.end_vertex_id
};
let Some(&target) = relocated.get(&vertex_id) else {
continue;
};
let projection = project_point_to_curve(&edge.curve, target)?;
if projection.distance > 10.0 * tolerance {
return Err(format!(
"offset_sphere_face: the push moves rim corner (vertex {vertex_id}) OFF \
fixed edge {} by {:.3e} — a rim corner that leaves its own side edge \
needs the corner re-solved against the neighbour carriers, which is \
deferred — refusing",
edge.id, projection.distance
));
}
let [d0, d1] = edge.curve.domain()?;
let span = (d1 - d0).max(1e-12);
let fixed_t = if at_start { edge.t1 } else { edge.t0 };
let old_t = if at_start { edge.t0 } else { edge.t1 };
let new_t = projection.u;
if (new_t - fixed_t) * (old_t - fixed_t) <= 0.0
|| (new_t - fixed_t).abs() <= 1e-7 * span
{
return Err(format!(
"offset_sphere_face: the push collapses or inverts the trim of fixed edge \
{} at rim corner (vertex {vertex_id}) — refusing",
edge.id
));
}
if at_start {
edge.t0 = new_t;
} else {
edge.t1 = new_t;
}
resized_edges.insert(edge.id);
}
}
let final_edges: HashMap<u64, EdgeRecord> =
result.edges.iter().map(|e| (e.id, e.clone())).collect();
for rb in &rim_builds {
let (ns, nf) = find_face(&result, rb.neighbour_id)
.ok_or_else(|| format!("offset_sphere_face: missing neighbour {}", rb.neighbour_id))?;
if rb.neighbour_is_sphere {
let nsurf = result.shells[ns].faces[nf].surface.clone();
for loop_record in &mut result.shells[ns].faces[nf].loops {
for coedge in &mut loop_record.coedges {
if coedge.edge_id == rb.edge_id {
let mut pcurve = build_pcurve_on_surface(&nsurf, &rb.circle)?;
if !coedge.forward {
pcurve = pcurve.reversed()?;
}
coedge.pcurve = pcurve;
}
}
}
} else if matches!(
result.shells[ns].faces[nf].surface.analytic(),
Some(AnalyticSurface::RuledRevolution { .. })
) {
super::face_offset::retrim_offset_ruled_face(
&mut result,
rb.neighbour_id,
&final_edges,
tolerance,
)?;
} else {
let plane = plane_of_surface(
&result.shells[ns].faces[nf].surface,
plane_tolerance,
"offset_sphere_face",
)?;
retrim_planar_face(
&mut result.shells[ns].faces[nf],
&plane,
&final_edges,
scale,
"offset_sphere_face",
)?;
refit_subrange_pcurves(
&mut result.shells[ns].faces[nf],
&final_edges,
tolerance,
)?;
}
}
for cap in &through_centre_caps {
let (cshell, cface) = find_face(&result, *cap)
.ok_or_else(|| format!("offset_sphere_face: missing cap {cap}"))?;
let plane = plane_of_surface(
&result.shells[cshell].faces[cface].surface,
plane_tolerance,
"offset_sphere_face",
)?;
retrim_planar_face(
&mut result.shells[cshell].faces[cface],
&plane,
&final_edges,
scale,
"offset_sphere_face",
)?;
refit_subrange_pcurves(
&mut result.shells[cshell].faces[cface],
&final_edges,
tolerance,
)?;
}
for shell in &mut result.shells {
for face in &mut shell.faces {
if face
.loops
.iter()
.flat_map(|l| &l.coedges)
.any(|c| resized_edges.contains(&c.edge_id))
{
refit_touched_pcurves(
face,
&final_edges,
&resized_edges,
true,
tolerance,
"offset_sphere_face",
)?;
}
}
}
let issues = result.validate();
if !issues.is_empty() {
return Err(format!(
"offset_sphere_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_sphere_face: the push inverts the solid — refusing".into());
}
}
Ok(result)
}
fn refit_subrange_pcurves(
face: &mut FaceRecord,
edges: &HashMap<u64, EdgeRecord>,
tolerance: f64,
) -> Result<(), String> {
let face_edges: HashSet<u64> = face
.loops
.iter()
.flat_map(|loop_record| loop_record.coedges.iter().map(|c| c.edge_id))
.collect();
refit_touched_pcurves(
face,
edges,
&face_edges,
true,
tolerance,
"offset_sphere_face",
)
}
fn nearest_circle(curves: &[NurbsCurve], reference: Vec3) -> Result<NurbsCurve, String> {
let mut best: Option<(f64, &NurbsCurve)> = None;
for curve in curves {
let [d0, d1] = curve.domain()?;
let mid = curve.evaluate(0.5 * (d0 + d1))?;
let distance = mid.sub(reference).length();
if best.map(|(known, _)| distance < known).unwrap_or(true) {
best = Some((distance, curve));
}
}
best.map(|(_, curve)| curve.clone())
.ok_or_else(|| "offset_sphere_face: the sphere ∩ neighbour intersection is empty".into())
}
fn patch_meridian_pcurve(
pcurve: &NurbsCurve,
v_new: f64,
closure_at_start: bool,
) -> Result<NurbsCurve, String> {
let [q0, q1] = pcurve.domain()?;
let start = pcurve.evaluate(q0)?;
let end = pcurve.evaluate(q1)?;
let (v_start, v_end) = if closure_at_start {
(v_new, end.y)
} else {
(start.y, v_new)
};
make_line(
Vec3::new(start.x, v_start, 0.0),
Vec3::new(end.x, v_end, 0.0),
)
}