use super::debug_enabled;
use super::pipeline::{offset_body, Body};
use super::{OffsetFaceRole, OffsetShellFaceImageRecord, OffsetShellResultRecord};
use crate::classification::PolygonClass;
use crate::topology::{BrepSolid, EdgeRecord, FaceRecord};
use crate::{
boolean_operation, fillet_edges, project_point_to_surface, BooleanOperation, BooleanOptions,
KernelRefusal, KernelStage, OrRefuse, RefusalClass, Vec2, Vec3,
};
use rustc_hash::FxHashMap as HashMap;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OffsetCorners {
#[default]
Sharp,
Rounded,
}
pub fn offset_solid(
source: &BrepSolid,
distance: f64,
corners: OffsetCorners,
) -> Result<OffsetShellResultRecord, KernelRefusal> {
if corners == OffsetCorners::Rounded && distance > 0.0 {
if let Some(grown) = grown_across_reflex_edges(source, distance) {
return Ok(grown);
}
}
let sharp = match offset_body(source, &[], -distance, Body::Whole, "offset_solid") {
Ok(sharp) => sharp,
Err(error) => {
if corners == OffsetCorners::Rounded {
if let Some(grown) = grown_by_pieces(source, distance) {
return Ok(grown);
}
}
return Err(error);
}
};
match corners {
OffsetCorners::Sharp => Ok(sharp),
OffsetCorners::Rounded => match round_offset_joins(sharp, distance) {
Ok(rounded) => Ok(rounded),
Err(error) => match crate::blend::convex_parallel::convex_polyhedron_parallel(source, distance) {
Ok(Some((solid, images))) if solid.validate().is_empty() => {
let face_images = images
.into_iter()
.map(|image| OffsetShellFaceImageRecord { role: OffsetFaceRole::Offset, source_face_id: image.unwrap_or(0) })
.collect();
Ok(OffsetShellResultRecord { solid, face_images })
}
_ => Err(error),
},
},
}
}
fn grow_by_pieces_enabled() -> bool {
switch!("BREP_OFFSET_GROW_BY_PIECES", true)
}
fn grown_by_pieces(source: &BrepSolid, distance: f64) -> Option<OffsetShellResultRecord> {
if !grow_by_pieces_enabled() {
return None;
}
match grown_by_pieces_impl(source, distance) {
Ok(result) => result,
Err(error) => {
os_debug!("grow by pieces declined: {error}");
None
}
}
}
fn grown_by_pieces_impl(source: &BrepSolid, distance: f64) -> Result<Option<OffsetShellResultRecord>, KernelRefusal> {
if distance < 0.0 {
return shrunk_by_pieces(source, -distance);
}
let Some(sets) = thin_pair_planes(source, distance, true)? else {
return Ok(None);
};
let mut answer = None;
for planes in &sets {
match grown_union(source, planes, distance) {
Ok(Some(solid)) => {
answer = Some(solid);
break;
}
Ok(None) => {}
Err(error) => os_debug!("grow by pieces: {} plane(s) declined: {error}", planes.len()),
}
}
let Some(solid) = answer else {
return Ok(None);
};
let face_images = solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.map(|_| OffsetShellFaceImageRecord { role: OffsetFaceRole::Offset, source_face_id: 0 })
.collect();
Ok(Some(OffsetShellResultRecord { solid, face_images }))
}
fn round_offset_joins(
sharp: OffsetShellResultRecord,
distance: f64,
) -> Result<OffsetShellResultRecord, KernelRefusal> {
let wanted_convex = distance > 0.0;
let midpoints = sharp_join_midpoints(&sharp.solid, wanted_convex)?;
if midpoints.is_empty() {
return Ok(sharp);
}
let solid = fillet_edges(&sharp.solid, &midpoints, None, distance.abs(), false, None)
.map_err(|error| {
error.with_message(|message| {
format!(
"offset_solid: the rounded joins of the offset body could not be built \
({} edge(s) at radius {:.6}): {message}",
midpoints.len(),
distance.abs()
)
})
})?;
let operands = sharp
.solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.zip(&sharp.face_images)
.map(|(face, image)| (face, image.clone()))
.collect::<Vec<_>>();
let cap = OffsetShellFaceImageRecord {
role: OffsetFaceRole::Offset,
source_face_id: 0,
};
let face_images = provenance_by_surface(&solid, &operands, Some(&cap))?;
Ok(OffsetShellResultRecord { solid, face_images })
}
fn provenance_by_surface(
result: &BrepSolid,
operands: &[(&FaceRecord, OffsetShellFaceImageRecord)],
unmatched: Option<&OffsetShellFaceImageRecord>,
) -> Result<Vec<OffsetShellFaceImageRecord>, KernelRefusal> {
let band = 1e-6 * crate::solid_scale(result).max(1.0);
let mut images = Vec::new();
for face in result.shells.iter().flat_map(|shell| &shell.faces) {
let seeds = super::source_seeds(result, face.id)?;
let seed = seeds[0];
let point = face.surface.evaluate(seed.x, seed.y).or_refuse(KernelStage::Sew, "evaluate")?;
let mut found = None;
for (operand, image) in operands {
let Ok(foot) = project_point_to_surface(&operand.surface, point) else { continue };
if foot.distance > band {
continue;
}
let inside = super::parameter_point_in_face(operand, Vec2 { x: foot.u, y: foot.v }, 1e-8)
.is_ok_and(|class| class != PolygonClass::Outside);
if inside {
found = Some(image.clone());
break;
}
}
match found.or_else(|| unmatched.cloned()) {
Some(image) => images.push(image),
None => {
return Err(KernelRefusal::internal(
KernelStage::Sew,
"offset_shell_provenance_lost",
format!(
"offset_shell: result face {} at ({:.4}, {:.4}, {:.4}) lies on no operand face",
face.id, point.x, point.y, point.z
),
))
}
}
}
Ok(images)
}
fn sharp_join_midpoints(solid: &BrepSolid, convex: bool) -> Result<Vec<Vec3>, KernelRefusal> {
const TANGENT_COSINE: f64 = 1.0 - 1e-6;
let faces = solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.collect::<Vec<_>>();
let mut uses = HashMap::<u64, Vec<(&FaceRecord, bool)>>::default();
for face in &faces {
for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
uses.entry(coedge.edge_id).or_default().push((face, coedge.forward));
}
}
let mut midpoints = Vec::new();
for edge in &solid.edges {
if edge.degenerate {
continue;
}
let Some(pair) = uses.get(&edge.id) else { continue };
if pair.len() != 2 || pair[0].0.id == pair[1].0.id {
continue;
}
if edge_is_convex_join(edge, pair[0], pair[1].0, TANGENT_COSINE)? == Some(convex) {
midpoints.push(edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).or_refuse(KernelStage::Classify, "evaluate")?);
}
}
Ok(midpoints)
}
fn edge_is_convex_join(
edge: &EdgeRecord,
(first, first_forward): (&FaceRecord, bool),
second: &FaceRecord,
tangent_cosine: f64,
) -> Result<Option<bool>, KernelRefusal> {
let t = 0.5 * (edge.t0 + edge.t1);
let derivatives = edge.curve.derivatives(t, 1).or_refuse(KernelStage::Classify, "derivatives")?;
let point = derivatives[0];
let mut tangent = derivatives[1].normalized().or_refuse(KernelStage::Classify, "normalized")?;
if !first_forward {
tangent = tangent.scale(-1.0);
}
let normal_a = outward_normal_at(first, point)?;
let normal_b = outward_normal_at(second, point)?;
if normal_a.dot(normal_b) >= tangent_cosine {
return Ok(None);
}
Ok(Some(normal_b.dot(normal_a.cross(tangent)) < 0.0))
}
fn outward_normal_at(face: &FaceRecord, point: Vec3) -> Result<Vec3, KernelRefusal> {
let foot = project_point_to_surface(&face.surface, point).or_refuse(KernelStage::Classify, "project_point_to_surface")?;
super::face_normal(face, foot.u, foot.v)
}
pub fn offset_shell_by_boolean(
source: &BrepSolid,
opening_face_ids: &[u64],
distance: f64,
) -> Result<OffsetShellResultRecord, KernelRefusal> {
if opening_face_ids.is_empty() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"offset_shell_no_opening",
"offset_shell: at least one opening face is required",
));
}
let body = offset_body(source, opening_face_ids, distance, Body::Whole, "offset_shell_by_boolean")
.or_else(|error| {
if !direct_refusal_takes_the_boolean_lane(&error) {
return Err(error);
}
let result = super::joint_offset::build(source, opening_face_ids, distance);
if let Err(reason) = &result {
os_debug!("joint offset refused: {reason}");
}
result.map_err(|_| error)
})?;
let (kept, removed) = if distance > 0.0 { (source, &body.solid) } else { (&body.solid, source) };
let solid = boolean_operation(kept, removed, BooleanOperation::Subtract, &BooleanOptions::default())
.map_err(|error| {
error.with_message(|message| {
format!("offset_shell: the boolean lane's difference could not be built: {message}")
})
})?;
let mut operands = body
.solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.zip(&body.face_images)
.map(|(face, image)| (face, image.clone()))
.collect::<Vec<_>>();
operands.extend(source.shells.iter().flat_map(|shell| &shell.faces).map(|face| {
(
face,
OffsetShellFaceImageRecord {
role: OffsetFaceRole::Source,
source_face_id: face.id,
},
)
}));
let face_images = provenance_by_surface(&solid, &operands, None)?;
Ok(OffsetShellResultRecord { solid, face_images })
}
pub fn offset_shell_closed(source: &BrepSolid, distance: f64) -> Result<OffsetShellResultRecord, KernelRefusal> {
let body = offset_body(source, &[], distance, Body::Whole, "offset_shell_closed")?;
let (kept, removed) = if distance > 0.0 { (source, &body.solid) } else { (&body.solid, source) };
let solid = boolean_operation(kept, removed, BooleanOperation::Subtract, &BooleanOptions::default())
.map_err(|error| {
error.with_message(|message| format!("offset_shell: the closed hollow's difference could not be built: {message}"))
})?;
let volume = |solid: &BrepSolid| {
crate::solid_mass_properties(solid)
.map(|properties| properties.volume)
.or_refuse(KernelStage::Validate, "solid_mass_properties")
};
let expected = volume(kept)? - volume(removed)?;
let measured = volume(&solid)?;
if solid.shells.len() != 2 || !(expected > 0.0) || !((measured - expected).abs() <= 1e-9 * expected) {
return Err(KernelRefusal::internal(
KernelStage::Validate,
"offset_shell_closed_hollow",
format!(
"offset_shell: the closed hollow is not the band between its operands ({} shells, volume {measured} against {expected})",
solid.shells.len()
),
));
}
let mut operands = body
.solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.zip(&body.face_images)
.map(|(face, image)| (face, image.clone()))
.collect::<Vec<_>>();
operands.extend(source.shells.iter().flat_map(|shell| &shell.faces).map(|face| {
(face, OffsetShellFaceImageRecord { role: OffsetFaceRole::Source, source_face_id: face.id })
}));
let face_images = provenance_by_surface(&solid, &operands, None)?;
Ok(OffsetShellResultRecord { solid, face_images })
}
pub(super) fn direct_refusal_takes_the_boolean_lane(refusal: &KernelRefusal) -> bool {
match &refusal.class {
RefusalClass::UnsupportedGeometry { what } | RefusalClass::Internal { what } => {
what.starts_with("offset_shell_")
}
RefusalClass::NonIntegralGenus { .. } => {
refusal.message.starts_with("offset_shell")
|| (bare_genus_takes_the_boolean_lane() && refusal.message.starts_with("boolean assembly:"))
}
RefusalClass::DegenerateArrangement { .. } => refusal.message.starts_with("offset_shell"),
_ => false,
}
}
fn bare_genus_takes_the_boolean_lane() -> bool {
crate::switch!("BREP_OFFSET_BARE_GENUS_BOOLEAN_LANE", true)
}
fn thin_pair_planes(solid: &BrepSolid, amount: f64, grow: bool) -> Result<Option<Vec<Vec<(Vec3, Vec3)>>>, KernelRefusal> {
let band = 1e-9 * crate::solid_scale(solid).max(1.0);
let mut faces: Vec<(Vec3, Vec3)> = Vec::new();
for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
let Some((point, normal)) = super::rim_welds::planar_surface_frame(&face.surface, band)? else {
continue;
};
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 Ok(outward) = super::face_normal(face, 0.5 * (u0 + u1), 0.5 * (v0 + v1)) else {
continue;
};
faces.push((point, if outward.dot(normal) < 0.0 { normal.scale(-1.0) } else { normal }));
}
let mut directions: Vec<Vec3> = Vec::new();
let mut pairs: Vec<(usize, usize)> = Vec::new();
for (index, (point, normal)) in faces.iter().enumerate() {
for (offset, (other, other_normal)) in faces[index + 1..].iter().enumerate() {
if normal.dot(*other_normal) > -1.0 + 1e-12 {
continue;
}
let gap = other.sub(*point).dot(*normal);
let thin = if grow { gap > band && gap < 2.0 * amount } else { gap < -band && -gap < 2.0 * amount };
if thin {
pairs.push((index, index + 1 + offset));
if !directions.iter().any(|axis| axis.dot(*normal).abs() >= 1.0 - 1e-12) {
directions.push(*normal);
}
}
}
}
if directions.is_empty() {
return Ok(None);
}
let push = |planes: &mut Vec<(Vec3, Vec3)>, point: Vec3, normal: Vec3| {
let seen = planes
.iter()
.any(|(other, axis)| axis.dot(normal).abs() >= 1.0 - 1e-12 && point.sub(*other).dot(*axis).abs() <= band);
if !seen {
planes.push((point, normal));
}
};
let mut parallel: Vec<(Vec3, Vec3)> = Vec::new();
for (point, normal) in &faces {
if directions.iter().any(|axis| axis.dot(*normal).abs() >= 1.0 - 1e-12) {
push(&mut parallel, *point, *normal);
}
}
let mut paired: Vec<(Vec3, Vec3)> = Vec::new();
for (first, second) in &pairs {
push(&mut paired, faces[*first].0, faces[*first].1);
push(&mut paired, faces[*second].0, faces[*second].1);
}
let mut sets = Vec::new();
if parallel.len() <= 8 {
sets.push(parallel.clone());
}
if paired.len() <= 8 && paired.len() < parallel.len() {
sets.push(paired);
}
Ok((!sets.is_empty()).then_some(sets))
}
fn cut_by_planes(solid: &BrepSolid, planes: &[(Vec3, Vec3)]) -> Vec<BrepSolid> {
let mut pieces = vec![solid.clone()];
for (point, normal) in planes {
let mut next = Vec::with_capacity(pieces.len() * 2);
for piece in pieces {
match crate::split_solid_by_plane(&piece, *point, *normal) {
Ok((below, above)) => {
next.push(below);
next.push(above);
}
Err(_) => next.push(piece),
}
}
pieces = next;
}
pieces
}
fn shrunk_by_pieces(source: &BrepSolid, amount: f64) -> Result<Option<OffsetShellResultRecord>, KernelRefusal> {
let Some(sets) = thin_pair_planes(source, amount, false)? else {
return Ok(None);
};
let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for face in source.shells.iter().flat_map(|shell| &shell.faces) {
for control in face.surface.control_points.iter().flatten() {
let Ok(point) = control.point() else { return Ok(None) };
low = Vec3::new(low.x.min(point.x), low.y.min(point.y), low.z.min(point.z));
high = Vec3::new(high.x.max(point.x), high.y.max(point.y), high.z.max(point.z));
}
}
let margin = 3.0 * amount;
let corner = low.sub(Vec3::new(margin, margin, margin));
let size = high.sub(low).add(Vec3::new(2.0 * margin, 2.0 * margin, 2.0 * margin));
let frame = crate::make_box_brep(corner, size.x, size.y, size.z).or_refuse(KernelStage::Refine, "make_box_brep")?;
let options = BooleanOptions::default();
let outside = boolean_operation(&frame, source, BooleanOperation::Subtract, &options)?;
let mut answer = None;
for planes in &sets {
let attempt = grown_union(&outside, planes, amount).and_then(|reach| match reach {
Some(reach) => boolean_operation(source, &reach, BooleanOperation::Subtract, &options).map(Some),
None => Ok(None),
});
match attempt {
Ok(Some(solid)) if solid.validate().is_empty() => {
answer = Some(solid);
break;
}
Ok(_) => {}
Err(error) => os_debug!("shrink by pieces: {} plane(s) declined: {error}", planes.len()),
}
}
if answer.is_none() && shrink_by_subtraction_enabled() {
for planes in &sets {
match subtracted_one_by_one(source, &outside, planes, amount) {
Ok(Some(solid)) => {
answer = Some(solid);
break;
}
Ok(None) => {}
Err(error) => os_debug!("shrink by subtraction: {} plane(s) declined: {error}", planes.len()),
}
}
}
let Some(solid) = answer else {
return Ok(None);
};
let face_images = solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.map(|_| OffsetShellFaceImageRecord { role: OffsetFaceRole::Offset, source_face_id: 0 })
.collect();
Ok(Some(OffsetShellResultRecord { solid, face_images }))
}
fn grown_union(solid: &BrepSolid, planes: &[(Vec3, Vec3)], amount: f64) -> Result<Option<BrepSolid>, KernelRefusal> {
let Some(grown) = grown_pieces(solid, planes, amount)? else {
return Ok(None);
};
let union = crate::boolean_operation_nary(&grown, BooleanOperation::Union)?;
Ok(union.validate().is_empty().then_some(union))
}
fn grown_pieces(solid: &BrepSolid, planes: &[(Vec3, Vec3)], amount: f64) -> Result<Option<Vec<BrepSolid>>, KernelRefusal> {
let pieces = cut_by_planes(solid, planes);
if pieces.len() < 2 {
return Ok(None);
}
os_debug!("by pieces: {} plane(s) cut the body into {} piece(s)", planes.len(), pieces.len());
let mut grown = Vec::with_capacity(pieces.len());
for (index, piece) in pieces.iter().enumerate() {
let sharp = offset_body(piece, &[], -amount, Body::Whole, "offset_solid_piece")
.map_err(|error| error.with_message(|message| format!("piece {index}: {message}")))?;
grown.push(round_offset_joins(sharp, amount)?.solid);
}
Ok(Some(grown))
}
fn shrink_by_subtraction_enabled() -> bool {
switch!("BREP_OFFSET_SHRINK_BY_SUBTRACTION", true)
}
fn subtracted_one_by_one(source: &BrepSolid, outside: &BrepSolid, planes: &[(Vec3, Vec3)], amount: f64) -> Result<Option<BrepSolid>, KernelRefusal> {
let Some(grown) = grown_pieces(outside, planes, amount)? else {
return Ok(None);
};
let options = BooleanOptions::default();
let mut body = source.clone();
for (index, piece) in grown.iter().enumerate() {
body = boolean_operation(&body, piece, BooleanOperation::Subtract, &options)
.map_err(|error| error.with_message(|message| format!("subtracting grown piece {index}: {message}")))?;
}
Ok(body.validate().is_empty().then_some(body))
}
fn reflex_by_pieces_enabled() -> bool {
switch!("BREP_OFFSET_REFLEX_BY_PIECES", true)
}
fn grown_across_reflex_edges(source: &BrepSolid, distance: f64) -> Option<OffsetShellResultRecord> {
if !reflex_by_pieces_enabled() {
return None;
}
let attempt = || -> Result<Option<OffsetShellResultRecord>, KernelRefusal> {
let band = 1e-9 * crate::solid_scale(source).max(1.0);
let faces = source.shells.iter().flat_map(|shell| &shell.faces).collect::<Vec<_>>();
let mut uses = HashMap::<u64, Vec<(&FaceRecord, bool)>>::default();
for face in &faces {
for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
uses.entry(coedge.edge_id).or_default().push((face, coedge.forward));
}
}
let mut planes: Vec<(Vec3, Vec3)> = Vec::new();
for edge in &source.edges {
if edge.degenerate {
continue;
}
let Some(pair) = uses.get(&edge.id) else { continue };
if pair.len() != 2 || pair[0].0.id == pair[1].0.id {
continue;
}
if edge_is_convex_join(edge, pair[0], pair[1].0, 1.0 - 1e-6)? != Some(false) {
continue;
}
for (face, _) in pair {
let Some((point, normal)) = super::rim_welds::planar_surface_frame(&face.surface, band)? else {
continue;
};
let seen = planes
.iter()
.any(|(other, axis)| axis.dot(normal).abs() >= 1.0 - 1e-12 && point.sub(*other).dot(*axis).abs() <= band);
if !seen {
planes.push((point, normal));
}
}
}
if planes.is_empty() || planes.len() > 8 {
return Ok(None);
}
let mut sets = planes.iter().map(|plane| vec![*plane]).collect::<Vec<_>>();
if planes.len() > 1 {
sets.push(planes.clone());
}
for set in &sets {
match grown_union(source, set, distance) {
Ok(Some(solid)) => {
os_debug!("reflex by pieces: {} plane(s)", set.len());
let face_images = solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.map(|_| OffsetShellFaceImageRecord { role: OffsetFaceRole::Offset, source_face_id: 0 })
.collect();
return Ok(Some(OffsetShellResultRecord { solid, face_images }));
}
Ok(None) => {}
Err(error) => os_debug!("reflex by pieces: {} plane(s) declined: {error}", set.len()),
}
}
Ok(None)
};
match attempt() {
Ok(result) => result,
Err(error) => {
os_debug!("reflex by pieces declined: {error}");
None
}
}
}