use super::*;
pub fn fillet_edge(
solid: &BrepSolid,
edge_id: u64,
radius: f64,
name: Option<&str>,
) -> Result<BrepSolid, String> {
check_mixed_concavity(solid, edge_id, "fillet_edge")?;
check_support_extent(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, String> {
check_mixed_concavity(solid, edge_id, "fillet_edge")?;
check_support_extent(solid, edge_id, radius, "fillet_edge")?;
fillet_or_chamfer(solid, edge_id, radius, false, name, ToolEnds::default(), Lane::CutterFirst)
}
pub fn chamfer_edge(
solid: &BrepSolid,
edge_id: u64,
distance: f64,
name: Option<&str>,
) -> Result<BrepSolid, String> {
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>,
) -> Result<BrepSolid, String> {
let mut tool = match &cross.path {
EdgePath::Straight { direction, length } => {
extrude_profile_brep(profile, *direction, *length)?
}
EdgePath::Circular { .. } => {
return Err(
"chamfer_edge_asymmetric: only straight edges on planar faces are supported \
in this slice (asymmetric chamfer on general/curved edges is out of scope)"
.into(),
);
}
};
if let Some(name) = name {
let mut side_index = 0usize;
for shell in &mut tool.shells {
for face in &mut shell.faces {
if side_index == 1 && face.name.is_none() {
face.name = Some(name.to_string());
}
side_index += 1;
if side_index >= profile.len() {
break;
}
}
}
}
apply_tool(solid, &tool, cross.convex)
}
pub fn chamfer_edge_asymmetric(
solid: &BrepSolid,
edge_id: u64,
d1: f64,
d2: f64,
name: Option<&str>,
) -> Result<BrepSolid, String> {
if !(d1 > 0.0) || !(d2 > 0.0) || !d1.is_finite() || !d2.is_finite() {
return Err("chamfer_edge_asymmetric: both setback distances must be positive".into());
}
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)
}
pub fn chamfer_edge_angle(
solid: &BrepSolid,
edge_id: u64,
d1: f64,
angle_rad: f64,
name: Option<&str>,
) -> Result<BrepSolid, String> {
if !(d1 > 0.0) || !d1.is_finite() {
return Err("chamfer_edge_angle: setback distance d1 must be positive".into());
}
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)
}
pub(super) fn heal_edge_vertex_gaps(solid: &mut BrepSolid, radius: f64) -> Result<(), String> {
let search = (radius.abs() * 1e-3).max(1e-7);
crate::boolean::commit_nearby_edge_endpoints(solid, search).map_err(String::from)
}
fn resolve_edge_by_point(solid: &BrepSolid, point: Vec3) -> Result<u64, String> {
match crate::topology::nearest_edge(solid, point) {
Some((edge_id, distance)) if distance <= 1e-3 => Ok(edge_id),
Some((_, distance)) => Err(format!(
"fillet_edges: no edge within tolerance of the point (nearest {distance:.6})"
)),
None => Err("fillet_edges: solid has no edges".into()),
}
}
pub fn fillet_edges(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
chamfer: bool,
name: Option<&str>,
) -> Result<BrepSolid, String> {
if !(radius > 0.0) || !radius.is_finite() {
return Err("fillet_edges: radius must be positive".into());
}
if edge_points.is_empty() {
return Err("fillet_edges: no edges selected".into());
}
let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
let mut selected_ids = Vec::with_capacity(edge_points.len());
for point in edge_points {
let edge_id = resolve_edge_by_point(solid, *point)?;
check_mixed_concavity(solid, edge_id, entry)?;
check_support_extent(solid, edge_id, radius, entry)?;
selected_ids.push(edge_id);
}
if !chamfer {
check_mixed_corner_convexity(solid, &selected_ids, edge_points, edge_names, radius, name)?;
}
match fillet_edges_group(solid, edge_points, edge_names, radius, chamfer, name) {
Ok(result) => Ok(result),
Err(group_err) => {
let n = edge_points.len();
if n < 2 || n > 12 {
return Err(group_err);
}
for drop in 1..n {
for dropped in index_combinations(n, drop) {
let kept: Vec<Vec3> = (0..n)
.filter(|i| !dropped.contains(i))
.map(|i| edge_points[i])
.collect();
let kept_names: Option<Vec<String>> = edge_names.map(|names| {
(0..n)
.filter(|i| !dropped.contains(i))
.map(|i| names[i].clone())
.collect()
});
if let Ok(result) = fillet_edges_group(
solid,
&kept,
kept_names.as_deref(),
radius,
chamfer,
name,
) {
if result.validate().is_empty() {
return Ok(result);
}
}
}
}
Err(group_err)
}
}
}
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()),
}
}
fn check_mixed_corner_convexity(
solid: &BrepSolid,
edge_ids: &[u64],
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
name: Option<&str>,
) -> Result<(), String> {
match crate::blend::mixed_convexity_corner(solid, edge_ids, radius) {
Some(corner) => Err(mixed_corner_message(&corner, edge_points, edge_names, name)),
None => Ok(()),
}
}
fn mixed_corner_message(
corner: &crate::blend::MixedCorner,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
name: Option<&str>,
) -> String {
let label = |index: usize| -> 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),
})
};
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(", ")),
}
};
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 vertex blend \
yet. Blend the concave edges in one fillet and the convex ones in a later \
fillet — both orders build, and concave-first is the tidier result.",
corner.point.x,
corner.point.y,
corner.point.z,
list(&corner.convex),
list(&corner.concave),
)
}
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;
}
}
}
fn fillet_edges_group(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radius: f64,
chamfer: bool,
name: Option<&str>,
) -> Result<BrepSolid, String> {
use rustc_hash::FxHashSet as HashSet;
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;
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("fillet_edges: resolved edge vanished")?;
let (start, end) = (edge.curve.evaluate(edge.t0)?, edge.curve.evaluate(edge.t1)?);
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);
}
if components.len() > 1 {
let mut separated = solid.clone();
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<_>>()
});
separated =
fillet_edges_group(&separated, &points, names.as_deref(), radius, chamfer, name)?;
}
return Ok(separated);
}
if std::env::var("BREP_NO_NETWORK").is_err() {
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);
match crate::blend::blend_star_network(
solid,
&selected_edge_ids,
radius,
chamfer,
&network_names,
&network_corner_name,
) {
Ok(mut network) => {
let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
let healed = heal_edge_vertex_gaps(&mut network, radius);
let issues = network.validate();
let interference =
check_blend_interference(solid, &network, &selected_edge_ids, entry);
if healed.is_ok() && issues.is_empty() && interference.is_ok() {
return Ok(network);
}
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!(
"network result rejected: heal={healed:?} issues={issues:?} \
interference={interference:?}"
);
dump_loops_debug("INPUT", solid);
dump_loops_debug("RESULT", &network);
}
}
Err(refusal) => {
if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
eprintln!("network refused: {refusal}");
}
}
}
}
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 build_sequential = || -> Result<BrepSolid, String> {
let mut sequential = solid.clone();
for (index, point) in edge_points.iter().enumerate() {
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);
sequential = fillet_or_chamfer(
&sequential,
edge_id,
radius,
chamfer,
edge_name,
ends,
composition_lane,
)?;
}
Ok(sequential)
};
let mut result = if let Some(operation) = miter_operation {
let options = crate::BooleanOptions::default();
let mut combined: Result<Option<BrepSolid>, String> = Ok(None);
for (index, point) in edge_points.iter().enumerate() {
let edge_id = resolve_edge_by_point(solid, *point)?;
let edge_name = per_edge_name(edge_names, name, index);
let blended = {
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| {
format!("fillet_edges: chain-corner miter composition failed: {error}")
})
}
};
if combined.is_err() {
break;
}
}
match combined {
Ok(Some(mitered)) => mitered,
Ok(None) => return Err("fillet_edges: no edges selected".into()),
Err(miter_err) => match build_sequential() {
Ok(seq) if seq.validate().is_empty() => seq,
_ => return Err(miter_err),
},
}
} else {
build_sequential()?
};
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 {
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]);
if let Ok(rounded) =
crate::blend::round_convex_corner(&result, *corner, radius, corner_name.as_deref())
{
result = rounded;
}
}
}
heal_edge_vertex_gaps(&mut result, radius)?;
Ok(result)
}
pub fn fillet_edges_variable(
solid: &BrepSolid,
edge_points: &[Vec3],
edge_names: Option<&[String]>,
radii: &[(f64, f64)],
chamfer: bool,
name: Option<&str>,
) -> Result<BrepSolid, String> {
if edge_points.is_empty() {
return Err("fillet_edges_variable: no edges selected".into());
}
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, String> {
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);
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(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)?;
let issues = result.validate();
if !issues.is_empty() {
let detail = 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"
} else {
"the composed radius-law blend produced invalid topology — the blends \
across a shared chain corner failed to reassemble"
};
return Err(format!(
"{entry}: {detail} ({} validation issues, first: {})",
issues.len(),
issues
.first()
.map(|issue| issue.message.clone())
.unwrap_or_default()
));
}
Ok(result)
}
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), String> {
let build = |n: usize| -> Result<(Vec<(f64, f64)>, f64), String> {
let mut table = Vec::with_capacity(n + 1);
let mut cumulative = 0.0;
let mut previous = curve.evaluate(t0)?;
table.push((0.0, 0.0));
for j in 1..=n {
let fraction = j as f64 / n as f64;
let point = curve.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>, String> {
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(format!("{entry}: the same edge was selected more than once"));
}
let edge = solid
.edges
.iter()
.find(|e| e.id == edge_id)
.ok_or_else(|| format!("{entry}: resolved edge vanished"))?;
let (arc, length) = edge_arc_table(&edge.curve, edge.t0, edge.t1, tol)?;
if !(length > 0.0) {
return Err(format!("{entry}: selected edge has zero length"));
}
resolved.push(Resolved {
edge_id,
start: edge.curve.evaluate(edge.t0)?,
end: edge.curve.evaluate(edge.t1)?,
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(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(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(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(|| format!("{entry}: chain walk lost an endpoint cluster"))?;
}
if visited.iter().any(|v| !v) {
return Err(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, String> {
const ENTRY: &str = "fillet_edges_variable_law";
if edge_points.is_empty() {
return Err(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, String> {
const ENTRY: &str = "fillet_edges_variable_vertex_radii";
if edge_points.is_empty() {
return Err(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| 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, String> {
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, String> {
if edge_points.is_empty() {
return Err("chamfer_edges_asymmetric: no edges selected".into());
}
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)?;
}
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, String> {
if edge_points.is_empty() {
return Err("chamfer_edges_angle: no edges selected".into());
}
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)?;
}
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| {
match solid.edges.iter().find(|e| e.id == coedge.edge_id) {
Some(edge) => format!(
"c{}:e{}{}({}->{})",
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(" ")
);
}
}
}
}