use super::*;
pub fn fillet_edge(
solid: &BrepSolid,
edge_id: u64,
radius: f64,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
let _operation = crate::blend::BlendOperation::enter();
check_mixed_concavity(solid, edge_id, "fillet_edge")?;
check_support_extent_clipping(solid, edge_id, radius, "fillet_edge")?;
fillet_or_chamfer(solid, edge_id, radius, false, name, ToolEnds::default(), Lane::GeneralFirst)
}
pub(crate) fn fillet_edge_cutter(
solid: &BrepSolid,
edge_id: u64,
radius: f64,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
check_mixed_concavity(solid, edge_id, "fillet_edge")?;
check_support_extent_clipping(solid, edge_id, radius, "fillet_edge")?;
fillet_or_chamfer(solid, edge_id, radius, false, name, ToolEnds::default(), Lane::CutterFirst)
.map(settle_cutter_scaffold)
}
pub fn chamfer_edge(
solid: &BrepSolid,
edge_id: u64,
distance: f64,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
let _operation = crate::blend::BlendOperation::enter();
check_mixed_concavity(solid, edge_id, "chamfer_edge")?;
check_support_extent(solid, edge_id, distance, "chamfer_edge")?;
fillet_or_chamfer(solid, edge_id, distance, true, name, ToolEnds::default(), Lane::GeneralFirst)
}
fn apply_chamfer_offsets_profile(
solid: &BrepSolid,
cross: &EdgeCross,
profile: &[NurbsCurve],
name: Option<&str>,
edge_id: u64,
) -> Result<BrepSolid, KernelRefusal> {
if let Some(result) = super::tool::chamfer_through_end_faces(solid, edge_id, cross, profile, name) {
return Ok(settle_cutter_scaffold(result));
}
let mut tool = match &cross.path {
EdgePath::Straight { direction, length } => {
extrude_profile_brep(profile, *direction, *length)?
}
EdgePath::Circular { .. } => {
return Err(KernelRefusal::unsupported(
KernelStage::Classify,
"asymmetric_curved_edge",
"chamfer_edge_asymmetric: only straight edges on planar faces are supported \
in this slice (asymmetric chamfer on general/curved edges is out of scope)",
));
}
};
let mut side_index = 0usize;
for shell in &mut tool.shells {
for face in &mut shell.faces {
if side_index == 1 {
if face.name.is_none() {
face.name = name.map(str::to_string);
}
} else {
face.name = Some(scaffold_tag(name));
}
side_index += 1;
}
}
apply_tool(solid, &tool, cross.convex).map(settle_cutter_scaffold)
}
pub fn chamfer_edge_asymmetric(
solid: &BrepSolid,
edge_id: u64,
d1: f64,
d2: f64,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
let _operation = crate::blend::BlendOperation::enter();
if !(d1 > 0.0) || !(d2 > 0.0) || !d1.is_finite() || !d2.is_finite() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"setback_distances",
"chamfer_edge_asymmetric: both setback distances must be positive",
));
}
let cross = analyze_edge(solid, edge_id, d1.min(d2))?;
let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
apply_chamfer_offsets_profile(solid, &cross, &profile, name, edge_id)
}
pub fn chamfer_edge_angle(
solid: &BrepSolid,
edge_id: u64,
d1: f64,
angle_rad: f64,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
let _operation = crate::blend::BlendOperation::enter();
if !(d1 > 0.0) || !d1.is_finite() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"setback_d1",
"chamfer_edge_angle: setback distance d1 must be positive",
));
}
let cross = analyze_edge(solid, edge_id, d1)?;
let d2 = chamfer_angle_second_distance(&cross, d1, angle_rad)?;
let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
apply_chamfer_offsets_profile(solid, &cross, &profile, name, edge_id)
}
pub(super) fn heal_edge_vertex_gaps(
solid: &mut BrepSolid,
radius: f64,
) -> Result<(), KernelRefusal> {
let search = (radius.abs() * 1e-3).max(1e-7);
crate::boolean::commit_nearby_edge_endpoints(solid, search)
}
fn refuse_invalid_result(result: &BrepSolid, entry: &str) -> Result<(), KernelRefusal> {
let errors: Vec<_> = result.validate_memoised().into_iter().filter(|issue| issue.severity == "error").collect();
let Some(first) = errors.first() else {
return Ok(());
};
Err(KernelRefusal::new(
RefusalClass::InvalidResultTopology { issues: errors.len() as u32 },
KernelStage::Validate,
format!(
"{entry}: the blend built a solid that fails validation ({} errors, first: {}); \
refused rather than returned",
errors.len(),
first.message
),
))
}
pub(super) fn resolve_edge_by_point(solid: &BrepSolid, point: Vec3) -> Result<u64, KernelRefusal> {
match crate::topology::nearest_edge(solid, point) {
Some((edge_id, distance)) if distance <= 1e-3 => Ok(edge_id),
Some((_, distance)) => Err(KernelRefusal::input(
KernelStage::Collect,
"edge_point",
format!(
"fillet_edges: no edge within tolerance of the point (nearest {distance:.6})"
),
)),
None => Err(KernelRefusal::input(
KernelStage::Collect,
"no_edges",
"fillet_edges: solid has no edges",
)),
}
}
pub(crate) fn fillet_coedge_memo_on() -> bool {
switch!("BREP_FILLET_COEDGE_MEMO", true)
}
pub fn fillet_edges(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
chamfer: bool,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
let prepared = if crate::blend::creased_rim::creased_rim_split_on() {
crate::blend::creased_rim::split_creased_rims(solid, edge_points, edge_names, &resolve_edge_by_point)?
} else {
None
};
match prepared {
Some(prepared) => {
let names = prepared.names.as_deref();
let built = fillet_edges_prepared(&prepared.solid, &prepared.points, names, radius, chamfer, name)?;
Ok(prepared.rejoin(built))
}
None => {
if chamfer && super::chamfer_star::chamfer_mixed_star_on() {
let build = |body: &BrepSolid, points: &[Vec3], names: Option<&[String]>| {
fillet_edges_prepared(body, points, names, radius, true, name)
};
if let Some(built) = super::chamfer_star::chamfer_mixed_stars(solid, edge_points, edge_names, &build) {
return Ok(built);
}
}
let ordinary = fillet_edges_prepared(solid, edge_points, edge_names, radius, chamfer, name);
let answered = match ordinary {
Err(refusal)
if crate::blend::split_rim::split_rim_merge_on()
&& matches!(&refusal.class, crate::RefusalClass::UnsupportedGeometry { what } if what == super::analyze::TANGENT_ROLLOVER_ONLY_WHAT) =>
{
match crate::blend::split_rim::merge_split_rims(solid, edge_points, edge_names, &resolve_edge_by_point) {
Some(merged) => fillet_edges_prepared(&merged.solid, &merged.points, merged.names.as_deref(), radius, chamfer, name)
.map_err(|merged_refusal| {
refusal.with_message(|message| {
format!("{message} The rim merged into one closed edge refused too: {}", merged_refusal.message)
})
}),
None => Err(refusal),
}
}
other => other,
};
match answered {
Err(refusal) if solid.kind == crate::BodyKind::Sheet && crate::blend::sheet_ends::sheet_free_end_on() => {
let edge_ids = edge_points.iter().map(|point| resolve_edge_by_point(solid, *point)).collect::<Result<Vec<_>, _>>();
let Ok(edge_ids) = edge_ids else { return Err(refusal) };
let Some(closed) = crate::blend::sheet_ends::close_free_ends(solid, &edge_ids) else { return Err(refusal) };
let count = crate::blend::sheet_ends::temporary_faces(&closed);
match fillet_edges_prepared(&closed, edge_points, edge_names, radius, chamfer, name) {
Ok(built) => crate::blend::sheet_ends::open_free_ends(built, count).ok_or(refusal),
Err(closed_refusal) => Err(refusal.with_message(|message| {
format!("{message} The sheet with its free ends closed refused too: {}", closed_refusal.message)
})),
}
}
other => other,
}
}
}
}
fn fillet_edges_prepared(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
chamfer: bool,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
let _operation = crate::blend::BlendOperation::enter();
BrepSolid::clear_validate_memo();
let _coedge_memo = crate::topology::CoedgeMemoScope::open(fillet_coedge_memo_on() && crate::topology::coedge_memo_enabled());
let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
let cap = std::env::var_os("BREP_SUBSET_SEARCH_FULL").is_none().then_some(1);
let _cap = SubsetDropCap::set(cap);
let (result, selection) = match fillet_edges_reported(solid, edge_points, edge_names, radius, chamfer, name) {
Err(fold) if !chamfer && crate::blend::is_wall_fold(&fold) && crate::blend::relief::radius_relief_on() => {
let misfit = crate::blend::relief::is_misfit_fold(&fold);
let relieved = relieve_fold(solid, edge_points, edge_names, radius, name, fold);
if relieved.is_err() && misfit {
let _off = crate::blend::relief::MisfitOff::enter();
return fillet_edges(solid, edge_points, edge_names, radius, chamfer, name);
}
return relieved;
}
reported => reported?,
};
if selection.is_complete() {
return Ok(result);
}
let dropped: Vec<String> = selection
.rejected
.iter()
.map(|index| {
let point = edge_points[*index];
let named = edge_names
.and_then(|names| names.get(*index))
.map(|name| format!("`{name}` "))
.unwrap_or_default();
format!(
"{named}#{index} at ({:.6}, {:.6}, {:.6})",
point.x, point.y, point.z
)
})
.collect();
Err(KernelRefusal::ill_posed(
KernelStage::Select,
"incomplete_selection",
format!(
"{entry}: {} of the {} selected edges were blended — {} could not co-blend with the \
rest of the selection and carry no wall in the result ({}). The group refusal was: \
{}. Blend them separately, or call the reported entry point to accept the partial \
selection deliberately.",
selection.applied.len(),
selection.requested,
selection.rejected.len(),
dropped.join(", "),
selection
.reason
.as_deref()
.unwrap_or("the whole group built but not every edge grew a wall"),
),
))
}
fn relieve_fold(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
name: Option<&str>,
fold: KernelRefusal,
) -> Result<BrepSolid, KernelRefusal> {
let _relieving = crate::blend::relief::Relieving::enter();
let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
let selected: Vec<u64> = edge_points.iter().map(|point| resolve_edge_by_point(solid, *point)).collect::<Result<_, _>>()?;
let joined = crate::blend::relief::join_continuations(solid, &selected);
let solid = joined.as_ref().unwrap_or(solid);
let mut ids: Vec<u64> = Vec::with_capacity(edge_points.len());
let mut points: Vec<Vec3> = Vec::with_capacity(edge_points.len());
let mut names: Vec<String> = Vec::with_capacity(edge_points.len());
for (index, point) in edge_points.iter().enumerate() {
let id = resolve_edge_by_point(solid, *point)?;
if !ids.contains(&id) {
ids.push(id);
points.push(*point);
if let Some(name) = edge_names.and_then(|names| names.get(index)) {
names.push(name.clone());
}
}
}
let (edge_points, edge_names) = (&points[..], edge_names.map(|_| &names[..]));
if debug && joined.is_some() {
eprintln!("radius relief: joined continuation edges, {} selected edges become {}", selected.len(), ids.len());
}
let started = web_time::Instant::now();
let measured = match crate::blend::relief::measure_ceilings(solid, &ids, radius) {
Ok(measured) => measured,
Err(refusal) => return Err(fold.with_message(|message| format!("{message}; the relief could not measure the edges: {refusal}"))),
};
if debug {
eprintln!("radius relief: ceilings measured in {:.1} ms", started.elapsed().as_secs_f64() * 1e3);
}
let largest = measured.iter().flatten().map(|(_, ceiling)| *ceiling).fold(0.0_f64, f64::max);
if largest < radius {
return Err(fold.with_message(|message| {
format!("{message}; r {radius} fits nowhere along the selection, so it is not reduced locally: the largest ball that fits anywhere on it is {largest:.6}")
}));
}
let mut last: Option<KernelRefusal> = None;
for attempt in 0..crate::blend::relief::ATTEMPTS {
let plan = match crate::blend::relief::plan_relief(solid, &ids, radius, &measured, attempt) {
Ok(Some(plan)) => plan,
Ok(None) => break,
Err(refusal) => {
last = Some(refusal);
break;
}
};
let mark = crate::blend::blend_notes_mark();
match build_relief_plan(solid, edge_points, edge_names, &ids, radius, name, &plan) {
Ok(body) => {
for zone in &plan.zones {
let near: Vec<String> = zone
.edges
.iter()
.filter_map(|index| solid.edges.iter().find(|edge| edge.id == ids[*index]))
.map(|edge| edge.name.clone().unwrap_or_else(|| format!("edge #{}", edge.id)))
.map(|label| format!("`{label}`"))
.collect();
let (low, high) = zone
.edges
.iter()
.flat_map(|index| plan.stops[*index].iter().map(|(_, r)| *r))
.fold((f64::INFINITY, 0.0_f64), |(low, high), r| (low.min(r), high.max(r)));
let note = if high < radius * (1.0 - 1e-9) {
format!(
"radius reduced over the whole of {}: {low:.2} to {high:.2}, so r {radius} is reached nowhere \
on them (where they turn tightest the largest ball that fits is {:.2})",
near.join(", "),
zone.ceiling,
)
} else {
format!(
"radius reduced to {:.2} locally near {}: r {radius} does not fit there (the largest ball that does is {:.2})",
zone.radius,
near.join(", "),
zone.ceiling,
)
};
crate::blend::record_blend_note(note);
}
return Ok(body);
}
Err(refusal) => {
crate::blend::blend_notes_truncate(mark);
if debug {
eprintln!("radius relief attempt {attempt} refused at {:.1} ms: {refusal}", started.elapsed().as_secs_f64() * 1e3);
}
last = Some(refusal);
}
}
}
Err(match last {
Some(last) => fold.with_message(|message| format!("{message}; a locally reduced radius did not build either: {last}")),
None => fold,
})
}
fn build_relief_plan(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
ids: &[u64],
radius: f64,
name: Option<&str>,
plan: &crate::blend::relief::ReliefPlan,
) -> Result<BrepSolid, KernelRefusal> {
let count = ids.len();
let mut group: Vec<usize> = (0..count).collect();
let find = |group: &mut Vec<usize>, mut index: usize| {
while group[index] != index {
group[index] = group[group[index]];
index = group[index];
}
index
};
let vertices: Vec<[u64; 2]> = ids
.iter()
.map(|id| {
solid.edges.iter().find(|edge| edge.id == *id).map(|edge| [edge.start_vertex_id, edge.end_vertex_id]).unwrap_or([u64::MAX; 2])
})
.collect();
for a in 0..count {
for b in a + 1..count {
if vertices[a].iter().any(|vertex| *vertex != u64::MAX && vertices[b].contains(vertex)) {
let (root_a, root_b) = (find(&mut group, a), find(&mut group, b));
group[root_a] = root_b;
}
}
}
let reduced: Vec<bool> = plan.stops.iter().map(|stops| stops.iter().any(|(_, r)| *r < radius)).collect();
let relieved_group: Vec<bool> = (0..count)
.map(|index| {
let root = find(&mut group, index);
(0..count).any(|other| reduced[other] && find(&mut group, other) == root)
})
.collect();
let pick = |want: bool| -> (Vec<Vec3>, Option<Vec<String>>, Vec<Vec<(f64, f64)>>) {
let indices: Vec<usize> = (0..count).filter(|index| relieved_group[*index] == want).collect();
(
indices.iter().map(|index| edge_points[*index]).collect(),
edge_names.map(|names| indices.iter().filter_map(|index| names.get(*index).cloned()).collect()),
indices.iter().map(|index| plan.stops[*index].clone()).collect(),
)
};
let (constant_points, constant_names, _) = pick(false);
let (relieved_points, relieved_names, relieved_stops) = pick(true);
let mut body = solid.clone();
if !constant_points.is_empty() {
body = fillet_edges(&body, &constant_points, constant_names.as_deref(), radius, false, name)?;
}
let built = fillet_edges_variable_impl(
&body,
&relieved_points,
relieved_names.as_deref(),
&relieved_stops,
false,
name,
"fillet_edges",
false,
)?;
let issues = built.validate();
if !issues.is_empty() {
return Err(KernelRefusal::internal(
KernelStage::Validate,
"relief_validate",
format!("radius relief: the relieved fillet does not validate: {issues:?}"),
));
}
Ok(built)
}
#[derive(Clone, Debug)]
pub struct BlendSelection {
pub requested: usize,
pub applied: Vec<usize>,
pub rejected: Vec<usize>,
pub reason: Option<String>,
}
impl BlendSelection {
pub fn is_complete(&self) -> bool {
self.rejected.is_empty()
}
}
pub fn fillet_edges_reported_at_offset(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
chamfer: bool,
name: Option<&str>,
offset: Vec3,
) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Control)
}
#[allow(clippy::too_many_arguments)]
fn fillet_translated(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
chamfer: bool,
name: Option<&str>,
offset: Vec3,
kind: crate::recentre::EntryKind,
) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
let _entry = crate::recentre::TranslatedEntry::open(kind);
let translate = |solid: &BrepSolid, offset: Vec3| {
crate::recentre::translate_solid(solid, offset).map_err(|message| {
KernelRefusal::internal(
KernelStage::Collect,
"translation_control",
format!("fillet_edges: the translation control could not move the solid: {message}"),
)
})
};
let moved = translate(solid, offset)?;
let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
let (result, selection) = fillet_edges_reported(&moved, &points, edge_names, radius, chamfer, name)?;
Ok((translate(&result, offset.scale(-1.0))?, selection))
}
pub fn fillet_edges_reported(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
chamfer: bool,
name: Option<&str>,
) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
if let Some(offset) = crate::recentre::control_offset_at_entry() {
return fillet_edges_reported_at_offset(solid, edge_points, edge_names, radius, chamfer, name, offset);
}
let bounds = crate::recentre::solid_bounds(solid);
if let Some(offset) = crate::recentre::guard_offset(bounds) {
crate::recentre::profile_fired("fillet", offset, bounds);
return fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Guard);
}
let _operation = crate::blend::BlendOperation::enter();
let _total = crate::blend::BlendTimer::start("timing.total_ms");
if !(radius > 0.0) || !radius.is_finite() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"radius",
"fillet_edges: radius must be positive",
));
}
if edge_points.is_empty() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"selection",
"fillet_edges: no edges selected",
));
}
let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
let mut selected_ids = Vec::with_capacity(edge_points.len());
if crate::blend::blend_operation_outermost() {
super::analyze::clear_rollover_only();
}
let selection_checks = crate::blend::BlendTimer::start("timing.selection_checks_ms");
let _boundaries = super::analyze::SupportBoundaryScope::open();
for point in edge_points {
let resolve = crate::blend::BlendTimer::start("timing.selection_checks.resolve_ms");
let edge_id = resolve_edge_by_point(solid, *point)?;
resolve.stop();
let concavity = crate::blend::BlendTimer::start("timing.selection_checks.concavity_ms");
check_mixed_concavity(solid, edge_id, entry)?;
concavity.stop();
let _extent = crate::blend::BlendTimer::start("timing.selection_checks.extent_ms");
if edge_points.len() == 1 && !chamfer {
check_support_extent_clipping(solid, edge_id, radius, entry)?;
} else {
check_support_extent(solid, edge_id, radius, entry)?;
}
selected_ids.push(edge_id);
}
drop(_boundaries);
selection_checks.stop();
if !chamfer {
let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
let mut corners = crate::blend::mixed_convexity_corners(solid, &selected_ids, radius);
corners.extend(crate::blend::runout::one_ended_runout_corners(solid, &selected_ids, radius));
corner_analysis.stop();
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
for corner in &corners {
eprintln!(
"mixed corner at vertex {} ({:.4}, {:.4}, {:.4}): convex {:?} concave {:?}",
corner.vertex, corner.point.x, corner.point.y, corner.point.z,
corner.convex.iter().map(|index| selected_ids[*index]).collect::<Vec<_>>(),
corner.concave.iter().map(|index| selected_ids[*index]).collect::<Vec<_>>()
);
}
}
if !corners.is_empty() {
RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(false));
let composed = rounded_boss_composition(
solid, &selected_ids, edge_points, edge_names, radius, name, &corners,
).or_else(|error| {
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!("rounded boss composition refused: {error}");
}
runout_composition(
solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
)
}).or_else(|error| {
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!("runout composition refused: {error}");
}
open_pad_composition(
solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
).map_err(|pad_error| {
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!("open-pad composition refused: {pad_error}");
}
let declined = matches!(&pad_error.class, crate::RefusalClass::UnsupportedGeometry { what } if what == "open_pad_shape");
if declined { error } else { pad_error }
})
}).or_else(|error| {
cusp_composition(solid, &selected_ids, edge_points, edge_names, radius, name, &corners).map_err(|cusp_error| {
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!("cusp composition refused: {cusp_error}");
}
error
})
});
match composed {
Ok(result) => {
let network_ring = RUNOUT_RING_IS_NETWORK.with(|flag| flag.replace(false));
refuse_invalid_result(&result, entry)?;
let missing = walls_missing_ties(
solid, &result, edge_points, &(0..edge_points.len()).collect::<Vec<_>>(), radius, chamfer, entry, network_ring,
)?;
if !missing.is_empty() {
return Err(KernelRefusal::internal(
KernelStage::Validate,
"runout_walls_missing",
format!(
"{entry}: the composition built a solid with no wall on {}",
missing.iter().map(|index| selection_label(*index, edge_points, edge_names, name)).collect::<Vec<_>>().join(", ")
),
));
}
let n = edge_points.len();
return Ok((
result,
BlendSelection { requested: n, applied: (0..n).collect(), rejected: Vec::new(), reason: None },
));
}
Err(error) => {
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!("runout composition refused: {error}");
}
check_mixed_corner_convexity(
solid, &selected_ids, edge_points, edge_names, radius, name, Some(&error),
)?;
}
}
}
let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
check_degenerate_corner_setbacks(
solid,
&selected_ids,
edge_points,
edge_names,
radius,
name,
)?;
corner_analysis.stop();
}
let n = edge_points.len();
let complete = |applied: Vec<usize>| BlendSelection {
requested: n,
applied,
rejected: Vec::new(),
reason: None,
};
let mut memo = ComponentMemo::default();
let profile = crate::profile::profile_enabled();
let group_started = web_time::Instant::now();
let group = fillet_edges_group_in(
solid,
edge_points,
edge_names,
radius,
chamfer,
name,
Some(&mut memo),
);
if profile {
profile_line!(
"blend.selection group edges={n} ok={} ms={:.3}",
group.is_ok(),
group_started.elapsed().as_secs_f64() * 1e3
);
}
let group_err = match group {
Ok(result) if refuse_invalid_result(&result, entry).is_err() => {
refuse_invalid_result(&result, entry).unwrap_err()
}
Ok(result) => {
let missing =
walls_missing(solid, &result, edge_points, &(0..n).collect::<Vec<_>>(), radius, chamfer, entry)?;
if missing.is_empty() {
return Ok((result, complete((0..n).collect())));
}
let applied: Vec<usize> = (0..n).filter(|index| !missing.contains(index)).collect();
return Ok((
result,
BlendSelection {
requested: n,
applied,
rejected: missing,
reason: None,
},
));
}
Err(error) => error,
};
if crate::blend::is_wall_fold(&group_err) || crate::blend::is_miter_support_overrun(&group_err)
|| crate::blend::is_tangential_transverse_end(&group_err)
|| matches!(&group_err.class, RefusalClass::UnsupportedGeometry { what } if what == "canal_footprint_bound")
{
return Err(group_err);
}
if n < 2 || n > 12 {
return Err(group_err);
}
let max_drop = SUBSET_DROP_CAP.with(|cap| cap.get()).map_or(n - 1, |cap| cap.min(n - 1));
for drop in 1..=max_drop {
for dropped in index_combinations(n, drop) {
let keep: Vec<usize> = (0..n).filter(|i| !dropped.contains(i)).collect();
let points: Vec<Vec3> = keep.iter().map(|i| edge_points[*i]).collect();
let kept_names: Option<Vec<String>> =
edge_names.map(|names| keep.iter().map(|i| names[*i].clone()).collect());
crate::blend::record_count("count.subset.builds", 1);
let subset_started = web_time::Instant::now();
let subset = fillet_edges_group_in(
solid,
&points,
kept_names.as_deref(),
radius,
chamfer,
name,
Some(&mut memo),
);
if profile {
profile_line!(
"blend.selection subset dropped={dropped:?} ok={} ms={:.3}",
subset.is_ok(),
subset_started.elapsed().as_secs_f64() * 1e3
);
}
let result = match subset {
Ok(result) => result,
Err(error) => {
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!("subset dropped={dropped:?} refused: {error}");
}
continue;
}
};
if !result.validate().is_empty() {
continue;
}
if !walls_missing(solid, &result, edge_points, &keep, radius, chamfer, entry)?.is_empty() {
continue;
}
return Ok((
result,
BlendSelection {
requested: n,
applied: keep,
rejected: dropped,
reason: Some(group_err.message),
},
));
}
}
Err(group_err)
}
pub(crate) fn walls_missing_nearest_on() -> bool {
switch!("BREP_WALLS_MISSING_NEAREST", true)
}
pub(super) fn walls_missing(
original: &BrepSolid,
result: &BrepSolid,
edge_points: &[Vec3],
selected: &[usize],
size: f64,
chamfer: bool,
entry: &str,
) -> Result<Vec<usize>, KernelRefusal> {
walls_missing_ties(original, result, edge_points, selected, size, chamfer, entry, false)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn walls_missing_ties(
original: &BrepSolid,
result: &BrepSolid,
edge_points: &[Vec3],
selected: &[usize],
size: f64,
chamfer: bool,
entry: &str,
tie_majority: bool,
) -> Result<Vec<usize>, KernelRefusal> {
use crate::{PointClass, SolidClassifier};
if selected.is_empty() {
return Ok(Vec::new());
}
let _timing = crate::blend::BlendTimer::start("timing.walls_missing_ms");
crate::classification::classify_profile_begin();
crate::classification::containment_profile_begin();
let band = crate::KernelTolerances::for_solid(result, 1e-7).model;
let visible = band * 100.0;
let build_timing = crate::blend::BlendTimer::start("timing.walls_missing.classifier_ms");
let classifier = SolidClassifier::new(result, band)
.or_refuse(KernelStage::Validate, "wall_classifier")?;
let nearest = if walls_missing_nearest_on() {
Some(super::analyze::NearestFaceSide::new(result, band).or_refuse(KernelStage::Validate, "wall_nearest_face")?)
} else {
None
};
let reads_on = |point: Vec3| -> Result<bool, KernelRefusal> {
if let Some(class) = nearest.as_ref().and_then(|nearest| nearest.side(point)) {
crate::blend::record_count("count.walls_missing.nearest", 1);
return Ok(class == PointClass::On);
}
Ok(classifier.classify(point).or_refuse(KernelStage::Validate, "wall_classify")?.class == PointClass::On)
};
build_timing.stop();
const STATIONS: usize = 9;
let mut missing = Vec::new();
for &index in selected {
let point = edge_points[index];
let edge_id = resolve_edge_by_point(original, point)?;
let (edge, faces) = super::analyze::edge_and_faces(original, edge_id)?;
let mut best: Option<(Vec3, f64)> = None;
let mut read: Vec<(Vec3, f64)> = Vec::with_capacity(STATIONS);
for step in 1..=STATIONS {
let t = edge.t0 + (edge.t1 - edge.t0) * step as f64 / (STATIONS + 1) as f64;
let Ok(station) = edge.curve.evaluate(t) else {
continue;
};
let clearance = match faces[..] {
[first, second] => {
let (Ok((_, a)), Ok((_, b))) = (
super::analyze::face_plane_normal(first, station),
super::analyze::face_plane_normal(second, station),
) else {
continue;
};
let half: f64 = 0.5 * a.dot(b).clamp(-1.0, 1.0).acos();
if chamfer {
size * half.sin()
} else {
size * (1.0 / half.cos().max(1e-12) - 1.0)
}
}
_ => f64::INFINITY,
};
if best.map_or(true, |(_, known)| clearance > known) {
best = Some((station, clearance));
}
read.push((station, clearance));
}
let Some((station, clearance)) = best else {
return Err(KernelRefusal::internal(
KernelStage::Validate,
"wall_attribution_stations",
format!(
"{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — no \
station of it could be evaluated",
point.x, point.y, point.z
),
));
};
if !(clearance > visible) {
return Err(KernelRefusal::ill_posed(
KernelStage::Validate,
"wall_attribution_band",
format!(
"{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — \
its faces are so nearly tangent that a size-{size} blend moves the edge by at \
most {clearance:.3e}, inside the {visible:.3e} the classifier can see; the \
selection is refused rather than reported on a guess",
point.x, point.y, point.z
),
));
}
let classify_timing = crate::blend::BlendTimer::start("timing.walls_missing.classify_ms");
let certified = station_clear_of_skin(result, station, band * 10.0, visible)
|| station_clear_of_trimmed_skin(result, station, band * 10.0, visible);
let on = if certified {
crate::blend::record_count("count.walls_missing.certified", 1);
false
} else {
crate::blend::record_count("count.walls_missing.classified", 1);
reads_on(station)?
};
classify_timing.stop();
let on = on && (!tie_majority || {
let tolerance = 1e-12 * clearance.abs().max(1.0);
let (mut tied, mut on_skin) = (1usize, 1usize);
for (other, other_clearance) in read.iter().copied() {
if other.sub(station).length() == 0.0 || clearance - other_clearance > tolerance {
continue;
}
tied += 1;
if !station_clear_of_skin(result, other, band * 10.0, visible) && reads_on(other)? {
on_skin += 1;
}
}
2 * on_skin > tied
});
if on {
missing.push(index);
}
}
crate::classification::classify_profile_report("blend.walls_missing");
crate::classification::containment_profile_report("blend.walls_missing");
Ok(missing)
}
fn station_clear_of_skin(result: &BrepSolid, station: Vec3, on_band: f64, margin: f64) -> bool {
use crate::AnalyticSurface;
let meridian = |frame: &crate::RevolutionFrame| -> (f64, f64, f64) {
let offset = station.sub(frame.origin);
let axial = offset.dot(frame.axis);
let radial = offset.sub(frame.axis.scale(axial));
let mut azimuth = radial.dot(frame.y_axis).atan2(radial.dot(frame.x_axis));
if azimuth < 0.0 {
azimuth += std::f64::consts::TAU;
}
(radial.length(), axial, azimuth)
};
let axial_clearance = |axial: f64, h0: f64, h1: f64| (h0.min(h1) - axial).max(axial - h0.max(h1)).max(0.0);
let wedge_clearance = |radial: f64, azimuth: f64, sweep: f64| -> f64 {
if sweep >= std::f64::consts::TAU || azimuth <= sweep {
return 0.0;
}
let turn = (azimuth - sweep).min(std::f64::consts::TAU - azimuth);
if turn >= std::f64::consts::FRAC_PI_2 { radial } else { radial * turn.sin() }
};
let line_distance = |radial: f64, axial: f64, (rho0, h0): (f64, f64), (rho1, h1): (f64, f64)| -> Option<f64> {
let (du, dv) = (rho1 - rho0, h1 - h0);
let length = (du * du + dv * dv).sqrt();
(length > 0.0).then(|| ((radial - rho0) * dv - (axial - h0) * du).abs() / length)
};
let clear = |distance: f64| distance.is_finite() && distance > margin;
for face in result.shells.iter().flat_map(|shell| &shell.faces) {
let Ok(controls) = crate::Aabb::from_surface_controls(&face.surface) else { return false };
if !controls.expanded(on_band).contains(station) {
continue;
}
let certified = match face.surface.analytic() {
Some(AnalyticSurface::RuledRevolution { frame, rho0, rho1, height }) => {
let (radial, axial, _) = meridian(frame);
let Some(line) = line_distance(radial, axial, (*rho0, 0.0), (*rho1, *height)) else { return false };
clear(line.max(axial_clearance(axial, 0.0, *height)))
}
Some(AnalyticSurface::Sphere { frame, radius }) => clear((station.sub(frame.origin).length() - radius).abs()),
Some(AnalyticSurface::Torus { frame, major_radius, minor_radius }) => {
let (radial, axial, _) = meridian(frame);
let tube = radial - major_radius;
clear(((tube * tube + axial * axial).sqrt() - minor_radius).abs())
}
Some(AnalyticSurface::Revolution { frame, sweep, generatrix, .. }) => {
let [a, b] = generatrix.control_points.as_slice() else { return false };
if generatrix.degree != 1 || a.w != 1.0 || b.w != 1.0 {
return false;
}
let meridian_of = |point: Vec3| -> Option<(f64, f64)> {
let offset = point.sub(frame.origin);
let axial = offset.dot(frame.axis);
let radial = offset.sub(frame.axis.scale(axial));
let rho = radial.length();
let on_meridian = radial.dot(frame.y_axis).abs() <= 1e-9 * rho.max(1.0) && radial.dot(frame.x_axis) >= -1e-9 * rho.max(1.0);
on_meridian.then_some((rho, axial))
};
let (Some(p0), Some(p1)) = (meridian_of(Vec3::new(a.x, a.y, a.z)), meridian_of(Vec3::new(b.x, b.y, b.z))) else {
return false;
};
let (radial, axial, azimuth) = meridian(frame);
let Some(line) = line_distance(radial, axial, p0, p1) else { return false };
let slab = trimmed_axial_slab(face, frame).unwrap_or((p0.1, p1.1));
clear(line.max(axial_clearance(axial, slab.0, slab.1)).max(wedge_clearance(radial, azimuth, *sweep)))
}
Some(plane @ AnalyticSurface::Plane { origin, u_dir, v_dir, .. }) => {
let Ok(normal) = u_dir.cross(*v_dir).normalized() else { return false };
if clear(station.sub(*origin).dot(normal).abs()) {
true
} else {
planar_trims_clear_of(face, plane, station, on_band, margin)
}
}
None => false,
};
if !certified {
return false;
}
}
true
}
fn station_clear_of_trimmed_skin(result: &BrepSolid, point: Vec3, on_band: f64, margin: f64) -> bool {
let _timer = crate::blend::BlendTimer::start("timing.walls_missing.trimmed_bound_ms");
if !point.x.is_finite() || !point.y.is_finite() || !point.z.is_finite()
|| !margin.is_finite() || !(margin > on_band && on_band > 0.0)
{ return false; }
for face in result.shells.iter().flat_map(|shell| &shell.faces) {
let Ok(bounds) = crate::Aabb::from_surface_controls(&face.surface) else { return false; };
if !bounds.expanded(margin).contains(point) { continue; }
if face.surface.analytic().is_none() {
if general_carrier_clear(&face.surface, point, margin) { continue; }
return false;
}
match face.surface.analytic() {
Some(crate::AnalyticSurface::Plane { .. }) => {},
Some(crate::AnalyticSurface::Sphere { radius, .. }) if *radius > 4.0 * margin => {},
Some(crate::AnalyticSurface::Torus { major_radius, minor_radius, .. })
if *minor_radius > 4.0 * margin && *major_radius > *minor_radius + 4.0 * margin => {},
_ => return false,
}
let Ok(projection) = crate::project_point_to_surface(&face.surface, point) else { return false; };
if !projection.distance.is_finite() { return false; }
if projection.distance > margin { continue; }
let band = crate::classification::surface_uv_band(&face.surface, projection.u, projection.v, on_band);
let Ok(inside) = crate::parameter_point_in_face(face, crate::Vec2 { x: projection.u, y: projection.v }, band) else { return false; };
if inside != crate::PolygonClass::Outside { return false; }
let mut edges = 0;
for coedge in face.loops.iter().flat_map(|lp| &lp.coedges) {
let Some(edge) = result.edges.iter().find(|edge| edge.id == coedge.edge_id) else { return false; };
let Some(piece) = super::analyze::edge_piece(&edge.curve, edge.t0.min(edge.t1), edge.t0.max(edge.t1)) else { return false; };
let Ok(foot) = crate::project_point_to_curve(&piece, point) else { return false; };
if !foot.distance.is_finite() || foot.distance <= margin { return false; }
edges += 1;
}
if edges == 0 { return false; }
}
true
}
fn general_carrier_clear(surface: &crate::NurbsSurface, point: Vec3, margin: f64) -> bool {
use crate::Vec4;
let rows = surface.control_points.len();
let columns = surface.control_points.first().map_or(0, Vec::len);
if rows == 0 || columns == 0 || rows.saturating_mul(columns) > 4096
|| surface.degree_u > 16 || surface.degree_v > 16 || surface.control_points.iter().any(|row| row.len() != columns)
|| !margin.is_finite() || margin <= 0.0
|| !point.x.is_finite() || !point.y.is_finite() || !point.z.is_finite()
{ return false; }
let mut scale = 1.0_f64;
for control in surface.control_points.iter().flatten() {
if !(control.w > 0.0) || !control.w.is_finite() { return false; }
let Ok(p) = control.point() else { return false; };
if !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite() { return false; }
scale = scale.max(p.x.abs()).max(p.y.abs()).max(p.z.abs());
}
let roundoff = 256.0 * f64::EPSILON * scale * (surface.degree_u + 1) as f64 * (surface.degree_v + 1) as f64;
if roundoff >= margin * 0.01 { return false; }
let zero = Vec4 { x: 0.0, y: 0.0, z: 0.0, w: 0.0 };
let blend = |a: Vec4, b: Vec4, alpha: f64| a.scale(alpha).add(b.scale(1.0 - alpha));
let mut fibres = Vec::with_capacity(columns);
for column in 0..columns {
let controls: Vec<_> = surface.control_points.iter().map(|row| row[column]).collect();
let Some(spans) = crate::curve::bezier_decompose_raw(&surface.knots_u, &controls, surface.degree_u, zero, blend) else { return false; };
fibres.push(spans);
}
let mut budget = 256usize;
for u_span in 0..fibres[0].len() {
let mut across = Vec::with_capacity(surface.degree_u + 1);
for row in 0..=surface.degree_u {
let controls: Vec<_> = fibres.iter().map(|fibre| fibre[u_span][row]).collect();
let Some(spans) = crate::curve::bezier_decompose_raw(&surface.knots_v, &controls, surface.degree_v, zero, blend) else { return false; };
across.push(spans);
}
for v_span in 0..across[0].len() {
let net = across.iter().map(|row| row[v_span].clone()).collect();
if !bezier_box_clear(net, point, margin + roundoff, 0, &mut budget) { return false; }
}
}
true
}
fn bezier_box_clear(net: Vec<Vec<crate::Vec4>>, point: Vec3, margin: f64, depth: usize, budget: &mut usize) -> bool {
if *budget == 0 { return false; }
*budget -= 1;
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 control in net.iter().flatten() {
if !(control.w > 0.0) || !control.w.is_finite() { return false; }
let Ok(p) = control.point() else { return false; };
if !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite() { return false; }
low.x = low.x.min(p.x); low.y = low.y.min(p.y); low.z = low.z.min(p.z);
high.x = high.x.max(p.x); high.y = high.y.max(p.y); high.z = high.z.max(p.z);
}
if point.x < low.x - margin || point.x > high.x + margin
|| point.y < low.y - margin || point.y > high.y + margin
|| point.z < low.z - margin || point.z > high.z + margin
{ return true; }
if depth == 10 { return false; }
let rows = net.len();
let columns = net[0].len();
let mut left = net.clone();
let mut right = net.clone();
if depth % 2 == 0 {
for column in 0..columns {
let mut work: Vec<_> = net.iter().map(|row| row[column]).collect();
for level in 1..rows {
for i in 0..rows - level { work[i] = work[i].scale(0.5).add(work[i + 1].scale(0.5)); }
left[level][column] = work[0];
right[rows - level - 1][column] = work[rows - level - 1];
}
}
} else {
for row in 0..rows {
let mut work = net[row].clone();
for level in 1..columns {
for i in 0..columns - level { work[i] = work[i].scale(0.5).add(work[i + 1].scale(0.5)); }
left[row][level] = work[0];
right[row][columns - level - 1] = work[columns - level - 1];
}
}
}
bezier_box_clear(left, point, margin, depth + 1, budget)
&& bezier_box_clear(right, point, margin, depth + 1, budget)
}
fn trimmed_axial_slab(face: &FaceRecord, frame: &crate::RevolutionFrame) -> Option<(f64, f64)> {
let (mut u_low, mut u_high, mut v_low, mut v_high) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
let pcurve = &coedge.pcurve;
let [a, b] = pcurve.control_points.as_slice() else { return None };
if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
return None;
}
for control in [a, b] {
u_low = u_low.min(control.x);
u_high = u_high.max(control.x);
v_low = v_low.min(control.y);
v_high = v_high.max(control.y);
}
}
if !(u_low <= u_high && v_low <= v_high) {
return None;
}
let axial_at = |u: f64, v: f64| face.surface.evaluate(u, v).ok().map(|point| point.sub(frame.origin).dot(frame.axis));
let corners = [axial_at(u_low, v_low)?, axial_at(u_high, v_low)?, axial_at(u_low, v_high)?, axial_at(u_high, v_high)?];
let scale = corners.iter().fold(1.0_f64, |scale, h| scale.max(h.abs()));
let same = |a: f64, b: f64| (a - b).abs() <= 1e-9 * scale;
let surface = &face.surface;
let affine_in_v = same(corners[0], corners[1]) && same(corners[2], corners[3])
&& surface.degree_v == 1 && surface.control_points.first().is_some_and(|row| row.len() == 2);
let affine_in_u = same(corners[0], corners[2]) && same(corners[1], corners[3])
&& surface.degree_u == 1 && surface.control_points.len() == 2;
if !(affine_in_v || affine_in_u) {
return None;
}
let low = corners.iter().copied().fold(f64::INFINITY, f64::min);
let high = corners.iter().copied().fold(f64::NEG_INFINITY, f64::max);
(low.is_finite() && high.is_finite()).then_some((low, high))
}
fn planar_trims_clear_of(
face: &FaceRecord,
plane: &crate::AnalyticSurface,
station: Vec3,
on_band: f64,
margin: f64,
) -> bool {
use crate::PolygonClass;
let Some(foot) = plane.project(&face.surface, station) else { return false };
let uv_band = crate::classification::surface_uv_band(&face.surface, foot.u, foot.v, on_band);
match crate::parameter_point_in_face(face, crate::Vec2 { x: foot.u, y: foot.v }, uv_band) {
Ok(PolygonClass::Outside) => {}
_ => return false,
}
for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
let pcurve = &coedge.pcurve;
let [a, b] = pcurve.control_points.as_slice() else { return false };
if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
return false;
}
let (Ok(start), Ok(end)) = (face.surface.evaluate(a.x, a.y), face.surface.evaluate(b.x, b.y)) else { return false };
let chord = end.sub(start);
let length_squared = chord.dot(chord);
let offset = station.sub(start);
let along = if length_squared > 0.0 { (offset.dot(chord) / length_squared).clamp(0.0, 1.0) } else { 0.0 };
let distance = offset.sub(chord.scale(along)).length();
if !(distance.is_finite() && distance > margin) {
return false;
}
}
true
}
fn per_edge_name<'a>(
edge_names: Option<&'a [String]>,
base: Option<&'a str>,
i: usize,
) -> Option<&'a str> {
match edge_names {
Some(names) => names.get(i).map(|value| value.as_str()),
None => base,
}
}
fn corner_face_name(
edge_names: Option<&[String]>,
base: Option<&str>,
adjacent: &[usize],
) -> Option<String> {
match (edge_names, base) {
(Some(names), Some(base)) => {
let prefix = format!("{base}:");
let mut raws: Vec<&str> = adjacent
.iter()
.filter_map(|&i| names.get(i))
.map(|composed| composed.strip_prefix(&prefix).unwrap_or(composed.as_str()))
.collect();
raws.sort_unstable();
raws.dedup();
Some(format!("{base}:CORNER:{}", raws.join("+")))
}
_ => base.map(|value| value.to_string()),
}
}
#[allow(clippy::too_many_arguments)]
fn rounded_boss_composition(
solid: &BrepSolid,
selected_ids: &[u64],
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
name: Option<&str>,
corners: &[crate::blend::MixedCorner],
) -> Result<BrepSolid, KernelRefusal> {
if edge_names.is_none() {
let prefix = "FILLET:COMPOSITION";
let names: Vec<_> = selected_ids
.iter()
.map(|id| format!("{prefix}:{id}"))
.collect();
let mut result = rounded_boss_composition(
solid,
selected_ids,
edge_points,
Some(&names),
radius,
Some(prefix),
corners,
)?;
for face in result.shells.iter_mut().flat_map(|shell| &mut shell.faces) {
if face
.name
.as_deref()
.is_some_and(|value| value.starts_with("FILLET:COMPOSITION:"))
{
face.name = name.map(str::to_owned);
}
}
return Ok(result);
}
let decline = || {
KernelRefusal::unsupported(KernelStage::Classify, "rounded_boss_selection",
"the rounded boss composition requires a convex group meeting transverse mixed corners at one end of each spine")
};
if std::env::var("BREP_NO_NETWORK").is_ok() {
return Err(decline());
}
let corner = corners
.iter()
.filter(|corner| {
corner.convex.len() == 1
&& corner.concave.len() == 2
&& corners
.iter()
.filter(|other| other.convex.contains(&corner.convex[0]))
.count()
== 1
})
.min_by_key(|corner| selected_ids[corner.convex[0]])
.ok_or_else(decline)?;
let convexity =
crate::blend::selection_convexity(solid, selected_ids, radius).ok_or_else(decline)?;
if !convexity[corner.convex[0]] {
return Err(decline());
}
let spine_index = corner.convex[0];
let selected_edges: Vec<&EdgeRecord> = selected_ids
.iter()
.map(|id| {
solid
.edges
.iter()
.find(|edge| edge.id == *id)
.ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Collect,
"rounded_boss_selected_edge",
format!("rounded boss composition: selected edge {id} is missing"),
)
})
})
.collect::<Result<_, _>>()?;
let mut rounded_indices = vec![spine_index];
loop {
let next = (0..selected_ids.len()).find(|index| {
convexity[*index]
&& !rounded_indices.contains(index)
&& rounded_indices.iter().any(|other| {
let edge = selected_edges[*index];
let mate = selected_edges[*other];
[edge.start_vertex_id, edge.end_vertex_id]
.iter()
.any(|v| *v == mate.start_vertex_id || *v == mate.end_vertex_id)
})
});
match next {
Some(index) => rounded_indices.push(index),
None => break,
}
}
loop {
let next = (0..selected_ids.len()).find(|&index| {
if convexity[index] || rounded_indices.contains(&index) {
return false;
}
let edge = selected_edges[index];
let mut has_star = false;
for vertex in [edge.start_vertex_id, edge.end_vertex_id] {
let neighbours: Vec<_> = selected_edges.iter().enumerate()
.filter(|(other, mate)| *other != index
&& (mate.start_vertex_id == vertex || mate.end_vertex_id == vertex))
.map(|(other, _)| other)
.collect();
if neighbours.iter().any(|other| !rounded_indices.contains(other)) {
return false;
}
has_star |= neighbours.len() >= 2;
}
has_star
});
match next {
Some(index) => rounded_indices.push(index),
None => break,
}
}
rounded_indices.sort_by_key(|index| selected_ids[*index]);
let mixed: Vec<_> = corners
.iter()
.filter(|corner| {
corner
.convex
.iter()
.any(|index| rounded_indices.contains(index))
})
.collect();
if mixed.iter().any(|corner| {
corner.convex.len() != 1
|| corner.concave.len() != 2
|| corners
.iter()
.filter(|other| other.convex.contains(&corner.convex[0]))
.count()
!= 1
}) {
return Err(decline());
}
let uses = |face: &FaceRecord, edge: u64| {
face.loops
.iter()
.flat_map(|rim| &rim.coedges)
.any(|coedge| coedge.edge_id == edge)
};
let mut caps = Vec::new();
for corner in &mixed {
let spine_id = selected_ids[corner.convex[0]];
let cap = solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.find(|face| {
corner
.concave
.iter()
.all(|index| uses(face, selected_ids[*index]))
})
.ok_or_else(decline)?;
let faces: Vec<_> = solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.filter(|face| face.id == cap.id || uses(face, spine_id))
.collect();
let normals: Option<Vec<Vec3>> = faces
.iter()
.map(|face| match face.surface.analytic() {
Some(crate::AnalyticSurface::Plane { u_dir, v_dir, .. }) => {
u_dir.cross(*v_dir).normalized().ok()
}
_ => {
let uv = crate::project_point_to_surface(&face.surface, corner.point).ok()?;
face.surface.normal(uv.u, uv.v).ok()
}
})
.collect();
let normals = normals.ok_or_else(decline)?;
if normals.len() != 3
|| normals[0].cross(normals[1]).dot(normals[2]).abs() < 1e-6
{
return Err(decline());
}
caps.push(cap.id);
}
let rounded_names: Vec<String> = rounded_indices
.iter()
.map(|index| {
per_edge_name(edge_names, name, *index)
.map(str::to_owned)
.unwrap_or_else(|| format!("FILLET:ROUNDED_BOSS_SPINE:{index}"))
})
.collect();
let rounded_points: Vec<Vec3> = rounded_indices
.iter()
.map(|index| edge_points[*index])
.collect();
let rounded = fillet_edges_group_in(
solid,
&rounded_points,
Some(&rounded_names),
radius,
false,
name,
None,
)?;
let mut bridges = Vec::new();
for (corner, cap) in mixed.iter().zip(&caps) {
let wall_index = rounded_indices
.iter()
.position(|index| *index == corner.convex[0])
.ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Classify,
"rounded_boss_bridge_wall",
format!(
"rounded boss composition: no convex wall for corner {}",
corner.vertex
),
)
})?;
let candidates: Vec<_> = rounded
.edges
.iter()
.filter(|edge| {
let faces: Vec<_> = rounded
.shells
.iter()
.flat_map(|shell| &shell.faces)
.filter(|face| uses(face, edge.id))
.collect();
faces.iter().any(|face| face.id == *cap)
&& faces
.iter()
.any(|face| face.name.as_deref() == Some(&rounded_names[wall_index]))
})
.collect();
if candidates.len() != 1 {
return Err(decline());
}
bridges.push(candidates[0]);
}
let rim_ids: Vec<u64> = selected_ids
.iter()
.enumerate()
.filter(|(index, _)| !rounded_indices.contains(index))
.map(|(_, id)| *id)
.collect();
if !bridges.iter().all(|bridge| {
[bridge.start_vertex_id, bridge.end_vertex_id]
.iter()
.all(|vertex| {
rounded.edges.iter().any(|edge| {
rim_ids.contains(&edge.id)
&& (edge.start_vertex_id == *vertex || edge.end_vertex_id == *vertex)
})
})
}) {
return Err(decline());
}
let mut points = Vec::new();
let mut names = Vec::new();
for (index, id) in selected_ids.iter().enumerate() {
if rounded_indices.contains(&index) {
continue;
}
let edge = rounded
.edges
.iter()
.find(|edge| edge.id == *id)
.ok_or_else(decline)?;
points.push(
edge.curve
.evaluate(0.5 * (edge.t0 + edge.t1))
.or_refuse(KernelStage::Collect, "rim_midpoint")?,
);
names.push(
per_edge_name(edge_names, name, index)
.unwrap_or("FILLET:RIM")
.to_owned(),
);
}
for (corner, bridge) in mixed.iter().zip(&bridges) {
points.push(
bridge
.curve
.evaluate(0.5 * (bridge.t0 + bridge.t1))
.or_refuse(KernelStage::Collect, "bridge_midpoint")?,
);
let mut adjacent = corner.concave.clone();
adjacent.push(corner.convex[0]);
names.push(
corner_face_name(edge_names, name, &adjacent)
.unwrap_or_else(|| format!("FILLET:BOSS_CORNER:{}", corner.vertex)),
);
}
fillet_edges_reported(&rounded, &points, Some(&names), radius, false, name).and_then(
|(result, selection)| {
if selection.applied.len() != points.len() {
Err(decline())
} else {
Ok(result)
}
},
)
}
#[allow(clippy::too_many_arguments)]
fn open_pad_on() -> bool {
switch!(live "BREP_BLEND_OPEN_PAD", true)
}
#[allow(clippy::too_many_arguments)]
fn open_pad_composition(
solid: &BrepSolid,
selected_ids: &[u64],
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
name: Option<&str>,
corners: &[crate::blend::MixedCorner],
entry: &str,
) -> Result<BrepSolid, KernelRefusal> {
let shape = |message: String| KernelRefusal::unsupported(KernelStage::Classify, "open_pad_shape", message);
if !open_pad_on() {
return Err(shape("the open-pad composition is switched off (BREP_BLEND_OPEN_PAD)".into()));
}
if std::env::var("BREP_NO_NETWORK").is_ok() {
return Err(shape("the open-pad composition is a network construction and BREP_NO_NETWORK is set".into()));
}
if corners.is_empty() || corners.iter().any(|corner| corner.convex.len() != 1 || corner.concave.len() != 1) {
return Err(shape("the open-pad composition is built for corners of one convex and one concave edge".into()));
}
let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
.ok_or_else(|| shape("the selection's convexity could not be read edge by edge".into()))?;
let concave: Vec<usize> = (0..selected_ids.len()).filter(|index| !convexity[*index]).collect();
let convex: Vec<usize> = (0..selected_ids.len()).filter(|index| convexity[*index]).collect();
if concave.is_empty() || convex.is_empty() {
return Err(shape("the open-pad composition needs convex and concave edges".into()));
}
let mates = crate::blend::selection_mates(solid, selected_ids, radius)
.map_err(|error| shape(format!("the selection's mates could not be read: {error}")))?;
for corner in corners {
let (_, convex_faces) = &mates[corner.convex[0]];
let (_, concave_faces) = &mates[corner.concave[0]];
let Some(shared) = convex_faces.iter().find(|face| concave_faces.iter().any(|other| other.id == face.id)) else {
return Err(shape(format!("the two edges at vertex {} share no face", corner.vertex)));
};
if !matches!(shared.surface.analytic(), Some(crate::AnalyticSurface::Plane { .. })) {
return Err(shape(format!("the face {} the edges share at vertex {} is not planar", shared.id, corner.vertex)));
}
}
for &index in &concave {
let edge = mates[index].0;
for vertex in [edge.start_vertex_id, edge.end_vertex_id] {
if !corners.iter().any(|corner| corner.vertex == vertex && corner.concave[0] == index) {
return Err(shape(format!(
"concave {} does not end at a mixed corner at vertex {vertex}; the open-pad composition is built for pads that do",
selection_label(index, edge_points, edge_names, name)
)));
}
}
}
let base = name.unwrap_or("FILLET:OPEN_PAD");
let bare = |index: usize| -> String {
per_edge_name(edge_names, name, index)
.map(|composed| composed.strip_prefix(&format!("{base}:")).unwrap_or(composed).to_string())
.unwrap_or_else(|| selected_ids[index].to_string())
};
let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
let convex_points: Vec<Vec3> = convex.iter().map(|index| edge_points[*index]).collect();
let convex_names: Vec<String> = convex
.iter()
.map(|index| per_edge_name(edge_names, name, *index).map(str::to_owned).unwrap_or_else(|| format!("{base}:WALL:{}", selected_ids[*index])))
.collect();
let stage1 = crate::blend::with_open_pad_network(|| {
fillet_edges_group_with_corner_policy(solid, &convex_points, Some(&convex_names), radius, false, name, None, true, false)
})
.map_err(|error| error.with_message(|error| format!("open-pad composition, the convex walls: {error}")))?;
if debug {
eprintln!("open-pad composition: stage 1 built the {} convex walls", convex.len());
}
let uses = |face: &FaceRecord, edge_id: u64| face.loops.iter().flat_map(|l| &l.coedges).any(|c| c.edge_id == edge_id);
let mut points: Vec<Vec3> = Vec::new();
let mut names: Vec<String> = Vec::new();
for &index in &concave {
let (edge, faces) = &mates[index];
let boss_id = if matches!(faces[0].surface.analytic(), Some(crate::AnalyticSurface::Plane { .. })) { faces[1].id } else { faces[0].id };
let pad = stage1
.edges
.iter()
.find(|candidate| candidate.id == edge.id)
.ok_or_else(|| KernelRefusal::internal(KernelStage::Classify, "open_pad_edge", format!(
"open-pad composition: concave edge {} vanished from the stage-1 body", edge.id
)))?;
points.push(pad.curve.evaluate(0.5 * (pad.t0 + pad.t1)).or_refuse(KernelStage::Collect, "pad_midpoint")?);
names.push(per_edge_name(edge_names, name, index).map(str::to_owned).unwrap_or_else(|| format!("{base}:PAD:{}", edge.id)));
for vertex in [pad.start_vertex_id, pad.end_vertex_id] {
let trims: Vec<&EdgeRecord> = stage1
.edges
.iter()
.filter(|candidate| {
candidate.id != pad.id
&& !candidate.degenerate
&& (candidate.start_vertex_id == vertex || candidate.end_vertex_id == vertex)
})
.filter(|candidate| {
let faces: Vec<&FaceRecord> = stage1.shells.iter().flat_map(|shell| &shell.faces).filter(|face| uses(face, candidate.id)).collect();
faces.iter().any(|face| face.id == boss_id)
&& faces.iter().any(|face| face.name.as_deref().is_some_and(|value| convex_names.iter().any(|wall| wall == value)))
})
.collect();
match trims.as_slice() {
[trim] => {
let wall_name = stage1
.shells
.iter()
.flat_map(|shell| &shell.faces)
.filter(|face| uses(face, trim.id))
.find_map(|face| face.name.clone().filter(|value| convex_names.contains(value)))
.unwrap_or_default();
let wall_bare = wall_name.strip_prefix(&format!("{base}:")).unwrap_or(&wall_name).to_string();
points.push(trim.curve.evaluate(0.5 * (trim.t0 + trim.t1)).or_refuse(KernelStage::Collect, "trim_midpoint")?);
names.push(format!("{base}:RUNOUT:{}+{wall_bare}", bare(index)));
if debug {
eprintln!("open-pad composition: pad {} continues into trim edge {} (wall {wall_name}) at vertex {vertex}", edge.id, trim.id);
}
}
[] => {
return Err(KernelRefusal::unsupported(KernelStage::Classify, "open_pad_trim", format!(
"open-pad composition: at vertex {vertex} the concave {} meets no trim curve of a stage-1 wall on the boss",
selection_label(index, edge_points, edge_names, name)
)));
}
_ => {
return Err(KernelRefusal::unsupported(KernelStage::Classify, "open_pad_trim", format!(
"open-pad composition: at vertex {vertex} the concave {} meets {} trim curves",
selection_label(index, edge_points, edge_names, name), trims.len()
)));
}
}
}
}
let result = crate::blend::with_open_pad_network(|| fillet_edges_group_in(&stage1, &points, Some(&names), radius, false, name, None))
.map_err(|error| error.with_message(|error| format!("open-pad composition, the pads on the walls: {error}")))?;
let _ = entry;
Ok(result)
}
fn cusp_composition(
solid: &BrepSolid,
selected_ids: &[u64],
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
name: Option<&str>,
corners: &[crate::blend::MixedCorner],
) -> Result<BrepSolid, KernelRefusal> {
let shape = |message: String| KernelRefusal::unsupported(KernelStage::Classify, "cusp_composition_shape", message);
if std::env::var("BREP_NO_NETWORK").is_ok() {
return Err(shape("the cusp composition is a network construction and BREP_NO_NETWORK is set".into()));
}
let edge_of = |index: usize| solid.edges.iter().find(|edge| edge.id == selected_ids[index]);
let away = |index: usize, vertex: u64| -> Option<Vec3> {
let edge = edge_of(index)?;
let at_start = edge.start_vertex_id == vertex;
let tangent = edge.curve.unit_tangent(if at_start { edge.t0 } else { edge.t1 }, edge.t0, edge.t1).ok()?;
Some(if at_start { tangent } else { tangent.scale(-1.0) })
};
if corners.is_empty() {
return Err(shape("no mixed corners".into()));
}
for corner in corners {
let cusped = corner.convex.iter().all(|&convex| {
corner.concave.iter().any(|&concave| {
matches!((away(convex, corner.vertex), away(concave, corner.vertex)), (Some(a), Some(b)) if a.dot(b) >= 1.0 - 1e-6)
})
});
if !cusped || corner.convex.is_empty() || corner.concave.is_empty() {
return Err(shape(format!("the mixed corner at vertex {} is not a cusp", corner.vertex)));
}
}
let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
.ok_or_else(|| shape("the selection's convexity could not be read edge by edge".into()))?;
let concave: Vec<usize> = (0..selected_ids.len()).filter(|index| !convexity[*index]).collect();
let convex: Vec<usize> = (0..selected_ids.len()).filter(|index| convexity[*index]).collect();
if concave.is_empty() || convex.is_empty() {
return Err(shape("the cusp composition needs convex and concave edges".into()));
}
let names_of = |indices: &[usize]| -> Option<Vec<String>> { edge_names.map(|names| indices.iter().map(|index| names[*index].clone()).collect()) };
let points_of = |indices: &[usize]| -> Vec<Vec3> { indices.iter().map(|index| edge_points[*index]).collect() };
let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
let stage1 = fillet_edges_group_in(solid, &points_of(&concave), names_of(&concave).as_deref(), radius, false, name, None)
.map_err(|error| error.with_message(|error| format!("cusp composition, the concave rounds: {error}")))?;
if debug {
eprintln!("cusp composition: stage 1 built the {} concave rounds", concave.len());
}
for &index in &convex {
resolve_edge_by_point(&stage1, edge_points[index])
.map_err(|error| error.with_message(|error| format!("cusp composition: a convex edge is not on the stage-1 body: {error}")))?;
}
let result = fillet_edges_group_in(&stage1, &points_of(&convex), names_of(&convex).as_deref(), radius, false, name, None)
.map_err(|error| error.with_message(|error| format!("cusp composition, the convex rounds: {error}")))?;
RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(true));
Ok(result)
}
fn runout_composition(
solid: &BrepSolid,
selected_ids: &[u64],
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
name: Option<&str>,
corners: &[crate::blend::MixedCorner],
entry: &str,
) -> Result<BrepSolid, KernelRefusal> {
if std::env::var("BREP_NO_NETWORK").is_ok() {
return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_no_network", "the runout composition is a network construction and BREP_NO_NETWORK is set"));
}
let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
.ok_or(KernelRefusal::unsupported(KernelStage::Classify, "runout_convexity", "the selection's convexity could not be read edge by edge"))?;
let mut spine_ends = vec![0usize; selected_ids.len()];
for corner in corners {
if corner.convex.len() != 1 {
return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_corner_shape", format!(
"the corner at ({:.3}, {:.3}, {:.3}) has {} convex edges; the runout is built for one convex edge dying into concave ones",
corner.point.x, corner.point.y, corner.point.z, corner.convex.len()
)));
}
spine_ends[corner.convex[0]] += 1;
}
let mut spines: Vec<usize> = Vec::new();
let mut concave: Vec<usize> = Vec::new();
for (index, ends) in spine_ends.iter().enumerate() {
match (*ends, convexity[index]) {
(2, true) => spines.push(index),
(1, true) if crate::blend::runout::one_ended_runout_on() => spines.push(index),
(0, false) => concave.push(index),
(ends, convex) => {
return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", format!(
"{} is {} and meets a mixed corner at {ends} of its ends; the runout is built for a convex edge running out at both ends among concave edges",
selection_label(index, edge_points, edge_names, name),
if convex { "convex" } else { "concave" },
)));
}
}
}
if concave.is_empty() {
return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", "no concave edges to build the ring from"));
}
let ring_points: Vec<Vec3> = concave.iter().map(|index| edge_points[*index]).collect();
let ring_names: Option<Vec<String>> = edge_names.map(|names| concave.iter().map(|index| names[*index].clone()).collect());
LAST_NETWORK_REFUSAL.with(|last| *last.borrow_mut() = None);
let ring = fillet_edges_group_with_corner_policy(solid, &ring_points, ring_names.as_deref(), radius, false, name, None, false, true)
.map_err(|error| {
let network = LAST_NETWORK_REFUSAL.with(|last| last.borrow_mut().take());
match network {
Some(network) if crate::blend::is_reentrant_concave(&network) => network.with_message(|network| format!(
"the concave ring did not build: {network} (the cutter fallback then refused: {error})"
)),
_ => error.with_message(|error| format!("the concave ring did not build: {error}")),
}
})?;
let network_declined = LAST_NETWORK_REFUSAL.with(|last| last.borrow_mut().take());
let ring_is_network = network_declined.is_none();
if let Some(network) = network_declined {
if crate::blend::is_reentrant_concave(&network) {
return Err(network.with_message(|network| format!(
"the concave ring did not build as the network's ring: {network}; the cutter fallback built a body for the concave edges without that closure, and the runout walls are marched only on the network's ring"
)));
}
}
let spine_walls: Vec<(u64, Option<String>)> = spines
.iter()
.map(|index| (selected_ids[*index], per_edge_name(edge_names, name, *index).map(str::to_string)))
.collect();
let spine_ids: Vec<u64> = spine_walls.iter().map(|(id, _)| *id).collect();
let mut result = crate::blend::build_runout_walls(&ring, &spine_walls, radius)?;
heal_edge_vertex_gaps(&mut result, radius)?;
crate::blend::fit_planar_charts_to_trims(&ring, &mut result)?;
let issues = result.validate();
if !issues.is_empty() {
return Err(KernelRefusal::new(
RefusalClass::InvalidResultTopology { issues: issues.len() as u32 },
KernelStage::Validate,
format!("the runout walls do not validate: {issues:?}"),
));
}
check_blend_interference(&ring, &result, &spine_ids, entry)?;
check_loop_self_crossings(&result, entry)?;
check_blend_wall_loops(&result, entry)?;
crate::soundness::census::profile_operation(&result, &[solid], None, entry);
let accepted = crate::accept_sound(result, entry)?;
RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(ring_is_network));
Ok(accepted)
}
thread_local! {
static RUNOUT_RING_IS_NETWORK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
fn check_mixed_corner_convexity(
solid: &BrepSolid,
edge_ids: &[u64],
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
name: Option<&str>,
composition_error: Option<&KernelRefusal>,
) -> Result<(), KernelRefusal> {
match crate::blend::mixed_convexity_corner(solid, edge_ids, radius) {
Some(corner) => {
let measured = match crate::blend::plan_runout(solid, edge_ids, radius, &corner) {
Ok(plan) => Some(plan),
Err(error) => {
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!("runout not measured: {error}");
}
None
}
};
let mut message = mixed_corner_message(
&corner,
edge_points,
edge_names,
name,
measured.as_ref(),
);
if let Some(error) = composition_error {
message.push_str(&format!(" The runout composition was tried on this selection and refused: {error}"));
}
Err(KernelRefusal::unsupported(
KernelStage::Classify,
"mixed_corner_convexity",
message,
))
}
None => Ok(()),
}
}
fn check_degenerate_corner_setbacks(
solid: &BrepSolid,
edge_ids: &[u64],
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
name: Option<&str>,
) -> Result<(), KernelRefusal> {
let Some(setback) = crate::blend::degenerate_corner_setbacks(solid, edge_ids, radius) else {
return Ok(());
};
let [from, to] = setback.stops;
let how = if setback.crossed {
format!(
"run {:.3e} PAST each other along it (the radius is beyond the limit)",
setback.strip
)
} else {
format!(
"meet there ({:.3e} apart against a bar of {:.3e})",
setback.strip, setback.bar
)
};
Err(KernelRefusal::input(
KernelStage::Classify,
"degenerate_corner_setbacks",
format!(
"fillet_edges: at r = {radius} the corner setbacks consume the whole of {} — the rolling \
balls seated at its two corners touch face {} at ({:.4}, {:.4}, {:.4}) and ({:.4}, \
{:.4}, {:.4}) and {how}, so no blend strip is left between them and the faces it lies \
on are consumed whole. The radius is at or past the degenerate limit for this \
selection; reduce it.",
selection_label(setback.edge, edge_points, edge_names, name),
setback.face_id,
from.x,
from.y,
from.z,
to.x,
to.y,
to.z,
),
))
}
fn selection_label(
index: usize,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
name: Option<&str>,
) -> String {
let named = edge_names.and_then(|names| names.get(index)).map(|composed| match name {
Some(base) => composed
.strip_prefix(&format!("{base}:"))
.unwrap_or(composed)
.to_string(),
None => composed.clone(),
});
named.unwrap_or_else(|| match edge_points.get(index) {
Some(point) => format!("the edge at ({:.3}, {:.3}, {:.3})", point.x, point.y, point.z),
None => format!("selection #{}", index + 1),
})
}
fn mixed_corner_message(
corner: &crate::blend::MixedCorner,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
name: Option<&str>,
measured: Option<&crate::blend::RunoutPlan>,
) -> String {
let label = |index: usize| selection_label(index, edge_points, edge_names, name);
let list = |indices: &[usize]| -> String {
let parts: Vec<String> = indices.iter().map(|index| label(*index)).collect();
match parts.split_last() {
None => "none".to_string(),
Some((last, [])) => last.clone(),
Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
}
};
let mut message = format!(
"fillet_edges: the selection mixes convexity at the corner ({:.3}, {:.3}, {:.3}) — convex \
{} meets concave {} there. One rolling ball cannot touch the face they share from both \
sides at once, so that corner has no closure: the convex blend runs out against the \
concave ones partway along the edge, and this kernel does not build that runout yet. \
Blending the concave edges in one fillet and the convex ones in another does not reach \
a wall on every one of them either: the convex wall comes out at FULL length in either \
order, and its contact leaves the faces it runs along where their own loops would then \
cross. Blend the concave edges first and the blend acceptance refuses each convex edge \
whose wall runs out into them; blend the convex ones first and the subset search drops \
the concave edges it cannot compose instead, leaving those unfilleted.",
corner.point.x,
corner.point.y,
corner.point.z,
list(&corner.convex),
list(&corner.concave),
);
if let Some(plan) = measured {
message.push_str(&format!(
" Measured on this selection: the convex edge {}'s ball becomes tangent to face {} — \
the face its two concave neighbours share — at s = {:.6} from the corner, centred \
({:.6}, {:.6}, {:.6}), where its contact on face {} reaches edge {}'s own rail \
(ball-to-stripe residual {:.3e}). Past that station the second carrier is edge {}'s \
STRIPE SURFACE, not a face of the input, and the march holds on it for {} \
stations that fit rows reproducing their own contacts to {:.3e}. On the way the \
kept mate's contact walks {:.3e} of a span off that face's own patch — into the \
strip the concave stripe's pad ADDS to it — which is the shape, not an escape: \
the bar a march is refused at is a full span.",
plan.convex_edge,
plan.shared_face,
plan.station.s,
plan.station.center.x,
plan.station.center.y,
plan.station.center.z,
plan.replaced_mate,
plan.second_carrier,
plan.station.stripe_residual,
plan.second_carrier,
plan.marched,
plan.fit_residual,
plan.carrier_excursion,
));
match (&plan.stop, plan.first_carrier) {
(Some(stop), Some(first)) => message.push_str(&format!(
" It does not simply stop there: at s = {:.6} the KEPT mate (face {}) runs out too \
— the ball's contact on it reaches edge {}'s rail, residual {:.3e} — so the FIRST \
carrier becomes that stripe as well and the last stretch rolls on two stripes.",
stop.s, plan.kept_mate, first, stop.stripe_residual,
)),
_ => message.push_str(&format!(
" Where the kept mate (face {}) runs out was not measurable on this selection.",
plan.kept_mate
)),
}
if let Some(pole) = plan.pole {
message.push_str(&format!(
" That stretch ends at a POLE, ({:.6}, {:.6}, {:.6}), where the two concave \
stripes' rails on face {} meet and the ball's two contacts merge, so the blend \
section shrinks to a point. Two carrier switches and a degenerate terminus is \
more than a stop station on a stripe, and none of it is composed yet.",
pole.x, pole.y, pole.z, plan.shared_face,
));
}
}
message
}
fn index_combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
let mut out = Vec::new();
if k == 0 || k > n {
return out;
}
let mut idx: Vec<usize> = (0..k).collect();
loop {
out.push(idx.clone());
let mut i = k;
loop {
if i == 0 {
return out;
}
i -= 1;
if idx[i] != i + n - k {
break;
}
}
idx[i] += 1;
for j in (i + 1)..k {
idx[j] = idx[j - 1] + 1;
}
}
}
thread_local! {
pub(crate) static CUTTER_COMPOSITIONS: std::cell::Cell<u64> =
const { std::cell::Cell::new(0) };
}
fn fillet_edges_group(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
chamfer: bool,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
fillet_edges_group_in(solid, edge_points, edge_names, radius, chamfer, name, None)
}
thread_local! {
static SUBSET_DROP_CAP: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
}
struct SubsetDropCap(Option<usize>);
impl SubsetDropCap {
fn set(cap: Option<usize>) -> Self {
Self(SUBSET_DROP_CAP.with(|current| current.replace(cap)))
}
}
impl Drop for SubsetDropCap {
fn drop(&mut self) {
SUBSET_DROP_CAP.with(|current| current.set(self.0));
}
}
thread_local! {
pub(crate) static GROUP_BUILDS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
static LAST_NETWORK_REFUSAL: std::cell::RefCell<Option<KernelRefusal>> = const { std::cell::RefCell::new(None) };
static GROUP_NETWORK_FOLD: std::cell::RefCell<Option<KernelRefusal>> = const { std::cell::RefCell::new(None) };
}
type ComponentKey = (Vec<[u64; 3]>, Option<Vec<String>>);
type SequentialKey = ([u64; 3], Option<String>, [u64; 2], u8);
#[derive(Default)]
struct ComponentMemo {
built: std::collections::HashMap<Vec<ComponentKey>, Result<BrepSolid, KernelRefusal>>,
prefix: Vec<ComponentKey>,
sequential: std::collections::HashMap<
(Vec<ComponentKey>, Vec<SequentialKey>),
Result<BrepSolid, KernelRefusal>,
>,
}
fn fillet_edges_group_in(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
chamfer: bool,
name: Option<&str>,
memo: Option<&mut ComponentMemo>,
) -> Result<BrepSolid, KernelRefusal> {
fillet_edges_group_with_corner_policy(solid, edge_points, edge_names, radius, chamfer, name, memo, true, true)
}
fn fillet_edges_group_with_corner_policy(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
chamfer: bool,
name: Option<&str>,
memo: Option<&mut ComponentMemo>,
wrap_concave: bool,
accept: bool,
) -> Result<BrepSolid, KernelRefusal> {
GROUP_BUILDS.with(|count| count.set(count.get() + 1));
let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
let GroupBody { body: result, bowtie_clean } =
fillet_edges_group_unchecked(solid, edge_points, edge_names, radius, chamfer, name, memo, wrap_concave)?;
if !accept {
return Ok(result);
}
if bowtie_clean {
crate::blend::record_count("count.bowtie.lane_scan_reused", 1);
} else {
check_loop_self_crossings(&result, entry)?;
check_blend_wall_loops(&result, entry)?;
}
let soundness = crate::blend::BlendTimer::start("timing.soundness_ms");
crate::soundness::census::profile_operation(&result, &[solid], None, entry);
let accepted = crate::accept_sound(result, entry);
soundness.stop();
accepted
}
struct GroupBody {
body: BrepSolid,
bowtie_clean: bool,
}
fn fillet_edges_group_unchecked(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
chamfer: bool,
name: Option<&str>,
memo: Option<&mut ComponentMemo>,
wrap_concave: bool,
) -> Result<GroupBody, KernelRefusal> {
let outer = GROUP_NETWORK_FOLD.with(|fold| fold.borrow_mut().take());
let built = fillet_edges_group_lanes(solid, edge_points, edge_names, radius, chamfer, name, memo, wrap_concave);
let fold = GROUP_NETWORK_FOLD.with(|fold| std::mem::replace(&mut *fold.borrow_mut(), outer));
match (built, fold) {
(Err(error), Some(fold)) if !crate::blend::is_wall_fold(&error) => Err(fold),
(built, _) => built,
}
}
fn fillet_edges_group_lanes(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
chamfer: bool,
name: Option<&str>,
mut memo: Option<&mut ComponentMemo>,
wrap_concave: bool,
) -> Result<GroupBody, KernelRefusal> {
use rustc_hash::FxHashSet as HashSet;
let heal = crate::blend::BlendTimer::start("timing.heal_operands_ms");
let mut healed_input = solid.clone();
let heal_policy = crate::KernelTolerances::for_solid(&healed_input, 1e-7);
crate::heal::heal_operands(&mut healed_input, &heal_policy)?;
let solid = &healed_input;
heal.stop();
let group_analysis = crate::blend::BlendTimer::start("timing.group_analysis_ms");
let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
let mut original_extents: Vec<(Vec3, Vec3)> = Vec::with_capacity(edge_points.len());
let mut selected_edge_ids: Vec<u64> = Vec::with_capacity(edge_points.len());
for (i, point) in edge_points.iter().enumerate() {
let edge_id = resolve_edge_by_point(solid, *point)?;
let edge = solid
.edges
.iter()
.find(|e| e.id == edge_id)
.ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Collect,
"resolved_edge",
"fillet_edges: resolved edge vanished",
)
})?;
let (start, end) = (
edge.curve
.evaluate(edge.t0)
.or_refuse(KernelStage::Collect, "edge_start")?,
edge.curve
.evaluate(edge.t1)
.or_refuse(KernelStage::Collect, "edge_end")?,
);
endpoints.push((start, i));
endpoints.push((end, i));
original_extents.push((start, end));
selected_edge_ids.push(edge_id);
}
let mut corners: Vec<Vec3> = Vec::new();
let mut corner_edges: Vec<Vec<usize>> = Vec::new();
let mut chain_corner_count = 0usize;
let mut chain_corners: Vec<(Vec3, [usize; 2])> = Vec::new();
let mut used = vec![false; endpoints.len()];
for i in 0..endpoints.len() {
if used[i] {
continue;
}
used[i] = true;
let mut edges_here: HashSet<usize> = HashSet::default();
edges_here.insert(endpoints[i].1);
for j in (i + 1)..endpoints.len() {
if used[j] {
continue;
}
if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
used[j] = true;
edges_here.insert(endpoints[j].1);
}
}
if edges_here.len() >= 3 {
corners.push(endpoints[i].0);
let mut adjacent: Vec<usize> = edges_here.into_iter().collect();
adjacent.sort_unstable();
corner_edges.push(adjacent);
} else if edges_here.len() == 2 {
chain_corner_count += 1;
let mut adjacent = edges_here.into_iter().collect::<Vec<_>>();
adjacent.sort_unstable();
chain_corners.push((endpoints[i].0, [adjacent[0], adjacent[1]]));
}
}
let mut component_of = vec![usize::MAX; edge_points.len()];
let mut components: Vec<Vec<usize>> = Vec::new();
for seed in 0..edge_points.len() {
if component_of[seed] != usize::MAX {
continue;
}
let component_index = components.len();
component_of[seed] = component_index;
let mut component = vec![seed];
let mut cursor = 0;
while cursor < component.len() {
let current = component[cursor];
cursor += 1;
for candidate in 0..edge_points.len() {
if component_of[candidate] != usize::MAX {
continue;
}
let connected = [original_extents[current].0, original_extents[current].1]
.into_iter()
.any(|a| {
[original_extents[candidate].0, original_extents[candidate].1]
.into_iter()
.any(|b| a.sub(b).length() < 1e-6)
});
if connected {
component_of[candidate] = component_index;
component.push(candidate);
}
}
}
component.sort_unstable();
components.push(component);
}
group_analysis.stop();
if components.len() > 1 {
let mut separated = solid.clone();
let mut chain: Vec<ComponentKey> = memo.as_deref().map(|memo| memo.prefix.clone()).unwrap_or_default();
chain.reserve(components.len());
for component in components {
let points = component
.iter()
.map(|index| edge_points[*index])
.collect::<Vec<_>>();
let names = edge_names.map(|all| {
component
.iter()
.map(|index| all[*index].clone())
.collect::<Vec<_>>()
});
let accept_component = !crate::blend::accept_once_on();
let Some(memo) = memo.as_deref_mut() else {
separated = fillet_edges_group_with_corner_policy(
&separated,
&points,
names.as_deref(),
radius,
chamfer,
name,
None,
wrap_concave,
accept_component,
)?;
continue;
};
let bits = points.iter().map(|p| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]);
chain.push((bits.collect(), names.clone()));
let built = match memo.built.get(&chain) {
Some(built) => built.clone(),
None => {
let outer_prefix = std::mem::replace(&mut memo.prefix, chain[..chain.len() - 1].to_vec());
let built = fillet_edges_group_with_corner_policy(
&separated,
&points,
names.as_deref(),
radius,
chamfer,
name,
Some(&mut *memo),
wrap_concave,
accept_component,
);
memo.prefix = outer_prefix;
memo.built.insert(chain.clone(), built.clone());
built
}
};
separated = built?;
}
return Ok(GroupBody { body: separated, bowtie_clean: false });
}
if std::env::var("BREP_NO_NETWORK").is_err() {
if selected_edge_ids.len() == 1 {
let edge_id = selected_edge_ids[0];
let closed = solid
.edges
.iter()
.find(|edge| edge.id == edge_id)
.is_some_and(|edge| edge.start_vertex_id == edge.end_vertex_id);
if closed {
let rim_name = per_edge_name(edge_names, name, 0);
match crate::blend::blend_closed_edge(solid, edge_id, radius, chamfer, rim_name) {
Ok(mut rim) => {
let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
let healed = heal_edge_vertex_gaps(&mut rim, radius);
heal.stop();
let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
crate::blend::fit_planar_charts_to_trims(solid, &mut rim)?;
charts.stop();
let validate = crate::blend::BlendTimer::start("timing.validate_ms");
let issues = rim.validate_memoised();
validate.stop();
let interference =
check_blend_interference(solid, &rim, &selected_edge_ids, entry);
let bowtie = check_loop_self_crossings(&rim, entry).and_then(|()| check_blend_wall_loops(&rim, entry));
if healed.is_ok()
&& issues.is_empty()
&& interference.is_ok()
&& bowtie.is_ok()
{
return Ok(GroupBody { body: rim, bowtie_clean: true });
}
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!(
"closed-rim result rejected: heal={healed:?} issues={issues:?} \
interference={interference:?} bowtie={bowtie:?}"
);
}
}
Err(refusal) => {
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!("closed-rim refused: {refusal}");
}
}
}
}
}
let network_names: Vec<Option<String>> = (0..edge_points.len())
.map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
.collect();
let network_corner_name =
|adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
let group_network = || {
crate::blend::blend_star_network_with_corner_policy(
solid,
&selected_edge_ids,
radius,
chamfer,
&network_names,
&network_corner_name,
wrap_concave,
)
};
let network_result = group_network().or_else(|first| {
if crate::blend::is_pcurve_floor_refusal(&first) {
crate::blend::with_pcurve_knot_retry(group_network).map_err(|_| first)
} else {
Err(first)
}
});
let accept_network = |mut network: BrepSolid, built_radius: f64| -> Result<Option<GroupBody>, KernelRefusal> {
let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
let healed = heal_edge_vertex_gaps(&mut network, built_radius);
heal.stop();
let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
charts.stop();
let validate = crate::blend::BlendTimer::start("timing.validate_ms");
let issues = network.validate_memoised();
validate.stop();
let interference =
check_blend_interference(solid, &network, &selected_edge_ids, entry);
let bowtie = check_loop_self_crossings(&network, entry).and_then(|()| check_blend_wall_loops(&network, entry));
if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
return Ok(Some(GroupBody { body: network, bowtie_clean: true }));
}
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!(
"network result rejected: heal={healed:?} issues={issues:?} \
interference={interference:?} bowtie={bowtie:?}"
);
dump_loops_debug("INPUT", solid);
dump_loops_debug("RESULT", &network);
}
Ok(None)
};
match network_result {
Ok(network) => {
if let Some(body) = accept_network(network, radius)? {
return Ok(body);
}
}
Err(refusal) => {
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!("network refused: {refusal}");
}
let pinched_taper = matches!(&refusal.class, crate::RefusalClass::UnsupportedGeometry { what }
if what == crate::blend::STRIPE_PINCH_TAPER_WHAT);
if !chamfer && pinched_taper && crate::blend::relief::radius_relief_on() {
let labels: Vec<String> =
(0..edge_points.len()).map(|index| selection_label(index, edge_points, edge_names, name)).collect();
for step in 1..=10u32 {
let reduced = radius * (1.0 - 0.05 * f64::from(step));
let built = crate::blend::blend_star_network_with_corner_policy(
solid, &selected_edge_ids, reduced, chamfer, &network_names, &network_corner_name, wrap_concave,
);
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!(
"taper pinch ladder: r {reduced:.4} -> {}",
match &built {
Ok(_) => "built".to_string(),
Err(error) => format!("refused: {error}"),
}
);
}
match built {
Ok(network) => {
let Some(body) = accept_network(network, reduced)? else { break };
crate::blend::record_blend_note(format!(
"radius reduced over the whole of {} to {reduced:.4}: at r {radius} the round tapering to \
its tip reaches the full radius only inside the corner setback at its other end, so \
r {radius} is reached nowhere",
labels.join(", ")
));
return Ok(body);
}
Err(again) if matches!(&again.class, crate::RefusalClass::UnsupportedGeometry { what }
if what == crate::blend::STRIPE_PINCH_TAPER_WHAT) => continue,
Err(_) => break,
}
}
}
if matches!(&refusal.class, RefusalClass::UnsupportedGeometry { what }
if what == "canal_footprint_bound") {
return Err(refusal);
}
LAST_NETWORK_REFUSAL.with(|last| *last.borrow_mut() = Some(refusal.clone()));
if crate::blend::is_wall_fold(&refusal) {
GROUP_NETWORK_FOLD.with(|fold| *fold.borrow_mut() = Some(refusal.clone()));
}
if !chamfer && !crate::blend::is_wall_fold(&refusal) && crate::blend::relief::radius_relief_on() {
let started = web_time::Instant::now();
let misfit = crate::blend::relief::first_misfit(solid, &selected_edge_ids, radius);
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!("radius relief: misfit probe {misfit:?} in {:.1} ms", started.elapsed().as_secs_f64() * 1e3);
}
if let Some((edge_id, least)) = misfit {
return Err(crate::blend::relief::misfit_fold(edge_id, least, radius, &refusal));
}
}
if crate::blend::is_rail_fold(&refusal)
|| crate::blend::is_marched_fit_off_carriers(&refusal)
|| crate::blend::is_consumed_snap(&refusal)
|| crate::blend::is_rail_collapse(&refusal)
|| crate::blend::is_full_width_collapse(&refusal)
|| crate::blend::is_planar_chart_refusal(&refusal)
|| crate::blend::is_miter_support_overrun(&refusal)
|| crate::blend::is_tangential_transverse_end(&refusal)
{
return Err(refusal);
}
}
}
}
if let Some(refusal) = super::analyze::rollover_only_refusal(&selected_edge_ids) {
return Err(refusal);
}
CUTTER_COMPOSITIONS.with(|count| count.set(count.get() + 1));
crate::blend::record_count("count.cutter_fallback", 1);
let _cutter = crate::blend::BlendTimer::start("timing.cutter_ms");
let miter_operation = if (chain_corner_count > 0 || !corners.is_empty())
&& (chamfer || corners.is_empty())
{
let mut any_convex = false;
let mut any_concave = false;
for point in edge_points {
match resolve_edge_by_point(solid, *point)
.and_then(|edge_id| super::analyze::scan_dihedral(solid, edge_id))
{
Ok(profile) if profile.samples > 0 && !profile.is_mixed() => {
any_convex |= profile.any_convex;
any_concave |= profile.any_concave;
}
_ => {
any_convex = true;
any_concave = true;
break;
}
}
}
match (any_convex, any_concave) {
(true, false) => Some(crate::BooleanOperation::Intersect),
(false, true) => Some(crate::BooleanOperation::Union),
_ => None,
}
} else {
None
};
let composition_lane = if corners.is_empty() && chain_corner_count == 0 {
Lane::GeneralFirst
} else {
Lane::CutterFirst
};
let concave_at_star = concave_star_edges(solid, &selected_edge_ids, &corner_edges, radius, chamfer);
let class_ranks = mixed_star_class_ranks(solid, &selected_edge_ids, &corner_edges, radius, chamfer);
let sequential_order = sequential_build_order(edge_points.len(), &concave_at_star, &class_ranks);
let (corners, corner_edges) = corners_concave_first(corners, corner_edges, &concave_at_star);
let mut build_sequential = || -> Result<BrepSolid, KernelRefusal> {
let mut sequential = solid.clone();
let mut chain: Vec<SequentialKey> = Vec::with_capacity(sequential_order.len());
for &index in &sequential_order {
let point = &edge_points[index];
let edge_id = resolve_edge_by_point(&sequential, *point)?;
let ends = tool_ends_to_original_extent(
&sequential,
edge_id,
radius,
original_extents[index],
if chamfer { &[] } else { &corners },
);
let edge_name = per_edge_name(edge_names, name, index);
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!(
"sequential build: selection #{index} {} is edge {edge_id} on the evolving body (ends {ends:?}, {composition_lane:?})",
selection_label(index, edge_points, edge_names, name)
);
}
chain.push((
[point.x.to_bits(), point.y.to_bits(), point.z.to_bits()],
edge_name.map(str::to_string),
ends.key_bits(),
u8::from(composition_lane == Lane::CutterFirst),
));
if let Some(memo) = memo.as_deref_mut() {
let key = (memo.prefix.clone(), chain.clone());
let hit = memo.sequential.get(&key);
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!(
"sequential memo: {} components before, step {} {} ({} chains stored)",
memo.prefix.len(),
chain.len(),
if hit.is_some() { "HIT" } else { "miss" },
memo.sequential.len(),
);
}
if let Some(built) = hit {
crate::blend::record_count("count.sequential.memo_hits", 1);
sequential = built.clone()?;
continue;
}
}
crate::blend::record_count("count.sequential.steps_built", 1);
let built = fillet_or_chamfer(
&sequential,
edge_id,
radius,
chamfer,
edge_name,
ends,
composition_lane,
);
if let Some(memo) = memo.as_deref_mut() {
memo.sequential.insert((memo.prefix.clone(), chain.clone()), built.clone());
}
sequential = built?;
}
Ok(sequential)
};
let mut result = if let Some(operation) = miter_operation {
let options = crate::BooleanOptions {
keep_unmerged_name_substrs: vec![CUTTER_SCAFFOLD_NAME.to_string()],
..crate::BooleanOptions::default()
};
let mut combined: Result<Option<BrepSolid>, KernelRefusal> = Ok(None);
let units = miter_tangent_units(solid, &selected_edge_ids, &chain_corners, edge_points.len());
for unit in &units {
let index = unit[0];
let point = &edge_points[index];
let blended = if unit.len() > 1 {
let points = unit.iter().map(|&member| edge_points[member]).collect::<Vec<_>>();
let names = edge_names.map(|all| unit.iter().map(|&member| all[member].clone()).collect::<Vec<_>>());
fillet_edges_group_with_corner_policy(
solid,
&points,
names.as_deref(),
radius,
chamfer,
name,
None,
wrap_concave,
!crate::blend::accept_once_on(),
)?
} else {
let edge_id = resolve_edge_by_point(solid, *point)?;
let edge_name = per_edge_name(edge_names, name, index);
let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
check_mixed_concavity(solid, edge_id, entry)?;
check_support_extent(solid, edge_id, radius, entry)?;
fillet_or_chamfer(
solid,
edge_id,
radius,
chamfer,
edge_name,
ToolEnds::default(),
Lane::CutterFirst,
)?
};
combined = match combined {
Err(e) => Err(e),
Ok(None) => Ok(Some(blended)),
Ok(Some(previous)) => {
crate::boolean_operation(&previous, &blended, operation, &options)
.map(Some)
.map_err(|error| {
error.with_message(|error| {
format!("fillet_edges: chain-corner miter composition failed: {error}")
})
})
}
};
if combined.is_err() {
break;
}
}
match combined {
Ok(Some(mut mitered)) => {
drop_slit_shells(&mut mitered);
mitered
}
Ok(None) => {
return Err(KernelRefusal::input(
KernelStage::Collect,
"miter_selection",
"fillet_edges: no edges selected",
))
}
Err(miter_err) => match build_sequential() {
Ok(seq) if seq.validate().is_empty() => seq,
_ => return Err(miter_err),
},
}
} else {
build_sequential()?
};
let mut open_mixed_corners: Vec<(Vec3, [u64; 2], Option<String>)> = Vec::new();
if !chamfer {
for (corner, adjacent) in &chain_corners {
let has_concave_unselected_edge = solid.edges.iter().any(|edge| {
if selected_edge_ids.contains(&edge.id) {
return false;
}
let Ok(a) = edge.curve.evaluate(edge.t0) else {
return false;
};
let Ok(b) = edge.curve.evaluate(edge.t1) else {
return false;
};
(a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
&& analyze_edge(solid, edge.id, radius)
.map(|cross| !cross.convex)
.unwrap_or(false)
});
if !has_concave_unselected_edge {
let convexity = |edge_id: u64| analyze_edge(solid, edge_id, radius).map(|cross| cross.convex).ok();
let selected_pair = [convexity(selected_edge_ids[adjacent[0]]), convexity(selected_edge_ids[adjacent[1]])];
let one_concave = matches!(selected_pair, [Some(true), Some(false)] | [Some(false), Some(true)]);
let has_convex_unselected_edge = solid.edges.iter().any(|edge| {
if selected_edge_ids.contains(&edge.id) {
return false;
}
let (Ok(a), Ok(b)) = (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1)) else {
return false;
};
(a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
&& analyze_edge(solid, edge.id, radius).map(|cross| cross.convex).unwrap_or(false)
});
if one_concave && has_convex_unselected_edge && crate::blend::open_mixed_corner_on() {
open_mixed_corners.push((
*corner,
[selected_edge_ids[adjacent[0]], selected_edge_ids[adjacent[1]]],
corner_face_name(edge_names, name, adjacent),
));
}
continue;
}
let corner_name = corner_face_name(edge_names, name, adjacent);
if let Ok(rounded) = crate::blend::round_concave_chain_corner(
&result,
solid,
*corner,
[
selected_edge_ids[adjacent[0]],
selected_edge_ids[adjacent[1]],
],
radius,
corner_name.as_deref(),
) {
result = rounded;
}
}
}
if !chamfer {
for (ci, corner) in corners.iter().enumerate() {
let corner_name = corner_face_name(edge_names, name, &corner_edges[ci]);
match crate::blend::round_convex_corner(&result, *corner, radius, corner_name.as_deref()) {
Ok(rounded) => result = rounded,
Err(error) => {
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!("corner at {corner:?} left as the edge fillets: {error}");
}
}
}
}
}
heal_edge_vertex_gaps(&mut result, radius)?;
crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
let mut body = settle_cutter_scaffold(result);
for (corner, pair, corner_name) in &open_mixed_corners {
match crate::blend::round_mixed_concave_open_corner(&body, solid, *corner, *pair, radius, corner_name.as_deref()) {
Ok(rounded) => body = rounded,
Err(error) => {
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!("open mixed corner at {corner:?} left as the composition built it: {error}");
}
}
}
}
Ok(GroupBody { body, bowtie_clean: false })
}
fn drop_slit_shells_on() -> bool {
switch!(live "BREP_FILLET_MITER_DROP_SLIT_SHELLS", true)
}
fn drop_slit_shells(body: &mut BrepSolid) -> usize {
if !drop_slit_shells_on() || body.shells.len() < 2 {
return 0;
}
let mut users: std::collections::HashMap<u64, std::collections::HashSet<usize>> = std::collections::HashMap::new();
for (index, shell) in body.shells.iter().enumerate() {
for coedge in shell.faces.iter().flat_map(|face| &face.loops).flat_map(|lp| &lp.coedges) {
users.entry(coedge.edge_id).or_default().insert(index);
}
}
let slit_face = |face: &crate::topology::FaceRecord| {
face.loops.iter().all(|lp| {
let mut runs: std::collections::HashMap<u64, (usize, usize)> = std::collections::HashMap::new();
for coedge in &lp.coedges {
let entry = runs.entry(coedge.edge_id).or_default();
if coedge.forward {
entry.0 += 1;
} else {
entry.1 += 1;
}
}
!runs.is_empty() && runs.values().all(|&(forward, back)| forward == 1 && back == 1)
})
};
let mut index = 0usize;
let mut dropped = 0usize;
body.shells.retain(|shell| {
let this = index;
index += 1;
let slit = this > 0
&& !shell.faces.is_empty()
&& shell.faces.iter().all(|face| slit_face(face))
&& shell
.faces
.iter()
.flat_map(|face| &face.loops)
.flat_map(|lp| &lp.coedges)
.all(|coedge| users.get(&coedge.edge_id).is_some_and(|set| set.len() == 1));
dropped += usize::from(slit);
!slit
});
if dropped == 0 {
return 0;
}
let used_edges: std::collections::HashSet<u64> = body
.shells
.iter()
.flat_map(|shell| &shell.faces)
.flat_map(|face| &face.loops)
.flat_map(|lp| &lp.coedges)
.map(|coedge| coedge.edge_id)
.collect();
body.edges.retain(|edge| used_edges.contains(&edge.id));
let used_vertices: std::collections::HashSet<u64> =
body.edges.iter().flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id]).collect();
body.vertices.retain(|vertex| used_vertices.contains(&vertex.id));
dropped
}
fn miter_tangent_units_on() -> bool {
switch!(live "BREP_FILLET_MITER_TANGENT_UNITS", true)
}
fn miter_tangent_units(
solid: &BrepSolid,
selected_edge_ids: &[u64],
chain_corners: &[(Vec3, [usize; 2])],
count: usize,
) -> Vec<Vec<usize>> {
let singles = || (0..count).map(|index| vec![index]).collect::<Vec<_>>();
if !miter_tangent_units_on() || selected_edge_ids.len() != count {
return singles();
}
let leaving = |edge_id: u64, corner: Vec3| -> Option<Vec3> {
let edge = solid.edges.iter().find(|edge| edge.id == edge_id)?;
if edge.degenerate {
return None;
}
let start = edge.curve.evaluate(edge.t0).ok()?;
let end = edge.curve.evaluate(edge.t1).ok()?;
let at_start = start.sub(corner).length() <= end.sub(corner).length();
let t = if at_start { edge.t0 } else { edge.t1 };
let tangent = edge.curve.unit_tangent(t, edge.t0, edge.t1).ok()?;
Some(if at_start { tangent } else { tangent.scale(-1.0) })
};
let mut parent: Vec<usize> = (0..count).collect();
fn root(parent: &mut [usize], mut index: usize) -> usize {
while parent[index] != index {
parent[index] = parent[parent[index]];
index = parent[index];
}
index
}
for (corner, [a, b]) in chain_corners {
let (Some(ta), Some(tb)) = (leaving(selected_edge_ids[*a], *corner), leaving(selected_edge_ids[*b], *corner)) else {
continue;
};
if ta.dot(tb) <= -(1.0 - 1e-6) {
let (ra, rb) = (root(&mut parent, *a), root(&mut parent, *b));
if ra != rb {
parent[ra.max(rb)] = ra.min(rb);
}
}
}
let mut units: Vec<Vec<usize>> = Vec::new();
let mut unit_of_root: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
for index in 0..count {
let r = root(&mut parent, index);
let slot = *unit_of_root.entry(r).or_insert_with(|| {
units.push(Vec::new());
units.len() - 1
});
units[slot].push(index);
}
if units.len() <= 1 || units.iter().all(|unit| unit.len() == 1) {
return singles();
}
units
}
fn concave_star_edges(
solid: &BrepSolid,
selected_edge_ids: &[u64],
corner_edges: &[Vec<usize>],
radius: f64,
chamfer: bool,
) -> Vec<usize> {
if chamfer || corner_edges.is_empty() || !concave_first_on() {
return Vec::new();
}
let Some(convexity) = crate::blend::selection_convexity(solid, selected_edge_ids, radius) else {
return Vec::new();
};
(0..selected_edge_ids.len())
.filter(|&index| !convexity[index] && corner_edges.iter().any(|adjacent| adjacent.contains(&index)))
.collect()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CutterClass {
Marched,
Straight,
Circular,
}
fn cutter_class(solid: &BrepSolid, edge_id: u64, radius: f64) -> CutterClass {
match super::analyze::analyze_edge(solid, edge_id, radius) {
Ok(cross) => match cross.path {
super::analyze::EdgePath::Straight { .. } => CutterClass::Straight,
super::analyze::EdgePath::Circular { .. } => CutterClass::Circular,
},
Err(_) => CutterClass::Marched,
}
}
fn mixed_star_class_ranks(
solid: &BrepSolid,
selected_edge_ids: &[u64],
corner_edges: &[Vec<usize>],
radius: f64,
chamfer: bool,
) -> Vec<u8> {
let mut ranks = vec![2u8; selected_edge_ids.len()];
if chamfer || corner_edges.is_empty() || !class_order_on() {
return ranks;
}
let classes: Vec<CutterClass> = selected_edge_ids
.iter()
.map(|&edge_id| cutter_class(solid, edge_id, radius))
.collect();
for adjacent in corner_edges {
let Some(&head) = adjacent.first() else {
continue;
};
if adjacent.iter().all(|&index| classes[index] == classes[head]) {
continue;
}
for &index in adjacent {
match classes[index] {
CutterClass::Marched => ranks[index] = 1,
CutterClass::Circular => ranks[index] = 3,
CutterClass::Straight => {}
}
}
}
ranks
}
pub(super) fn sequential_build_order(count: usize, first: &[usize], ranks: &[u8]) -> Vec<usize> {
let mut order: Vec<usize> = (0..count).collect();
order.sort_by_key(|&index| if first.contains(&index) { 0 } else { ranks.get(index).copied().unwrap_or(2).max(1) });
order
}
fn class_order_on() -> bool {
true
}
fn corners_concave_first(
corners: Vec<Vec3>,
corner_edges: Vec<Vec<usize>>,
first: &[usize],
) -> (Vec<Vec3>, Vec<Vec<usize>>) {
if first.is_empty() {
return (corners, corner_edges);
}
let mut ordered: Vec<(Vec3, Vec<usize>)> = Vec::with_capacity(corners.len());
let pairs: Vec<(Vec3, Vec<usize>)> = corners.into_iter().zip(corner_edges).collect();
let with_concave = |adjacent: &Vec<usize>| adjacent.iter().any(|index| first.contains(index));
ordered.extend(pairs.iter().filter(|(_, adjacent)| with_concave(adjacent)).cloned());
ordered.extend(pairs.iter().filter(|(_, adjacent)| !with_concave(adjacent)).cloned());
ordered.into_iter().unzip()
}
fn concave_first_on() -> bool {
true
}
pub fn fillet_edges_variable(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radii: &[(f64, f64)],
chamfer: bool,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
let _operation = crate::blend::BlendOperation::enter();
if edge_points.is_empty() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"variable_selection",
"fillet_edges_variable: no edges selected",
));
}
let per_edge_stops: Vec<Vec<(f64, f64)>> = vec![radii.to_vec(); edge_points.len()];
fillet_edges_variable_impl(
solid,
edge_points,
edge_names,
&per_edge_stops,
chamfer,
name,
"fillet_edges_variable",
true,
)
}
#[allow(clippy::too_many_arguments)]
fn fillet_edges_variable_impl(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
per_edge_stops: &[Vec<(f64, f64)>],
chamfer: bool,
name: Option<&str>,
entry: &str,
allow_tapered_sequential: bool,
) -> Result<BrepSolid, KernelRefusal> {
let bounds = crate::recentre::solid_bounds(solid);
let translated = match crate::recentre::control_offset_at_entry() {
Some(offset) => Some((offset, crate::recentre::EntryKind::Control)),
None => crate::recentre::guard_offset(bounds).map(|offset| {
crate::recentre::profile_fired(entry, offset, bounds);
(offset, crate::recentre::EntryKind::Guard)
}),
};
if let Some((offset, kind)) = translated {
let _entry = crate::recentre::TranslatedEntry::open(kind);
let translate = |solid: &BrepSolid, offset: Vec3| {
crate::recentre::translate_solid(solid, offset).map_err(|message| {
KernelRefusal::internal(
KernelStage::Collect,
"translation_control",
format!("{entry}: the translation control could not move the solid: {message}"),
)
})
};
let moved = translate(solid, offset)?;
let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
let result = fillet_edges_variable_impl(
&moved,
&points,
edge_names,
per_edge_stops,
chamfer,
name,
entry,
allow_tapered_sequential,
)?;
return translate(&result, offset.scale(-1.0));
}
debug_assert_eq!(per_edge_stops.len(), edge_points.len());
if let Some(constant) = per_edge_stops
.first()
.and_then(|stops| stops.first())
.map(|(_, radius)| *radius)
{
let uniform = per_edge_stops.iter().all(|stops| {
stops
.iter()
.all(|(_, radius)| (radius - constant).abs() <= 1e-12 * (1.0 + constant.abs()))
});
if uniform && constant > 0.0 {
return fillet_edges(solid, edge_points, edge_names, constant, chamfer, name);
}
}
let max_radius = per_edge_stops
.iter()
.flat_map(|stops| stops.iter())
.map(|(_, r)| r.abs())
.fold(0.0_f64, f64::max);
if crate::blend::taper_network_on() && edge_points.len() >= 2 {
match fillet_variable_network(
solid, edge_points, edge_names, per_edge_stops, chamfer, name, entry, max_radius,
)? {
Ok(built) => return Ok(built),
Err(refusal) if crate::blend::relief::relieving() => return Err(refusal),
Err(_) => {}
}
}
let mut chain_corner = false;
{
use rustc_hash::FxHashSet as HashSet;
let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
for (i, point) in edge_points.iter().enumerate() {
if let Ok(edge_id) = resolve_edge_by_point(solid, *point) {
if let Some(edge) = solid.edges.iter().find(|e| e.id == edge_id) {
if let (Ok(a), Ok(b)) =
(edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1))
{
endpoints.push((a, i));
endpoints.push((b, i));
}
}
}
}
let mut used = vec![false; endpoints.len()];
for i in 0..endpoints.len() {
if used[i] {
continue;
}
used[i] = true;
let mut edges_here: HashSet<usize> = HashSet::default();
edges_here.insert(endpoints[i].1);
for j in (i + 1)..endpoints.len() {
if used[j] {
continue;
}
if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
used[j] = true;
edges_here.insert(endpoints[j].1);
}
}
if edges_here.len() == 2 {
chain_corner = true;
}
}
}
let miter_operation = if chain_corner {
let probe_radius = if max_radius > 0.0 { max_radius } else { 1.0 };
let mut any_convex = false;
let mut any_concave = false;
for point in edge_points {
match resolve_edge_by_point(solid, *point)
.and_then(|edge_id| analyze_edge(solid, edge_id, probe_radius))
{
Ok(cross) if cross.convex => any_convex = true,
Ok(_) => any_concave = true,
Err(_) => {
any_convex = true;
any_concave = true;
break;
}
}
}
match (any_convex, any_concave) {
(true, false) => Some(crate::BooleanOperation::Intersect),
(false, true) => Some(crate::BooleanOperation::Union),
_ => None,
}
} else {
None
};
let miter_attempt: Option<BrepSolid> = if let Some(operation) = miter_operation {
let options = crate::BooleanOptions::default();
let mut combined: Option<BrepSolid> = None;
let mut failed = false;
for (index, point) in edge_points.iter().enumerate() {
let Ok(edge_id) = resolve_edge_by_point(solid, *point) else {
failed = true;
break;
};
let edge_name = per_edge_name(edge_names, name, index);
let Ok(blended) = crate::blend::blend_edge_variable(
solid,
edge_id,
&per_edge_stops[index],
chamfer,
edge_name,
) else {
failed = true;
break;
};
let next = match combined.take() {
None => blended,
Some(previous) => {
match crate::boolean_operation(&previous, &blended, operation, &options) {
Ok(next) => next,
Err(_) => {
failed = true;
break;
}
}
}
};
combined = Some(next);
}
if failed {
None
} else {
combined.filter(|s| s.validate().is_empty())
}
} else {
None
};
let mut result = match miter_attempt {
Some(mitered) => mitered,
None => {
let tapered = per_edge_stops.iter().any(|stops| {
stops.first().is_some_and(|(_, first)| {
stops
.iter()
.any(|(_, r)| (r - first).abs() > 1e-12 * (1.0 + first.abs()))
})
});
if chain_corner && tapered && !allow_tapered_sequential {
return Err(KernelRefusal::unsupported(
KernelStage::Sew,
"tapered_chain_miter",
format!(
"{entry}: the tapered blends across the selected chain's shared corners \
did not compose to a valid solid (the fitted blend boundaries could not \
be mitered); fillet fewer edges per operation or reduce the taper"
),
));
}
let mut sequential = solid.clone();
for (index, point) in edge_points.iter().enumerate() {
let edge_id = resolve_edge_by_point(&sequential, *point)?;
let edge_name = per_edge_name(edge_names, name, index);
sequential = crate::blend::blend_edge_variable(
&sequential,
edge_id,
&per_edge_stops[index],
chamfer,
edge_name,
)?;
}
sequential
}
};
heal_edge_vertex_gaps(&mut result, max_radius)?;
crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
let issues = result.validate();
if !issues.is_empty() {
let (detail, class) = if allow_tapered_sequential {
(
"tapered blends meet at a shared chain vertex with \
mismatched radii — the radius-transition corner patch is not implemented; \
fillet the edges in separate operations or use matching stop radii",
RefusalClass::UnsupportedGeometry {
what: "taper_transition_patch".into(),
},
)
} else {
(
"the composed radius-law blend produced invalid topology — the blends \
across a shared chain corner failed to reassemble",
RefusalClass::Internal {
what: "law_chain_topology".into(),
},
)
};
return Err(KernelRefusal::new(
class,
KernelStage::Validate,
format!(
"{entry}: {detail} ({} validation issues, first: {})",
issues.len(),
issues
.first()
.map(|issue| issue.message.clone())
.unwrap_or_default()
),
));
}
crate::soundness::census::profile_operation(&result, &[solid], None, entry);
crate::accept_sound(result, entry)
}
#[allow(clippy::too_many_arguments)]
fn fillet_variable_network(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
per_edge_stops: &[Vec<(f64, f64)>],
chamfer: bool,
name: Option<&str>,
entry: &str,
max_radius: f64,
) -> Result<Result<BrepSolid, KernelRefusal>, KernelRefusal> {
let mut edge_ids = Vec::with_capacity(edge_points.len());
for point in edge_points {
edge_ids.push(resolve_edge_by_point(solid, *point)?);
}
let laws: Vec<crate::blend::StripeRadius> =
per_edge_stops.iter().map(|stops| crate::blend::StripeRadius::from_stops(stops)).collect();
let network_names: Vec<Option<String>> = (0..edge_points.len())
.map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
.collect();
let network_corner_name = |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
let law_network = || {
crate::blend::blend_star_network_laws(solid, &edge_ids, &laws, chamfer, &network_names, &network_corner_name, true)
};
let network_result = law_network().or_else(|first| {
if crate::blend::is_pcurve_floor_refusal(&first) {
crate::blend::with_pcurve_knot_retry(law_network).map_err(|_| first)
} else {
Err(first)
}
});
let mut network = match network_result {
Ok(network) => network,
Err(refusal) => {
if debug {
eprintln!("law network refused: {refusal}");
}
return Ok(Err(refusal));
}
};
let healed = heal_edge_vertex_gaps(&mut network, max_radius);
crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
let issues = network.validate();
let interference = check_blend_interference(solid, &network, &edge_ids, entry);
let bowtie = check_loop_self_crossings(&network, entry).and_then(|()| check_blend_wall_loops(&network, entry));
if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
crate::soundness::census::profile_operation(&network, &[solid], None, entry);
match crate::accept_sound(network, entry) {
Ok(sound) => return Ok(Ok(sound)),
Err(refusal) => {
if debug {
eprintln!("law network result unsound: {refusal}");
}
return Ok(Err(refusal));
}
}
}
if debug {
eprintln!(
"law network result rejected: heal={healed:?} issues={issues:?} \
interference={interference:?} bowtie={bowtie:?}"
);
}
Ok(Err(KernelRefusal::unsupported(
KernelStage::Validate,
"law_network_rejected",
format!(
"{entry}: the tapered network's result was rejected (heal {}, {} validation issues, interference {}, loops {})",
if healed.is_ok() { "ok" } else { "failed" },
issues.len(),
if interference.is_ok() { "ok" } else { "found" },
if bowtie.is_ok() { "ok" } else { "found" },
),
)))
}
pub(crate) fn fillet_edges_variable_stops(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
per_edge_stops: &[Vec<(f64, f64)>],
chamfer: bool,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
let _operation = crate::blend::BlendOperation::enter();
if edge_points.is_empty() || per_edge_stops.len() != edge_points.len() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"variable_selection",
"fillet_edges_variable_stops: one stop list per selected edge is required",
));
}
fillet_edges_variable_impl(
solid,
edge_points,
edge_names,
per_edge_stops,
chamfer,
name,
"fillet_edges_variable_stops",
false,
)
}
struct ChainLink {
input_index: usize,
forward: bool,
arc: Vec<(f64, f64)>,
length: f64,
abscissa: f64,
}
fn edge_arc_table(
curve: &NurbsCurve,
t0: f64,
t1: f64,
tol: f64,
) -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
let evaluate = |t: f64| curve.evaluate(t).or_refuse(KernelStage::Collect, "arc_table");
let build = |n: usize| -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
let mut table = Vec::with_capacity(n + 1);
let mut cumulative = 0.0;
let mut previous = evaluate(t0)?;
table.push((0.0, 0.0));
for j in 1..=n {
let fraction = j as f64 / n as f64;
let point = evaluate(t0 + (t1 - t0) * fraction)?;
cumulative += point.sub(previous).length();
previous = point;
table.push((fraction, cumulative));
}
Ok((table, cumulative))
};
let mut n = 16usize;
let (mut table, mut length) = build(n)?;
while n < 4096 {
n *= 2;
let (next_table, next_length) = build(n)?;
let converged = (next_length - length).abs() <= tol;
table = next_table;
length = next_length;
if converged {
break;
}
}
Ok((table, length))
}
fn arc_length_at_fraction(table: &[(f64, f64)], fraction: f64) -> f64 {
let fraction = fraction.clamp(0.0, 1.0);
let intervals = table.len() - 1;
let scaled = fraction * intervals as f64;
let index = (scaled.floor() as usize).min(intervals - 1);
let local = scaled - index as f64;
let (_, a) = table[index];
let (_, b) = table[index + 1];
a + (b - a) * local
}
fn resolve_selected_chain(
solid: &BrepSolid,
edge_points: &[Vec3],
entry: &str,
tol: f64,
) -> Result<Vec<ChainLink>, KernelRefusal> {
let band = crate::tolerance::COINCIDENCE_DISTANCE_FLOOR;
struct Resolved {
edge_id: u64,
start: Vec3,
end: Vec3,
arc: Vec<(f64, f64)>,
length: f64,
}
let mut resolved: Vec<Resolved> = Vec::with_capacity(edge_points.len());
for point in edge_points {
let edge_id = resolve_edge_by_point(solid, *point)?;
if resolved.iter().any(|r| r.edge_id == edge_id) {
return Err(KernelRefusal::input(
KernelStage::Collect,
"duplicate_edge",
format!("{entry}: the same edge was selected more than once"),
));
}
let edge = solid
.edges
.iter()
.find(|e| e.id == edge_id)
.ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Collect,
"chain_resolved_edge",
format!("{entry}: resolved edge vanished"),
)
})?;
let (arc, length) = edge_arc_table(&edge.curve, edge.t0, edge.t1, tol)?;
if !(length > 0.0) {
return Err(KernelRefusal::input(
KernelStage::Collect,
"zero_length_edge",
format!("{entry}: selected edge has zero length"),
));
}
resolved.push(Resolved {
edge_id,
start: edge
.curve
.evaluate(edge.t0)
.or_refuse(KernelStage::Collect, "chain_edge_start")?,
end: edge
.curve
.evaluate(edge.t1)
.or_refuse(KernelStage::Collect, "chain_edge_end")?,
arc,
length,
});
}
let n = resolved.len();
if n == 1 {
let only = resolved.remove(0);
return Ok(vec![ChainLink {
input_index: 0,
forward: true,
arc: only.arc,
length: only.length,
abscissa: 0.0,
}]);
}
let mut clusters: Vec<(Vec3, Vec<(usize, bool)>)> = Vec::new();
for (i, r) in resolved.iter().enumerate() {
for (point, is_start) in [(r.start, true), (r.end, false)] {
match clusters
.iter_mut()
.find(|(anchor, _)| anchor.sub(point).length() < band)
{
Some((_, members)) => members.push((i, is_start)),
None => clusters.push((point, vec![(i, is_start)])),
}
}
}
if clusters.iter().any(|(_, members)| members.len() > 2) {
return Err(KernelRefusal::input(
KernelStage::Collect,
"chain_branching",
format!(
"{entry}: selected edges must form one open chain \
(a vertex is shared by three or more selected edges)"
),
));
}
let free: Vec<usize> = clusters
.iter()
.enumerate()
.filter(|(_, (_, members))| members.len() == 1)
.map(|(c, _)| c)
.collect();
if free.len() != 2 {
return Err(KernelRefusal::input(
KernelStage::Collect,
"chain_open",
format!(
"{entry}: selected edges must form one OPEN chain \
(closed rings and disconnected selections are not supported)"
),
));
}
let start_cluster = *free
.iter()
.min_by_key(|&&c| clusters[c].1[0].0)
.expect("two free ends");
let mut links: Vec<ChainLink> = Vec::with_capacity(n);
let mut visited = vec![false; n];
let mut abscissa = 0.0_f64;
let mut cluster = start_cluster;
for _ in 0..n {
let Some(&(edge_index, entered_at_start)) = clusters[cluster]
.1
.iter()
.find(|(edge_index, _)| !visited[*edge_index])
else {
return Err(KernelRefusal::input(
KernelStage::Collect,
"chain_walk",
format!(
"{entry}: selected edges are not connected into one chain"
),
));
};
visited[edge_index] = true;
let r = &resolved[edge_index];
links.push(ChainLink {
input_index: edge_index,
forward: entered_at_start,
arc: r.arc.clone(),
length: r.length,
abscissa,
});
abscissa += r.length;
let exit_point = if entered_at_start { r.end } else { r.start };
cluster = clusters
.iter()
.position(|(anchor, _)| anchor.sub(exit_point).length() < band)
.ok_or_else(|| {
KernelRefusal::internal(
KernelStage::Collect,
"chain_cluster",
format!("{entry}: chain walk lost an endpoint cluster"),
)
})?;
}
if visited.iter().any(|v| !v) {
return Err(KernelRefusal::input(
KernelStage::Collect,
"chain_connected",
format!(
"{entry}: selected edges are not connected into one chain"
),
));
}
Ok(links)
}
fn law_stops_for_link(
link: &ChainLink,
law: &crate::law::RadiusLaw,
scale: f64,
tol: f64,
) -> Vec<(f64, f64)> {
let radius_at_fraction = |fraction: f64| -> f64 {
let arc = arc_length_at_fraction(&link.arc, fraction);
let chain_s = if link.forward {
link.abscissa + arc
} else {
link.abscissa + (link.length - arc)
};
law.radius_at(chain_s * scale)
};
let curvature = law
.max_second_derivative(link.abscissa * scale, (link.abscissa + link.length) * scale)
* (scale * link.length).powi(2);
let base = if curvature * 0.125 <= tol {
1usize
} else {
((curvature / (8.0 * tol)).sqrt().ceil() as usize).clamp(1, 256)
};
let mut stops: Vec<(f64, f64)> = (0..=base)
.map(|j| {
let fraction = j as f64 / base as f64;
(fraction, radius_at_fraction(fraction))
})
.collect();
let mut depth = 0usize;
while depth < 8 {
let mut refined: Vec<(f64, f64)> = Vec::with_capacity(stops.len());
let mut inserted = false;
for pair in stops.windows(2) {
refined.push(pair[0]);
let mid_fraction = 0.5 * (pair[0].0 + pair[1].0);
let law_mid = radius_at_fraction(mid_fraction);
let linear_mid = 0.5 * (pair[0].1 + pair[1].1);
if (law_mid - linear_mid).abs() > tol {
refined.push((mid_fraction, law_mid));
inserted = true;
}
}
refined.push(*stops.last().expect("at least two stops"));
stops = refined;
if !inserted {
break;
}
depth += 1;
}
stops
}
pub fn fillet_edges_variable_law(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
law: &crate::law::RadiusLaw,
chamfer: bool,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
let _operation = crate::blend::BlendOperation::enter();
const ENTRY: &str = "fillet_edges_variable_law";
if edge_points.is_empty() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"law_selection",
format!("{ENTRY}: no edges selected"),
));
}
let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
fillet_variable_law_on_chain(
solid,
edge_points,
edge_names,
&chain,
law,
chamfer,
name,
ENTRY,
tolerances.intersection_fit,
)
}
pub fn fillet_edges_variable_vertex_radii(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
vertex_radii: &[f64],
chamfer: bool,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
let _operation = crate::blend::BlendOperation::enter();
const ENTRY: &str = "fillet_edges_variable_vertex_radii";
if edge_points.is_empty() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"vertex_radii_selection",
format!("{ENTRY}: no edges selected"),
));
}
let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
let lengths: Vec<f64> = chain.iter().map(|link| link.length).collect();
let law = crate::law::RadiusLaw::from_vertex_radii(&lengths, vertex_radii)
.map_err(|error| error.with_message(|error| format!("{ENTRY}: {error}")))?;
fillet_variable_law_on_chain(
solid,
edge_points,
edge_names,
&chain,
&law,
chamfer,
name,
ENTRY,
tolerances.intersection_fit,
)
}
#[allow(clippy::too_many_arguments)]
fn fillet_variable_law_on_chain(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
chain: &[ChainLink],
law: &crate::law::RadiusLaw,
chamfer: bool,
name: Option<&str>,
entry: &str,
tol: f64,
) -> Result<BrepSolid, KernelRefusal> {
let chain_length: f64 = chain.iter().map(|link| link.length).sum();
let scale = law.total_length() / chain_length;
let mut per_edge_stops: Vec<Vec<(f64, f64)>> = vec![Vec::new(); edge_points.len()];
for link in chain {
per_edge_stops[link.input_index] = law_stops_for_link(link, law, scale, tol);
}
fillet_edges_variable_impl(
solid,
edge_points,
edge_names,
&per_edge_stops,
chamfer,
name,
entry,
false,
)
}
pub fn chamfer_edges_asymmetric(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
d1: f64,
d2: f64,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
let _operation = crate::blend::BlendOperation::enter();
if edge_points.is_empty() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"asymmetric_selection",
"chamfer_edges_asymmetric: no edges selected",
));
}
let mut result = solid.clone();
for (index, point) in edge_points.iter().enumerate() {
let edge_id = resolve_edge_by_point(&result, *point)?;
let edge_name = per_edge_name(edge_names, name, index);
result = chamfer_edge_asymmetric(&result, edge_id, d1, d2, edge_name)?;
}
refuse_invalid_result(&result, "chamfer_edges_asymmetric")?;
Ok(result)
}
pub fn chamfer_edges_angle(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
d1: f64,
angle_rad: f64,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
let _operation = crate::blend::BlendOperation::enter();
if edge_points.is_empty() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"angle_selection",
"chamfer_edges_angle: no edges selected",
));
}
let mut result = solid.clone();
for (index, point) in edge_points.iter().enumerate() {
let edge_id = resolve_edge_by_point(&result, *point)?;
let edge_name = per_edge_name(edge_names, name, index);
result = chamfer_edge_angle(&result, edge_id, d1, angle_rad, edge_name)?;
}
refuse_invalid_result(&result, "chamfer_edges_angle")?;
Ok(result)
}
fn dump_loops_debug(label: &str, solid: &BrepSolid) {
for shell in &solid.shells {
for face in &shell.faces {
for (index, loop_record) in face.loops.iter().enumerate() {
let walk: Vec<String> = loop_record
.coedges
.iter()
.map(|coedge| {
let uv = coedge
.pcurve
.domain()
.ok()
.and_then(|[q0, q1]| {
let a = coedge.pcurve.evaluate(q0).ok()?;
let b = coedge.pcurve.evaluate(q1).ok()?;
Some(format!("uv[({:.4},{:.4})->({:.4},{:.4})]", a.x, a.y, b.x, b.y))
})
.unwrap_or_default();
match solid.edges.iter().find(|e| e.id == coedge.edge_id) {
Some(edge) => format!(
"c{}:e{}{}({}->{}){uv}",
coedge.id,
edge.id,
if coedge.forward { "+" } else { "-" },
edge.start_vertex_id,
edge.end_vertex_id
),
None => format!("c{}:e{}?MISSING", coedge.id, coedge.edge_id),
}
})
.collect();
eprintln!(
"{label} face {} ({:?}) loop {index}: {}",
face.id,
face.name,
walk.join(" ")
);
}
}
}
}