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> {
let sharp = offset_body(source, &[], -distance, Body::Whole, "offset_solid")?;
match corners {
OffsetCorners::Sharp => Ok(sharp),
OffsetCorners::Rounded => round_offset_joins(sharp, distance),
}
}
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(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 { .. } | RefusalClass::DegenerateArrangement { .. } => {
refusal.message.starts_with("offset_shell")
}
_ => false,
}
}