brep_kernel/blending/fillet/edges.rs
1use super::*;
2
3/// Constant-radius rolling-ball fillet of one edge (Golovanov §4.9 march,
4/// §6.9 surgery; the cutter only where the march refuses).
5pub fn fillet_edge(
6 solid: &BrepSolid,
7 edge_id: u64,
8 radius: f64,
9 name: Option<&str>,
10) -> Result<BrepSolid, String> {
11 check_mixed_concavity(solid, edge_id, "fillet_edge")?;
12 check_support_extent(solid, edge_id, radius, "fillet_edge")?;
13 fillet_or_chamfer(solid, edge_id, radius, false, name, ToolEnds::default(), Lane::GeneralFirst)
14}
15
16/// `fillet_edge` with the cutter first: the input the corner-closure lanes
17/// (`round_convex_corner`, the mixed-convexity closures) were written
18/// against. Their tests build it directly; production reaches those lanes
19/// only through the group's sequential composition, which is cutter-first
20/// for the same reason.
21pub(crate) fn fillet_edge_cutter(
22 solid: &BrepSolid,
23 edge_id: u64,
24 radius: f64,
25 name: Option<&str>,
26) -> Result<BrepSolid, String> {
27 check_mixed_concavity(solid, edge_id, "fillet_edge")?;
28 check_support_extent(solid, edge_id, radius, "fillet_edge")?;
29 fillet_or_chamfer(solid, edge_id, radius, false, name, ToolEnds::default(), Lane::CutterFirst)
30}
31
32/// Equal-leg chamfer of one edge (Golovanov §6.11: the same construction
33/// with the arc's chord).
34pub fn chamfer_edge(
35 solid: &BrepSolid,
36 edge_id: u64,
37 distance: f64,
38 name: Option<&str>,
39) -> Result<BrepSolid, String> {
40 check_mixed_concavity(solid, edge_id, "chamfer_edge")?;
41 check_support_extent(solid, edge_id, distance, "chamfer_edge")?;
42 fillet_or_chamfer(solid, edge_id, distance, true, name, ToolEnds::default(), Lane::GeneralFirst)
43}
44
45/// Build and apply the chamfer tool for one straight edge from an already-built
46/// cross-section profile (curve index 1 is the chamfer chord / blend wall).
47/// Shared by the two-distance and distance-angle asymmetric entries.
48fn apply_chamfer_offsets_profile(
49 solid: &BrepSolid,
50 cross: &EdgeCross,
51 profile: &[NurbsCurve],
52 name: Option<&str>,
53) -> Result<BrepSolid, String> {
54 let mut tool = match &cross.path {
55 EdgePath::Straight { direction, length } => {
56 extrude_profile_brep(profile, *direction, *length)?
57 }
58 EdgePath::Circular { .. } => {
59 return Err(
60 "chamfer_edge_asymmetric: only straight edges on planar faces are supported \
61 in this slice (asymmetric chamfer on general/curved edges is out of scope)"
62 .into(),
63 );
64 }
65 };
66 if let Some(name) = name {
67 // Side faces are emitted in input-curve order; the chamfer wall is the
68 // second profile curve (index 1).
69 let mut side_index = 0usize;
70 for shell in &mut tool.shells {
71 for face in &mut shell.faces {
72 if side_index == 1 && face.name.is_none() {
73 face.name = Some(name.to_string());
74 }
75 side_index += 1;
76 if side_index >= profile.len() {
77 break;
78 }
79 }
80 }
81 }
82 apply_tool(solid, &tool, cross.convex)
83}
84
85/// Asymmetric (two-distance) chamfer of one STRAIGHT edge between two planar
86/// faces (Golovanov §6.11): setback `d1` along face 1 and `d2` along face 2 —
87/// the standard CAD "d1 × d2" bevel. General/curved edges are out of scope for
88/// this slice and return a clear error.
89pub fn chamfer_edge_asymmetric(
90 solid: &BrepSolid,
91 edge_id: u64,
92 d1: f64,
93 d2: f64,
94 name: Option<&str>,
95) -> Result<BrepSolid, String> {
96 if !(d1 > 0.0) || !(d2 > 0.0) || !d1.is_finite() || !d2.is_finite() {
97 return Err("chamfer_edge_asymmetric: both setback distances must be positive".into());
98 }
99 // `analyze_edge` only uses the radius to size the orientation probe step;
100 // the smaller setback keeps that probe inside both faces.
101 let cross = analyze_edge(solid, edge_id, d1.min(d2))?;
102 let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
103 apply_chamfer_offsets_profile(solid, &cross, &profile, name)
104}
105
106/// Distance-angle chamfer of one STRAIGHT edge between two planar faces
107/// (Golovanov §6.11): setback `d1` along face 1 and angle `angle_rad` between
108/// the chamfer face and face 1. `d2` is constructed geometrically in the
109/// cross-section plane (see `chamfer_angle_second_distance`), then the
110/// two-distance builder is applied.
111pub fn chamfer_edge_angle(
112 solid: &BrepSolid,
113 edge_id: u64,
114 d1: f64,
115 angle_rad: f64,
116 name: Option<&str>,
117) -> Result<BrepSolid, String> {
118 if !(d1 > 0.0) || !d1.is_finite() {
119 return Err("chamfer_edge_angle: setback distance d1 must be positive".into());
120 }
121 let cross = analyze_edge(solid, edge_id, d1)?;
122 let d2 = chamfer_angle_second_distance(&cross, d1, angle_rad)?;
123 let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
124 apply_chamfer_offsets_profile(solid, &cross, &profile, name)
125}
126
127/// Snap small trim/intersection gaps between blend edges and their vertices.
128/// The boolean endpoint welder preserves gaps already inside the validation
129/// band and limits repairs to `max(|radius| * 1e-3, 1e-4)`.
130pub(super) fn heal_edge_vertex_gaps(solid: &mut BrepSolid, radius: f64) -> Result<(), String> {
131 let search = (radius.abs() * 1e-3).max(1e-7);
132 crate::boolean::commit_nearby_edge_endpoints(solid, search).map_err(String::from)
133}
134
135/// Resolve a point within 1e-3 of a trimmed edge.
136fn resolve_edge_by_point(solid: &BrepSolid, point: Vec3) -> Result<u64, String> {
137 match crate::topology::nearest_edge(solid, point) {
138 Some((edge_id, distance)) if distance <= 1e-3 => Ok(edge_id),
139 Some((_, distance)) => Err(format!(
140 "fillet_edges: no edge within tolerance of the point (nearest {distance:.6})"
141 )),
142 None => Err("fillet_edges: solid has no edges".into()),
143 }
144}
145
146/// Fillet (or chamfer) a GROUP of edges as ONE operation, and — for fillets —
147/// round the convex "star" vertices where three or more of the selected edges
148/// meet (Golovanov §6.9.7). This is the whole multi-edge fillet in a single
149/// kernel call: the caller passes the object plus one 3D point on each edge,
150/// and the kernel orchestrates the filleting and corner blending against the
151/// full topology (so acute corners resolve coherently instead of being
152/// stitched edge-by-edge by the app). A corner the kernel cannot round (e.g.
153/// non-orthogonal beyond support, or a general no-common-ball star) is left as
154/// the edge fillets rather than failing the whole group.
155///
156/// When the WHOLE selection cannot be blended as one unit — a shared convex
157/// corner where a revolve axis/pole edge meets the adjacent cap edges can
158/// defeat the sequential corner surgery even though each edge and every proper
159/// SUBSET of the selection blends cleanly (the three fillets converge on the
160/// pole with no single end face across the corner) — we do NOT hard-reject the
161/// whole selection (which makes the app refuse it outright with "does not yet
162/// support the selected edge geometry"). Instead we blend the LARGEST subset
163/// of the selected edges that yields a VALID solid, dropping only the edge(s)
164/// that cannot co-blend at the corner. A selection that already composes is
165/// returned unchanged (byte-identical) — the subset search only runs after the
166/// full-group attempt errors.
167///
168/// `edge_names` (when `Some`) is the per-edge blend-FACE name parallel to
169/// `edge_points` — each grown wall is named after ITS originating edge; `None`
170/// names every wall with the single base `name` (the legacy/test behavior,
171/// byte-identical to before). The whole `*_edges` family takes the same
172/// `edge_names` slot in the same position.
173pub fn fillet_edges(
174 solid: &BrepSolid,
175 edge_points: &[Vec3],
176 edge_names: Option<&[String]>,
177 radius: f64,
178 chamfer: bool,
179 name: Option<&str>,
180) -> Result<BrepSolid, String> {
181 if !(radius > 0.0) || !radius.is_finite() {
182 return Err("fillet_edges: radius must be positive".into());
183 }
184 if edge_points.is_empty() {
185 return Err("fillet_edges: no edges selected".into());
186 }
187 // The rolling ball must reach both supports of every SELECTED edge — see
188 // `check_support_extent`. Checked ONCE, here, against the solid the
189 // selection was made on: inside the group build the faces have already
190 // been eaten into by earlier blends of the same selection, and a fillet
191 // legitimately runs off that remainder (two rounds pinching on a shared
192 // face). Dropping edges cannot rescue an oversized radius either, so
193 // this runs before the subset search rather than inside it.
194 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
195 let mut selected_ids = Vec::with_capacity(edge_points.len());
196 for point in edge_points {
197 let edge_id = resolve_edge_by_point(solid, *point)?;
198 check_mixed_concavity(solid, edge_id, entry)?;
199 check_support_extent(solid, edge_id, radius, entry)?;
200 selected_ids.push(edge_id);
201 }
202 // A corner where the selection mixes convexity — a convex edge dying into
203 // the concave edges it meets — has no rolling-ball closure at all: the
204 // blends run out against each other partway along, which is the vertex
205 // blend the stripe network does not construct yet. It is TERMINAL, and
206 // for the same reason as the two checks above it runs once, here: neither
207 // the cutter composition nor the subset search below can rescue it. The
208 // cutter "succeeds" on this shape by shredding the solid (a sliver end cap
209 // per corner, the carrier face split and renamed), which is worse than
210 // the named refusal — see the 2026-09-09 rib-spine report.
211 if !chamfer {
212 check_mixed_corner_convexity(solid, &selected_ids, edge_points, edge_names, radius, name)?;
213 }
214
215 match fillet_edges_group(solid, edge_points, edge_names, radius, chamfer, name) {
216 Ok(result) => Ok(result),
217 Err(group_err) => {
218 // Fewer than two edges: nothing to drop, so the group error is final.
219 // Cap the combinatorial search so a large malformed selection cannot
220 // explode (the full group carries the common case; the search is a
221 // rare fallback).
222 let n = edge_points.len();
223 if n < 2 || n > 12 {
224 return Err(group_err);
225 }
226 // Drop the fewest edges first (largest surviving subset), trying the
227 // drop-sets in lexicographic order so the result is deterministic.
228 // Return the first subset that blends to a VALID (watertight) solid.
229 for drop in 1..n {
230 for dropped in index_combinations(n, drop) {
231 let kept: Vec<Vec3> = (0..n)
232 .filter(|i| !dropped.contains(i))
233 .map(|i| edge_points[i])
234 .collect();
235 // Subset the per-edge blend-face names with the IDENTICAL
236 // drop-set so `kept_names[k]` still names `kept[k]`.
237 let kept_names: Option<Vec<String>> = edge_names.map(|names| {
238 (0..n)
239 .filter(|i| !dropped.contains(i))
240 .map(|i| names[i].clone())
241 .collect()
242 });
243 if let Ok(result) = fillet_edges_group(
244 solid,
245 &kept,
246 kept_names.as_deref(),
247 radius,
248 chamfer,
249 name,
250 ) {
251 if result.validate().is_empty() {
252 return Ok(result);
253 }
254 }
255 }
256 }
257 Err(group_err)
258 }
259 }
260}
261
262/// The blend-FACE name for the `i`-th selected edge: its per-edge name when the
263/// caller supplied the parallel `edge_names` (feature path — each wall named
264/// after its originating edge, `{fid}:BLEND:{edge}`), else the single base
265/// `name` for every wall (the legacy/test path, byte-identical to before).
266fn per_edge_name<'a>(
267 edge_names: Option<&'a [String]>,
268 base: Option<&'a str>,
269 i: usize,
270) -> Option<&'a str> {
271 match edge_names {
272 Some(names) => names.get(i).map(|value| value.as_str()),
273 None => base,
274 }
275}
276
277/// The star-corner patch name: `{base}:CORNER:{sorted+join of adjacent edge
278/// names}` when per-edge names were supplied (feature path), else the single
279/// `base` (legacy/test path — the corner keeps the wall name, pre-change
280/// behavior). `base` is `{fid}:BLEND` and each `edge_names[i]` is the composed
281/// `{fid}:BLEND:{edge}`, so stripping the `{base}:` prefix recovers the bare
282/// originating-edge name for the join. Unique per corner: two distinct corners
283/// never share the same set of >=3 selected edges.
284fn corner_face_name(
285 edge_names: Option<&[String]>,
286 base: Option<&str>,
287 adjacent: &[usize],
288) -> Option<String> {
289 match (edge_names, base) {
290 (Some(names), Some(base)) => {
291 let prefix = format!("{base}:");
292 let mut raws: Vec<&str> = adjacent
293 .iter()
294 .filter_map(|&i| names.get(i))
295 .map(|composed| composed.strip_prefix(&prefix).unwrap_or(composed.as_str()))
296 .collect();
297 raws.sort_unstable();
298 raws.dedup();
299 Some(format!("{base}:CORNER:{}", raws.join("+")))
300 }
301 _ => base.map(|value| value.to_string()),
302 }
303}
304
305/// **A corner with two concave edges cannot also take a convex one.**
306///
307/// Where selected edges meet, the rolling ball has to touch every face around
308/// the vertex from ONE side. A convex edge running into the concave edges it
309/// meets (a rib spine dying into the fillets at its own base) asks the ball to
310/// sit under the shared face for one blend and over it for the other, so no
311/// ball seats there and the corner has no closure: physically the convex blend
312/// runs out against the concave beads partway along the edge, which is a vertex
313/// blend the stripe network does not construct
314/// (`docs/developer/kernel-plans/fillet-stripe-network.md`).
315///
316/// The re-entrant vertex of a notch — ONE concave edge, the rest convex — is
317/// NOT this: the ball rolls round that single concave edge from one convex
318/// blend to the next, and `round_concave_chain_corner` / `round_convex_corner`
319/// close it with the horn-torus sector. Only its mirror is refused.
320///
321/// This is TERMINAL — deliberately not a fall-through to the cutter
322/// composition. The cutter answers this shape with a watertight but shredded
323/// solid: a sliver end cap at each unclosed corner and the carrier face split
324/// so the original name lands on a fragment, which breaks every downstream
325/// reference to it. A named refusal that says which edges to separate is
326/// worth more than that solid.
327fn check_mixed_corner_convexity(
328 solid: &BrepSolid,
329 edge_ids: &[u64],
330 edge_points: &[Vec3],
331 edge_names: Option<&[String]>,
332 radius: f64,
333 name: Option<&str>,
334) -> Result<(), String> {
335 match crate::blend::mixed_convexity_corner(solid, edge_ids, radius) {
336 Some(corner) => Err(mixed_corner_message(&corner, edge_points, edge_names, name)),
337 None => Ok(()),
338 }
339}
340
341/// The refusal text for [`check_mixed_corner_convexity`], naming the edges the
342/// way the user selected them (their originating edge names when the feature
343/// layer supplied them, else the picked point).
344fn mixed_corner_message(
345 corner: &crate::blend::MixedCorner,
346 edge_points: &[Vec3],
347 edge_names: Option<&[String]>,
348 name: Option<&str>,
349) -> String {
350 let label = |index: usize| -> String {
351 let named = edge_names.and_then(|names| names.get(index)).map(|composed| {
352 match name {
353 Some(base) => composed
354 .strip_prefix(&format!("{base}:"))
355 .unwrap_or(composed)
356 .to_string(),
357 None => composed.clone(),
358 }
359 });
360 named.unwrap_or_else(|| match edge_points.get(index) {
361 Some(point) => format!("the edge at ({:.3}, {:.3}, {:.3})", point.x, point.y, point.z),
362 None => format!("selection #{}", index + 1),
363 })
364 };
365 let list = |indices: &[usize]| -> String {
366 let parts: Vec<String> = indices.iter().map(|index| label(*index)).collect();
367 match parts.split_last() {
368 None => "none".to_string(),
369 Some((last, [])) => last.clone(),
370 Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
371 }
372 };
373 format!(
374 "fillet_edges: the selection mixes convexity at the corner ({:.3}, {:.3}, {:.3}) — convex \
375 {} meets concave {} there. One rolling ball cannot touch the face they share from both \
376 sides at once, so that corner has no closure: the convex blend runs out against the \
377 concave ones partway along the edge, and this kernel does not build that vertex blend \
378 yet. Blend the concave edges in one fillet and the convex ones in a later \
379 fillet — both orders build, and concave-first is the tidier result.",
380 corner.point.x,
381 corner.point.y,
382 corner.point.z,
383 list(&corner.convex),
384 list(&corner.concave),
385 )
386}
387
388/// All ways to choose `k` distinct indices from `0..n`, in lexicographic order.
389fn index_combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
390 let mut out = Vec::new();
391 if k == 0 || k > n {
392 return out;
393 }
394 let mut idx: Vec<usize> = (0..k).collect();
395 loop {
396 out.push(idx.clone());
397 // Advance to the next combination (like counting with carry).
398 let mut i = k;
399 loop {
400 if i == 0 {
401 return out;
402 }
403 i -= 1;
404 if idx[i] != i + n - k {
405 break;
406 }
407 }
408 idx[i] += 1;
409 for j in (i + 1)..k {
410 idx[j] = idx[j - 1] + 1;
411 }
412 }
413}
414
415/// Blend the WHOLE selection as one group (the single-shot multi-edge fillet).
416/// Errors if any selected edge or the shared-corner surgery cannot compose;
417/// `fillet_edges` wraps this with a maximal-valid-subset fallback.
418fn fillet_edges_group(
419 solid: &BrepSolid,
420 edge_points: &[Vec3],
421 edge_names: Option<&[String]>,
422 radius: f64,
423 chamfer: bool,
424 name: Option<&str>,
425) -> Result<BrepSolid, String> {
426 use rustc_hash::FxHashSet as HashSet;
427
428 // Fuse-first operand heal (Lever A) before the multi-edge surgery: snap the
429 // input's near-coincident / off-plane vertices (e.g. a revolve pole apex
430 // sitting a few microns off the axis) to exact and re-anchor incident
431 // edges. The selected edges are resolved geometrically below, so a
432 // sub-heal_tol vertex move never changes which edges are picked; a clean
433 // input is left byte-identical.
434 let mut healed_input = solid.clone();
435 let heal_policy = crate::KernelTolerances::for_solid(&healed_input, 1e-7);
436 crate::heal::heal_operands(&mut healed_input, &heal_policy)?;
437 let solid = &healed_input;
438
439 // 1. Detect convex corners from the ORIGINAL solid: a point that is an
440 // endpoint of >=3 of the selected edges (a cube/prism-style vertex).
441 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
442 // The extent each selected edge has BEFORE any blend trims it, so the
443 // sequential build can run every cutter through the shared corners.
444 let mut original_extents: Vec<(Vec3, Vec3)> = Vec::with_capacity(edge_points.len());
445 let mut selected_edge_ids: Vec<u64> = Vec::with_capacity(edge_points.len());
446 for (i, point) in edge_points.iter().enumerate() {
447 let edge_id = resolve_edge_by_point(solid, *point)?;
448 let edge = solid
449 .edges
450 .iter()
451 .find(|e| e.id == edge_id)
452 .ok_or("fillet_edges: resolved edge vanished")?;
453 let (start, end) = (edge.curve.evaluate(edge.t0)?, edge.curve.evaluate(edge.t1)?);
454 endpoints.push((start, i));
455 endpoints.push((end, i));
456 original_extents.push((start, end));
457 selected_edge_ids.push(edge_id);
458 }
459 let mut corners: Vec<Vec3> = Vec::new();
460 // The selected-edge INPUT INDICES meeting at each star corner (parallel to
461 // `corners`), sorted — used to name the corner patch after its adjacent
462 // edges (`{fid}:BLEND:CORNER:{e_a}+{e_b}+…`), UNIQUE per corner because no
463 // two distinct corners share the same set of >=3 selected edges.
464 let mut corner_edges: Vec<Vec<usize>> = Vec::new();
465 let mut chain_corner_count = 0usize;
466 let mut chain_corners: Vec<(Vec3, [usize; 2])> = Vec::new();
467 let mut used = vec![false; endpoints.len()];
468 for i in 0..endpoints.len() {
469 if used[i] {
470 continue;
471 }
472 used[i] = true;
473 let mut edges_here: HashSet<usize> = HashSet::default();
474 edges_here.insert(endpoints[i].1);
475 for j in (i + 1)..endpoints.len() {
476 if used[j] {
477 continue;
478 }
479 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
480 used[j] = true;
481 edges_here.insert(endpoints[j].1);
482 }
483 }
484 if edges_here.len() >= 3 {
485 corners.push(endpoints[i].0);
486 let mut adjacent: Vec<usize> = edges_here.into_iter().collect();
487 adjacent.sort_unstable();
488 corner_edges.push(adjacent);
489 } else if edges_here.len() == 2 {
490 chain_corner_count += 1;
491 let mut adjacent = edges_here.into_iter().collect::<Vec<_>>();
492 adjacent.sort_unstable();
493 chain_corners.push((endpoints[i].0, [adjacent[0], adjacent[1]]));
494 }
495 }
496
497 // A FACE selection may contain several disconnected boundary components
498 // (the common example is an outer perimeter plus a circular hole rim).
499 // Miter composition is only meaningful inside one connected edge graph.
500 // Feeding every component into the same INTERSECT/UNION combines multiple
501 // independently filleted copies of otherwise untouched support faces; the
502 // boolean then imprints those coincident copies and can leave redundant
503 // seams (the reported through-hole cylinder split into three faces).
504 //
505 // Partition by shared original endpoints and process components in input
506 // order. Each recursive call sees one connected component, so it follows
507 // the existing miter/sequential path without recursion cycling. Distinct
508 // components are then composed sequentially on the evolving solid.
509 let mut component_of = vec![usize::MAX; edge_points.len()];
510 let mut components: Vec<Vec<usize>> = Vec::new();
511 for seed in 0..edge_points.len() {
512 if component_of[seed] != usize::MAX {
513 continue;
514 }
515 let component_index = components.len();
516 component_of[seed] = component_index;
517 let mut component = vec![seed];
518 let mut cursor = 0;
519 while cursor < component.len() {
520 let current = component[cursor];
521 cursor += 1;
522 for candidate in 0..edge_points.len() {
523 if component_of[candidate] != usize::MAX {
524 continue;
525 }
526 let connected = [original_extents[current].0, original_extents[current].1]
527 .into_iter()
528 .any(|a| {
529 [original_extents[candidate].0, original_extents[candidate].1]
530 .into_iter()
531 .any(|b| a.sub(b).length() < 1e-6)
532 });
533 if connected {
534 component_of[candidate] = component_index;
535 component.push(candidate);
536 }
537 }
538 }
539 component.sort_unstable();
540 components.push(component);
541 }
542 if components.len() > 1 {
543 let mut separated = solid.clone();
544 for component in components {
545 let points = component
546 .iter()
547 .map(|index| edge_points[*index])
548 .collect::<Vec<_>>();
549 let names = edge_names.map(|all| {
550 component
551 .iter()
552 .map(|index| all[*index].clone())
553 .collect::<Vec<_>>()
554 });
555 separated =
556 fillet_edges_group(&separated, &points, names.as_deref(), radius, chamfer, name)?;
557 }
558 return Ok(separated);
559 }
560
561 // 2a. The stripe network (blend/network.rs) is the lane for EVERY
562 // selection: each stripe is marched against THIS solid, each shared
563 // vertex is solved before anything is cut — a star closed by a patch
564 // of the corner ball, a two-edge corner by the seam between the two
565 // blends, a re-entrant corner by a horn torus, a tangent pair by a
566 // flush join, an unselected tangent continuation by a cap — and
567 // nothing is subtracted, so no cutter can overshoot into a neighbour
568 // and no leftover cap has to be identified afterwards. It refuses
569 // BY NAME on what it does not yet construct (mixed-convexity corners,
570 // no-common-ball stars, chamfer corners, pinched edges), and those
571 // fall through to the cutter composition below.
572 if std::env::var("BREP_NO_NETWORK").is_err() {
573 let network_names: Vec<Option<String>> = (0..edge_points.len())
574 .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
575 .collect();
576 let network_corner_name =
577 |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
578 match crate::blend::blend_star_network(
579 solid,
580 &selected_edge_ids,
581 radius,
582 chamfer,
583 &network_names,
584 &network_corner_name,
585 ) {
586 Ok(mut network) => {
587 // Fail-safe like the rest of the ladder: a heal or validation
588 // problem in the network result falls through to the cutter,
589 // it does not fail the group. So does an UNTRIMMED result: the
590 // network re-trims only each stripe's two mates, and
591 // `check_blend_interference` is what notices a third face
592 // crossing the swept volume -- `validate` cannot, because a
593 // self-intersecting solid is still a watertight one.
594 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
595 let healed = heal_edge_vertex_gaps(&mut network, radius);
596 let issues = network.validate();
597 let interference =
598 check_blend_interference(solid, &network, &selected_edge_ids, entry);
599 if healed.is_ok() && issues.is_empty() && interference.is_ok() {
600 return Ok(network);
601 }
602 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
603 eprintln!(
604 "network result rejected: heal={healed:?} issues={issues:?} \
605 interference={interference:?}"
606 );
607 dump_loops_debug("INPUT", solid);
608 dump_loops_debug("RESULT", &network);
609 }
610 }
611 Err(refusal) => {
612 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
613 eprintln!("network refused: {refusal}");
614 }
615 }
616 }
617 }
618
619 // 2. Build the blends.
620 //
621 // CHAIN corners (§6.9.6 — exactly TWO selected edges share a vertex):
622 // the sequential build truncates the later blend where it runs into the
623 // earlier one and closes it with a flat bulkhead across the fillet
624 // channel — a hard step, not a transition. For selections containing
625 // chain corners, blend each edge FULL-LENGTH on the ORIGINAL solid and
626 // INTERSECT the per-edge results instead: the removal volumes union, so
627 // adjacent blends run through the shared corner and trim each other
628 // along their intersection curve — the standard MITER corner, tangent
629 // to the shared face at the seam's tangency end. Non-adjacent edges
630 // are unaffected (their removals are disjoint, intersection ≡
631 // sequential).
632 //
633 // Selections without chain corners keep the sequential build unchanged
634 // (star corners are rounded in step 3 against exactly the sequential
635 // geometry round_convex_corner was built for).
636 // CONVEXITY GUARD: a convex blend REMOVES material (fillet = orig −
637 // cut), a concave blend ADDS it (orig + pad). Full-length blends
638 // combine as orig − ∪cuts + ∪pads, so the per-edge results compose by
639 // INTERSECTION when every edge is convex and by UNION when every edge
640 // is concave; a mixed selection has no single composition and falls
641 // back to the sequential build.
642 // Fillet stars keep the sequential topology required by their sphere
643 // patches. Chamfer stars have no subsequent corner patch: compose their
644 // full-length removals too, so all bevel planes meet at the corner.
645 let miter_operation = if (chain_corner_count > 0 || !corners.is_empty())
646 && (chamfer || corners.is_empty())
647 {
648 let mut any_convex = false;
649 let mut any_concave = false;
650 for point in edge_points {
651 // Convexity is a DIHEDRAL property, so it is read from the general
652 // scan (`scan_dihedral`) — the same one `check_mixed_concavity`
653 // has already run over every selected edge. `analyze_edge` used
654 // to answer here and it ALSO refuses an edge the EXACT CUTTER
655 // cannot build: a straight edge whose mates are not both planes,
656 // a circular one whose mates do not share its axis. Reading that
657 // refusal as "unknown convexity" dropped the §6.9.6 miter for
658 // every selection touching a curved carrier — the groove rim of a
659 // bored box, say — and the sequential build then left a bulkhead
660 // face standing in the shared corner (the 2026-09-10 "chamfer
661 // produced a spurious face" report).
662 match resolve_edge_by_point(solid, *point)
663 .and_then(|edge_id| super::analyze::scan_dihedral(solid, edge_id))
664 {
665 // A mixed edge has no single composition either; it takes the
666 // `_ => None` arm below through both flags.
667 Ok(profile) if profile.samples > 0 && !profile.is_mixed() => {
668 any_convex |= profile.any_convex;
669 any_concave |= profile.any_concave;
670 }
671 // Unknown edge class: let the sequential path produce its own
672 // (more specific) error or result.
673 _ => {
674 any_convex = true;
675 any_concave = true;
676 break;
677 }
678 }
679 }
680 match (any_convex, any_concave) {
681 (true, false) => Some(crate::BooleanOperation::Intersect),
682 (false, true) => Some(crate::BooleanOperation::Union),
683 _ => None,
684 }
685 } else {
686 None
687 };
688
689 // Fillet/chamfer each edge in turn, resolving its point on the evolving
690 // solid (ids shift as earlier fillets rewrite topology; the midpoint of an
691 // edge is untouched by the corner surgery of the others). This is the
692 // baseline build used directly for non-miter selections AND as the
693 // fallback when the miter composition below cannot reassemble.
694 // The composition's per-edge lane: cutter-first ONLY when a corner
695 // closure will run afterwards and read cutter-shaped topology (a star or
696 // a chain corner). A selection with no shared vertex — a lone closed
697 // rim, a lone chamfer, disconnected edges — has no such closure, so each
698 // edge gets the march first there too.
699 let composition_lane = if corners.is_empty() && chain_corner_count == 0 {
700 Lane::GeneralFirst
701 } else {
702 Lane::CutterFirst
703 };
704 let build_sequential = || -> Result<BrepSolid, String> {
705 let mut sequential = solid.clone();
706 for (index, point) in edge_points.iter().enumerate() {
707 let edge_id = resolve_edge_by_point(&sequential, *point)?;
708 // Earlier cutters in this loop TRIM the edges that share a corner
709 // with them; extend this cutter back over what they took so the
710 // two removal volumes union through the corner (§6.9.6 miter)
711 // instead of leaving a wedge of material standing behind a flush
712 // end cap. Untouched edges get a zero pad and the historical
713 // flush cutter.
714 let ends = tool_ends_to_original_extent(
715 &sequential,
716 edge_id,
717 radius,
718 original_extents[index],
719 if chamfer { &[] } else { &corners },
720 );
721 // This edge's blend wall carries the name of THIS input edge
722 // (`edge_names[index]`); a smooth chain that engulfs several edges
723 // is named after the FIRST such input edge processed here (input
724 // order), the chain's representative.
725 let edge_name = per_edge_name(edge_names, name, index);
726 sequential = fillet_or_chamfer(
727 &sequential,
728 edge_id,
729 radius,
730 chamfer,
731 edge_name,
732 ends,
733 composition_lane,
734 )?;
735 }
736 Ok(sequential)
737 };
738
739 let mut result = if let Some(operation) = miter_operation {
740 let options = crate::BooleanOptions::default();
741 let mut combined: Result<Option<BrepSolid>, String> = Ok(None);
742 for (index, point) in edge_points.iter().enumerate() {
743 let edge_id = resolve_edge_by_point(solid, *point)?;
744 let edge_name = per_edge_name(edge_names, name, index);
745 let blended = {
746 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
747 check_mixed_concavity(solid, edge_id, entry)?;
748 check_support_extent(solid, edge_id, radius, entry)?;
749 fillet_or_chamfer(
750 solid,
751 edge_id,
752 radius,
753 chamfer,
754 edge_name,
755 ToolEnds::default(),
756 Lane::CutterFirst,
757 )?
758 };
759 combined = match combined {
760 Err(e) => Err(e),
761 Ok(None) => Ok(Some(blended)),
762 Ok(Some(previous)) => {
763 crate::boolean_operation(&previous, &blended, operation, &options)
764 .map(Some)
765 .map_err(|error| {
766 format!("fillet_edges: chain-corner miter composition failed: {error}")
767 })
768 }
769 };
770 if combined.is_err() {
771 break;
772 }
773 }
774 // A §6.9.6 miter (per-edge blends intersected/unioned through the
775 // shared chain corners) can fail to reassemble on faces whose
776 // fragmented boundary does not close — e.g. a planar cap whose ENTIRE
777 // perimeter is selected, where fragment_face reports an "incomplete
778 // run". Rather than let `fillet_edges` silently DROP a selected edge
779 // to recover a valid subset (the reported defect: not every edge of
780 // the face gets a fillet), fall back to the sequential build, which
781 // blends EVERY selected edge (chain corners get a flat bulkhead
782 // instead of a miter). Only if that also fails to produce a valid
783 // solid do we surface the miter error so the caller's
784 // maximal-valid-subset search can still run.
785 match combined {
786 Ok(Some(mitered)) => mitered,
787 Ok(None) => return Err("fillet_edges: no edges selected".into()),
788 Err(miter_err) => match build_sequential() {
789 Ok(seq) if seq.validate().is_empty() => seq,
790 _ => return Err(miter_err),
791 },
792 }
793 } else {
794 build_sequential()?
795 };
796
797 // A re-entrant vertex of a selected face perimeter has two selected,
798 // convex cap-wall edges and one UNSELECTED concave wall-wall edge. The
799 // two cutter volumes only touch there, so the boolean miter leaves a
800 // triangular planar end-cap instead of carrying the rolling ball around
801 // the corner. Close that exact orthogonal class with its horn-torus
802 // sector (major radius = minor radius = fillet radius).
803 if !chamfer {
804 for (corner, adjacent) in &chain_corners {
805 let has_concave_unselected_edge = solid.edges.iter().any(|edge| {
806 if selected_edge_ids.contains(&edge.id) {
807 return false;
808 }
809 let Ok(a) = edge.curve.evaluate(edge.t0) else {
810 return false;
811 };
812 let Ok(b) = edge.curve.evaluate(edge.t1) else {
813 return false;
814 };
815 (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
816 && analyze_edge(solid, edge.id, radius)
817 .map(|cross| !cross.convex)
818 .unwrap_or(false)
819 });
820 if !has_concave_unselected_edge {
821 continue;
822 }
823 let corner_name = corner_face_name(edge_names, name, adjacent);
824 if let Ok(rounded) = crate::blend::round_concave_chain_corner(
825 &result,
826 solid,
827 *corner,
828 [
829 selected_edge_ids[adjacent[0]],
830 selected_edge_ids[adjacent[1]],
831 ],
832 radius,
833 corner_name.as_deref(),
834 ) {
835 result = rounded;
836 }
837 }
838 }
839
840 // 3. Round the convex corners (fillets only — chamfers keep sharp
841 // vertices). A corner that cannot be rounded is left as the edge
842 // fillets so the group still succeeds. Each corner patch is named after
843 // the selected edges meeting there (`{fid}:BLEND:CORNER:{e_a}+…`) so no
844 // two corners collide and the patch stays under the `{fid}:BLEND` prefix.
845 if !chamfer {
846 for (ci, corner) in corners.iter().enumerate() {
847 let corner_name = corner_face_name(edge_names, name, &corner_edges[ci]);
848 if let Ok(rounded) =
849 crate::blend::round_convex_corner(&result, *corner, radius, corner_name.as_deref())
850 {
851 result = rounded;
852 }
853 }
854 }
855
856 // Heal any residual vertex/edge gaps introduced by the corner-rounding
857 // surgery (the per-edge results are already healed inside fillet_or_chamfer).
858 heal_edge_vertex_gaps(&mut result, radius)?;
859 Ok(result)
860}
861
862/// Variable-radius fillet/chamfer of a GROUP of edges (§4.9.5), the app entry
863/// for tapered blends: each selected edge (resolved by a point on it) is
864/// blended with the SAME radius profile `radii` — a list of (edge-fraction,
865/// radius) stops in [0,1] — applied along that edge's own parameterization.
866/// Edges are blended independently (no shared-vertex corner rounding; a
867/// variable-radius star has no single tangent ball), so this is the tapered
868/// counterpart of `fillet_edges` for the constant case.
869pub fn fillet_edges_variable(
870 solid: &BrepSolid,
871 edge_points: &[Vec3],
872 edge_names: Option<&[String]>,
873 radii: &[(f64, f64)],
874 chamfer: bool,
875 name: Option<&str>,
876) -> Result<BrepSolid, String> {
877 if edge_points.is_empty() {
878 return Err("fillet_edges_variable: no edges selected".into());
879 }
880 let per_edge_stops: Vec<Vec<(f64, f64)>> = vec![radii.to_vec(); edge_points.len()];
881 fillet_edges_variable_impl(
882 solid,
883 edge_points,
884 edge_names,
885 &per_edge_stops,
886 chamfer,
887 name,
888 "fillet_edges_variable",
889 true,
890 )
891}
892
893/// The shared variable-radius group core: each selected edge `i` is blended
894/// with ITS OWN stop list `per_edge_stops[i]` (the legacy entry replicates one
895/// list; the law entries sample a chain-abscissa [`crate::law::RadiusLaw`] per
896/// edge). `allow_tapered_sequential` keeps the legacy entry's sequential
897/// fallback for tapered chains (whose end state is the honest
898/// "mismatched radii" validation gate); the law entries pass `false` because
899/// their stop fractions are computed against the ORIGINAL edges — the
900/// sequential build re-resolves edges on the evolving solid whose shared
901/// corners are already TRIMMED by earlier blends, which would silently distort
902/// the law's abscissa mapping (constant stops are immune, so they may still
903/// fall back).
904#[allow(clippy::too_many_arguments)]
905fn fillet_edges_variable_impl(
906 solid: &BrepSolid,
907 edge_points: &[Vec3],
908 edge_names: Option<&[String]>,
909 per_edge_stops: &[Vec<(f64, f64)>],
910 chamfer: bool,
911 name: Option<&str>,
912 entry: &str,
913 allow_tapered_sequential: bool,
914) -> Result<BrepSolid, String> {
915 debug_assert_eq!(per_edge_stops.len(), edge_points.len());
916 // A law that is one constant everywhere IS the constant-radius fillet:
917 // hand it to the constant group, whose corners are constructed (the
918 // stripe network) rather than composed by boolean.
919 if let Some(constant) = per_edge_stops
920 .first()
921 .and_then(|stops| stops.first())
922 .map(|(_, radius)| *radius)
923 {
924 let uniform = per_edge_stops.iter().all(|stops| {
925 stops
926 .iter()
927 .all(|(_, radius)| (radius - constant).abs() <= 1e-12 * (1.0 + constant.abs()))
928 });
929 if uniform && constant > 0.0 {
930 return fillet_edges(solid, edge_points, edge_names, constant, chamfer, name);
931 }
932 }
933 let max_radius = per_edge_stops
934 .iter()
935 .flat_map(|stops| stops.iter())
936 .map(|(_, r)| r.abs())
937 .fold(0.0_f64, f64::max);
938
939 // Chain corners miter exactly like the constant-radius group (§6.9.6):
940 // per-edge blends on the ORIGINAL solid composed by boolean — Intersect
941 // when every edge is convex, Union when every edge is concave. Mixed or
942 // unclassifiable selections keep the sequential build. Variable blends
943 // never round star vertices, so unlike the constant group there is no
944 // sequential-only star path to protect.
945 let mut chain_corner = false;
946 {
947 use rustc_hash::FxHashSet as HashSet;
948 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
949 for (i, point) in edge_points.iter().enumerate() {
950 if let Ok(edge_id) = resolve_edge_by_point(solid, *point) {
951 if let Some(edge) = solid.edges.iter().find(|e| e.id == edge_id) {
952 if let (Ok(a), Ok(b)) =
953 (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1))
954 {
955 endpoints.push((a, i));
956 endpoints.push((b, i));
957 }
958 }
959 }
960 }
961 let mut used = vec![false; endpoints.len()];
962 for i in 0..endpoints.len() {
963 if used[i] {
964 continue;
965 }
966 used[i] = true;
967 let mut edges_here: HashSet<usize> = HashSet::default();
968 edges_here.insert(endpoints[i].1);
969 for j in (i + 1)..endpoints.len() {
970 if used[j] {
971 continue;
972 }
973 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
974 used[j] = true;
975 edges_here.insert(endpoints[j].1);
976 }
977 }
978 if edges_here.len() == 2 {
979 chain_corner = true;
980 }
981 }
982 }
983 let miter_operation = if chain_corner {
984 let probe_radius = if max_radius > 0.0 { max_radius } else { 1.0 };
985 let mut any_convex = false;
986 let mut any_concave = false;
987 for point in edge_points {
988 match resolve_edge_by_point(solid, *point)
989 .and_then(|edge_id| analyze_edge(solid, edge_id, probe_radius))
990 {
991 Ok(cross) if cross.convex => any_convex = true,
992 Ok(_) => any_concave = true,
993 Err(_) => {
994 any_convex = true;
995 any_concave = true;
996 break;
997 }
998 }
999 }
1000 match (any_convex, any_concave) {
1001 (true, false) => Some(crate::BooleanOperation::Intersect),
1002 (false, true) => Some(crate::BooleanOperation::Union),
1003 _ => None,
1004 }
1005 } else {
1006 None
1007 };
1008
1009 // Try the miter first; the variable blend's FITTED boundary curves are
1010 // only ~1e-3 accurate at blend-blend tangencies (unlike the exact
1011 // constant-radius cylinders), so the composition can fail — fall back to
1012 // the sequential build then, which is never worse than the pre-miter
1013 // behavior. Tightening the taper surface's endpoint fitting is the
1014 // documented follow-up that would make the miter stick.
1015 let miter_attempt: Option<BrepSolid> = if let Some(operation) = miter_operation {
1016 let options = crate::BooleanOptions::default();
1017 let mut combined: Option<BrepSolid> = None;
1018 let mut failed = false;
1019 for (index, point) in edge_points.iter().enumerate() {
1020 let Ok(edge_id) = resolve_edge_by_point(solid, *point) else {
1021 failed = true;
1022 break;
1023 };
1024 let edge_name = per_edge_name(edge_names, name, index);
1025 let Ok(blended) = crate::blend::blend_edge_variable(
1026 solid,
1027 edge_id,
1028 &per_edge_stops[index],
1029 chamfer,
1030 edge_name,
1031 ) else {
1032 failed = true;
1033 break;
1034 };
1035 let next = match combined.take() {
1036 None => blended,
1037 Some(previous) => {
1038 match crate::boolean_operation(&previous, &blended, operation, &options) {
1039 Ok(next) => next,
1040 Err(_) => {
1041 failed = true;
1042 break;
1043 }
1044 }
1045 }
1046 };
1047 combined = Some(next);
1048 }
1049 if failed {
1050 None
1051 } else {
1052 combined.filter(|s| s.validate().is_empty())
1053 }
1054 } else {
1055 None
1056 };
1057 let mut result = match miter_attempt {
1058 Some(mitered) => mitered,
1059 None => {
1060 // Any stop list that is NOT radius-uniform (the same 1e-12
1061 // relative criterion `blend_edge_variable` uses for its exact
1062 // constant-radius degeneration) makes the sequential build
1063 // abscissa-distorting on trimmed chain edges; law entries refuse
1064 // instead of silently shifting the law.
1065 let tapered = per_edge_stops.iter().any(|stops| {
1066 stops.first().is_some_and(|(_, first)| {
1067 stops
1068 .iter()
1069 .any(|(_, r)| (r - first).abs() > 1e-12 * (1.0 + first.abs()))
1070 })
1071 });
1072 if chain_corner && tapered && !allow_tapered_sequential {
1073 return Err(format!(
1074 "{entry}: the tapered blends across the selected chain's shared corners \
1075 did not compose to a valid solid (the fitted blend boundaries could not \
1076 be mitered); fillet fewer edges per operation or reduce the taper"
1077 ));
1078 }
1079 let mut sequential = solid.clone();
1080 for (index, point) in edge_points.iter().enumerate() {
1081 let edge_id = resolve_edge_by_point(&sequential, *point)?;
1082 let edge_name = per_edge_name(edge_names, name, index);
1083 sequential = crate::blend::blend_edge_variable(
1084 &sequential,
1085 edge_id,
1086 &per_edge_stops[index],
1087 chamfer,
1088 edge_name,
1089 )?;
1090 }
1091 sequential
1092 }
1093 };
1094 // Heal §6.9 surgery so re-trimmed edges meet their vertices exactly; scale
1095 // the heal bound by the largest radius stop in the taper profile.
1096 heal_edge_vertex_gaps(&mut result, max_radius)?;
1097 // Final honesty gate: a genuinely TAPERED chain (different radii at the
1098 // shared corner) has mismatched trim stations there — the blends cannot
1099 // meet without a transition patch (not implemented), and the sequential
1100 // surgery silently left broken topology before this gate existed.
1101 let issues = result.validate();
1102 if !issues.is_empty() {
1103 let detail = if allow_tapered_sequential {
1104 "tapered blends meet at a shared chain vertex with \
1105 mismatched radii — the radius-transition corner patch is not implemented; \
1106 fillet the edges in separate operations or use matching stop radii"
1107 } else {
1108 "the composed radius-law blend produced invalid topology — the blends \
1109 across a shared chain corner failed to reassemble"
1110 };
1111 return Err(format!(
1112 "{entry}: {detail} ({} validation issues, first: {})",
1113 issues.len(),
1114 issues
1115 .first()
1116 .map(|issue| issue.message.clone())
1117 .unwrap_or_default()
1118 ));
1119 }
1120 Ok(result)
1121}
1122
1123/// One selected edge placed on the ordered chain, with its arc-length
1124/// parameterization (the caller-side mapping from chain abscissa to the
1125/// `blend_edge_variable` per-edge parameter-fraction stop seam).
1126struct ChainLink {
1127 /// Position of this edge in the caller's `edge_points` selection.
1128 input_index: usize,
1129 /// True when the edge's own t0→t1 parameter direction runs WITH the chain.
1130 forward: bool,
1131 /// Cumulative chord-length table from the edge's t0 end:
1132 /// `(parameter fraction, arc length)`, uniformly spaced in fraction.
1133 arc: Vec<(f64, f64)>,
1134 /// Total arc length of the edge.
1135 length: f64,
1136 /// Chain abscissa at the link's ENTRY vertex (the end reached first when
1137 /// walking the chain from its start).
1138 abscissa: f64,
1139}
1140
1141/// Cumulative chord-length table of one edge, `(parameter fraction, arc
1142/// length)` at uniform fractions. The sample count doubles until two
1143/// successive total-length estimates agree within `tol` (chord length
1144/// converges O(N⁻²) for smooth curves, so the agreement of the N and 2N
1145/// estimates bounds the remaining error at the same order); straight edges
1146/// converge on the first doubling. The 16-sample start resolves any
1147/// single-span arc of up to half a turn to sub-percent before refinement; the
1148/// 4096 cap (8 doublings) guards adversarial curves — beyond it the table is
1149/// two orders denser than the blend march's station grid, so finer chords
1150/// cannot move any station's sampled radius meaningfully.
1151fn edge_arc_table(
1152 curve: &NurbsCurve,
1153 t0: f64,
1154 t1: f64,
1155 tol: f64,
1156) -> Result<(Vec<(f64, f64)>, f64), String> {
1157 let build = |n: usize| -> Result<(Vec<(f64, f64)>, f64), String> {
1158 let mut table = Vec::with_capacity(n + 1);
1159 let mut cumulative = 0.0;
1160 let mut previous = curve.evaluate(t0)?;
1161 table.push((0.0, 0.0));
1162 for j in 1..=n {
1163 let fraction = j as f64 / n as f64;
1164 let point = curve.evaluate(t0 + (t1 - t0) * fraction)?;
1165 cumulative += point.sub(previous).length();
1166 previous = point;
1167 table.push((fraction, cumulative));
1168 }
1169 Ok((table, cumulative))
1170 };
1171 let mut n = 16usize;
1172 let (mut table, mut length) = build(n)?;
1173 while n < 4096 {
1174 n *= 2;
1175 let (next_table, next_length) = build(n)?;
1176 let converged = (next_length - length).abs() <= tol;
1177 table = next_table;
1178 length = next_length;
1179 if converged {
1180 break;
1181 }
1182 }
1183 Ok((table, length))
1184}
1185
1186/// Arc length from the edge's t0 end at `fraction` of its parameter span,
1187/// linearly interpolated in the uniform chord table.
1188fn arc_length_at_fraction(table: &[(f64, f64)], fraction: f64) -> f64 {
1189 let fraction = fraction.clamp(0.0, 1.0);
1190 let intervals = table.len() - 1;
1191 let scaled = fraction * intervals as f64;
1192 let index = (scaled.floor() as usize).min(intervals - 1);
1193 let local = scaled - index as f64;
1194 let (_, a) = table[index];
1195 let (_, b) = table[index + 1];
1196 a + (b - a) * local
1197}
1198
1199/// Resolve the selected edges and order them into ONE OPEN CHAIN with
1200/// cumulative arc-length abscissas. The chain starts at the free endpoint
1201/// belonging to the EARLIEST-selected end edge (so users get the natural
1202/// "first pick carries the law start" orientation); a single selected edge is
1203/// its own chain oriented t0→t1 (which also admits a closed edge — the law's
1204/// end radii must then match, enforced downstream by `blend_edge_variable`).
1205/// Branching (a vertex shared by 3+ selected edges), closed rings of several
1206/// edges, and disconnected selections refuse with named errors.
1207fn resolve_selected_chain(
1208 solid: &BrepSolid,
1209 edge_points: &[Vec3],
1210 entry: &str,
1211 tol: f64,
1212) -> Result<Vec<ChainLink>, String> {
1213 // Endpoint identity uses the kernel-wide COINCIDENCE_DISTANCE_FLOOR — the
1214 // same band the constant-radius group's corner detector applies.
1215 let band = crate::tolerance::COINCIDENCE_DISTANCE_FLOOR;
1216
1217 struct Resolved {
1218 edge_id: u64,
1219 start: Vec3,
1220 end: Vec3,
1221 arc: Vec<(f64, f64)>,
1222 length: f64,
1223 }
1224 let mut resolved: Vec<Resolved> = Vec::with_capacity(edge_points.len());
1225 for point in edge_points {
1226 let edge_id = resolve_edge_by_point(solid, *point)?;
1227 if resolved.iter().any(|r| r.edge_id == edge_id) {
1228 return Err(format!("{entry}: the same edge was selected more than once"));
1229 }
1230 let edge = solid
1231 .edges
1232 .iter()
1233 .find(|e| e.id == edge_id)
1234 .ok_or_else(|| format!("{entry}: resolved edge vanished"))?;
1235 let (arc, length) = edge_arc_table(&edge.curve, edge.t0, edge.t1, tol)?;
1236 if !(length > 0.0) {
1237 return Err(format!("{entry}: selected edge has zero length"));
1238 }
1239 resolved.push(Resolved {
1240 edge_id,
1241 start: edge.curve.evaluate(edge.t0)?,
1242 end: edge.curve.evaluate(edge.t1)?,
1243 arc,
1244 length,
1245 });
1246 }
1247 let n = resolved.len();
1248 if n == 1 {
1249 let only = resolved.remove(0);
1250 return Ok(vec![ChainLink {
1251 input_index: 0,
1252 forward: true,
1253 arc: only.arc,
1254 length: only.length,
1255 abscissa: 0.0,
1256 }]);
1257 }
1258
1259 // Cluster the 2n endpoints; each entry is (edge index, is_start_end).
1260 let mut clusters: Vec<(Vec3, Vec<(usize, bool)>)> = Vec::new();
1261 for (i, r) in resolved.iter().enumerate() {
1262 for (point, is_start) in [(r.start, true), (r.end, false)] {
1263 match clusters
1264 .iter_mut()
1265 .find(|(anchor, _)| anchor.sub(point).length() < band)
1266 {
1267 Some((_, members)) => members.push((i, is_start)),
1268 None => clusters.push((point, vec![(i, is_start)])),
1269 }
1270 }
1271 }
1272 if clusters.iter().any(|(_, members)| members.len() > 2) {
1273 return Err(format!(
1274 "{entry}: selected edges must form one open chain \
1275 (a vertex is shared by three or more selected edges)"
1276 ));
1277 }
1278 let free: Vec<usize> = clusters
1279 .iter()
1280 .enumerate()
1281 .filter(|(_, (_, members))| members.len() == 1)
1282 .map(|(c, _)| c)
1283 .collect();
1284 if free.len() != 2 {
1285 return Err(format!(
1286 "{entry}: selected edges must form one OPEN chain \
1287 (closed rings and disconnected selections are not supported)"
1288 ));
1289 }
1290 // Start at the free end whose edge appears EARLIEST in the selection.
1291 let start_cluster = *free
1292 .iter()
1293 .min_by_key(|&&c| clusters[c].1[0].0)
1294 .expect("two free ends");
1295
1296 // Walk the chain.
1297 let mut links: Vec<ChainLink> = Vec::with_capacity(n);
1298 let mut visited = vec![false; n];
1299 let mut abscissa = 0.0_f64;
1300 let mut cluster = start_cluster;
1301 for _ in 0..n {
1302 let Some(&(edge_index, entered_at_start)) = clusters[cluster]
1303 .1
1304 .iter()
1305 .find(|(edge_index, _)| !visited[*edge_index])
1306 else {
1307 return Err(format!(
1308 "{entry}: selected edges are not connected into one chain"
1309 ));
1310 };
1311 visited[edge_index] = true;
1312 let r = &resolved[edge_index];
1313 links.push(ChainLink {
1314 input_index: edge_index,
1315 forward: entered_at_start,
1316 arc: r.arc.clone(),
1317 length: r.length,
1318 abscissa,
1319 });
1320 abscissa += r.length;
1321 let exit_point = if entered_at_start { r.end } else { r.start };
1322 cluster = clusters
1323 .iter()
1324 .position(|(anchor, _)| anchor.sub(exit_point).length() < band)
1325 .ok_or_else(|| format!("{entry}: chain walk lost an endpoint cluster"))?;
1326 }
1327 if visited.iter().any(|v| !v) {
1328 return Err(format!(
1329 "{entry}: selected edges are not connected into one chain"
1330 ));
1331 }
1332 Ok(links)
1333}
1334
1335/// Sample the law into one edge's `(parameter fraction, radius)` stop list —
1336/// the caller-side bridge from chain abscissa into the `radius_at` closure
1337/// seam that `blend_edge_variable` builds over its stops.
1338///
1339/// `scale` maps chain abscissa into the law's own abscissa units
1340/// (`law.total_length() / chain_length` — proportional, so a law built from
1341/// the measured chain lengths maps 1:1). The base stop count derives from
1342/// the law's curvature: piecewise-linear sampling of a C1, piecewise-C2
1343/// function over step `h` errs at most `h²·max|r''|/8`, so
1344/// `h = sqrt(8·tol/max|r''|)` holds the sampling error under the SSI fit
1345/// tolerance the variable lane is built to. That bound is exact for
1346/// arc-length-linear (straight) edges; curved edges bend the
1347/// parameter→abscissa map, so each interval is additionally midpoint-checked
1348/// against the law and bisected on violation (up to 8 halvings — a 4⁸ ≈ 6·10⁴
1349/// error reduction, decisive for any C1 law). The base count is capped at
1350/// 256 intervals: 4× the blend march's 64-station density
1351/// (blend/stations.rs), beyond which denser stops cannot move any station's
1352/// sampled radius by more than the fit tolerance.
1353fn law_stops_for_link(
1354 link: &ChainLink,
1355 law: &crate::law::RadiusLaw,
1356 scale: f64,
1357 tol: f64,
1358) -> Vec<(f64, f64)> {
1359 let radius_at_fraction = |fraction: f64| -> f64 {
1360 let arc = arc_length_at_fraction(&link.arc, fraction);
1361 let chain_s = if link.forward {
1362 link.abscissa + arc
1363 } else {
1364 link.abscissa + (link.length - arc)
1365 };
1366 law.radius_at(chain_s * scale)
1367 };
1368 // Curvature of the law in edge-fraction units: d²r/df² ≤
1369 // max|r''|·(scale·length)² for the arc-length-linear map.
1370 let curvature = law
1371 .max_second_derivative(link.abscissa * scale, (link.abscissa + link.length) * scale)
1372 * (scale * link.length).powi(2);
1373 let base = if curvature * 0.125 <= tol {
1374 1usize
1375 } else {
1376 ((curvature / (8.0 * tol)).sqrt().ceil() as usize).clamp(1, 256)
1377 };
1378 let mut stops: Vec<(f64, f64)> = (0..=base)
1379 .map(|j| {
1380 let fraction = j as f64 / base as f64;
1381 (fraction, radius_at_fraction(fraction))
1382 })
1383 .collect();
1384 // Midpoint refinement for curved parameter→abscissa maps.
1385 let mut depth = 0usize;
1386 while depth < 8 {
1387 let mut refined: Vec<(f64, f64)> = Vec::with_capacity(stops.len());
1388 let mut inserted = false;
1389 for pair in stops.windows(2) {
1390 refined.push(pair[0]);
1391 let mid_fraction = 0.5 * (pair[0].0 + pair[1].0);
1392 let law_mid = radius_at_fraction(mid_fraction);
1393 let linear_mid = 0.5 * (pair[0].1 + pair[1].1);
1394 if (law_mid - linear_mid).abs() > tol {
1395 refined.push((mid_fraction, law_mid));
1396 inserted = true;
1397 }
1398 }
1399 refined.push(*stops.last().expect("at least two stops"));
1400 stops = refined;
1401 if !inserted {
1402 break;
1403 }
1404 depth += 1;
1405 }
1406 stops
1407}
1408
1409/// Fillet (or chamfer) a chain of edges under a composable radius law
1410/// evaluated on the chain's cumulative arc-length abscissa (the OCCT
1411/// `Law_Composite` model; see [`crate::law::RadiusLaw`]). The selected edges
1412/// must form ONE OPEN CHAIN (or be a single edge); the chain starts at the
1413/// free end of the earliest-selected end edge, and the law's abscissa maps
1414/// proportionally onto the chain's measured arc length (a law built with the
1415/// chain's own lengths — e.g. [`crate::law::RadiusLaw::from_vertex_radii`] —
1416/// maps 1:1). Endpoint radii are met exactly; radii at shared chain vertices
1417/// match by the law's continuity, so the per-edge blends miter through the
1418/// corners; a chain whose blends cannot be mitered REFUSES rather than
1419/// distorting the law through the sequential rebuild.
1420pub fn fillet_edges_variable_law(
1421 solid: &BrepSolid,
1422 edge_points: &[Vec3],
1423 edge_names: Option<&[String]>,
1424 law: &crate::law::RadiusLaw,
1425 chamfer: bool,
1426 name: Option<&str>,
1427) -> Result<BrepSolid, String> {
1428 const ENTRY: &str = "fillet_edges_variable_law";
1429 if edge_points.is_empty() {
1430 return Err(format!("{ENTRY}: no edges selected"));
1431 }
1432 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
1433 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
1434 fillet_variable_law_on_chain(
1435 solid,
1436 edge_points,
1437 edge_names,
1438 &chain,
1439 law,
1440 chamfer,
1441 name,
1442 ENTRY,
1443 tolerances.intersection_fit,
1444 )
1445}
1446
1447/// The natural per-vertex user model: radius `vertex_radii[i]` at chain
1448/// vertex `i` (in CHAIN order, starting at the free end of the
1449/// earliest-selected edge), smoothly interpolated along the chain
1450/// (monotone C1 — every vertex radius met exactly, no overshoot). Requires
1451/// exactly one radius per chain vertex (`edges + 1`).
1452pub fn fillet_edges_variable_vertex_radii(
1453 solid: &BrepSolid,
1454 edge_points: &[Vec3],
1455 edge_names: Option<&[String]>,
1456 vertex_radii: &[f64],
1457 chamfer: bool,
1458 name: Option<&str>,
1459) -> Result<BrepSolid, String> {
1460 const ENTRY: &str = "fillet_edges_variable_vertex_radii";
1461 if edge_points.is_empty() {
1462 return Err(format!("{ENTRY}: no edges selected"));
1463 }
1464 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
1465 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
1466 let lengths: Vec<f64> = chain.iter().map(|link| link.length).collect();
1467 let law = crate::law::RadiusLaw::from_vertex_radii(&lengths, vertex_radii)
1468 .map_err(|error| format!("{ENTRY}: {error}"))?;
1469 fillet_variable_law_on_chain(
1470 solid,
1471 edge_points,
1472 edge_names,
1473 &chain,
1474 &law,
1475 chamfer,
1476 name,
1477 ENTRY,
1478 tolerances.intersection_fit,
1479 )
1480}
1481
1482/// Shared law-entry tail: sample per-edge stop lists from the law over the
1483/// resolved chain and run the variable group core (miter-or-refuse: no
1484/// sequential fallback for tapered chains — see `fillet_edges_variable_impl`).
1485#[allow(clippy::too_many_arguments)]
1486fn fillet_variable_law_on_chain(
1487 solid: &BrepSolid,
1488 edge_points: &[Vec3],
1489 edge_names: Option<&[String]>,
1490 chain: &[ChainLink],
1491 law: &crate::law::RadiusLaw,
1492 chamfer: bool,
1493 name: Option<&str>,
1494 entry: &str,
1495 tol: f64,
1496) -> Result<BrepSolid, String> {
1497 let chain_length: f64 = chain.iter().map(|link| link.length).sum();
1498 let scale = law.total_length() / chain_length;
1499 let mut per_edge_stops: Vec<Vec<(f64, f64)>> = vec![Vec::new(); edge_points.len()];
1500 for link in chain {
1501 per_edge_stops[link.input_index] = law_stops_for_link(link, law, scale, tol);
1502 }
1503 fillet_edges_variable_impl(
1504 solid,
1505 edge_points,
1506 edge_names,
1507 &per_edge_stops,
1508 chamfer,
1509 name,
1510 entry,
1511 false,
1512 )
1513}
1514
1515/// Asymmetric (two-distance) chamfer of a GROUP of edges, the app entry: each
1516/// selected edge (resolved by a point on it) gets a `d1 × d2` bevel (§6.11).
1517/// Edges are chamfered independently — asymmetric chamfers keep sharp vertices,
1518/// so there is no shared-corner blending.
1519pub fn chamfer_edges_asymmetric(
1520 solid: &BrepSolid,
1521 edge_points: &[Vec3],
1522 edge_names: Option<&[String]>,
1523 d1: f64,
1524 d2: f64,
1525 name: Option<&str>,
1526) -> Result<BrepSolid, String> {
1527 if edge_points.is_empty() {
1528 return Err("chamfer_edges_asymmetric: no edges selected".into());
1529 }
1530 let mut result = solid.clone();
1531 // `edge_names[index]` is keyed by INPUT position; the loop resolves each
1532 // point on the EVOLVING `result`, but enumerates the input points in order,
1533 // so the index alignment holds.
1534 for (index, point) in edge_points.iter().enumerate() {
1535 let edge_id = resolve_edge_by_point(&result, *point)?;
1536 let edge_name = per_edge_name(edge_names, name, index);
1537 result = chamfer_edge_asymmetric(&result, edge_id, d1, d2, edge_name)?;
1538 }
1539 Ok(result)
1540}
1541
1542/// Distance-angle chamfer of a GROUP of edges, the app entry: each selected
1543/// edge (resolved by a point on it) gets a setback `d1` on face 1 and a chamfer
1544/// face at `angle_rad` from face 1 (§6.11); `d2` is constructed per edge.
1545pub fn chamfer_edges_angle(
1546 solid: &BrepSolid,
1547 edge_points: &[Vec3],
1548 edge_names: Option<&[String]>,
1549 d1: f64,
1550 angle_rad: f64,
1551 name: Option<&str>,
1552) -> Result<BrepSolid, String> {
1553 if edge_points.is_empty() {
1554 return Err("chamfer_edges_angle: no edges selected".into());
1555 }
1556 let mut result = solid.clone();
1557 for (index, point) in edge_points.iter().enumerate() {
1558 let edge_id = resolve_edge_by_point(&result, *point)?;
1559 let edge_name = per_edge_name(edge_names, name, index);
1560 result = chamfer_edge_angle(&result, edge_id, d1, angle_rad, edge_name)?;
1561 }
1562 Ok(result)
1563}
1564
1565/// Debug aid (`BREP_DEBUG_NETWORK`): every face loop as its coedge walk, with
1566/// each edge's stored vertices, so a loop the surgery left open can be read
1567/// against the input it was built from.
1568fn dump_loops_debug(label: &str, solid: &BrepSolid) {
1569 for shell in &solid.shells {
1570 for face in &shell.faces {
1571 for (index, loop_record) in face.loops.iter().enumerate() {
1572 let walk: Vec<String> = loop_record
1573 .coedges
1574 .iter()
1575 .map(|coedge| {
1576 match solid.edges.iter().find(|e| e.id == coedge.edge_id) {
1577 Some(edge) => format!(
1578 "c{}:e{}{}({}->{})",
1579 coedge.id,
1580 edge.id,
1581 if coedge.forward { "+" } else { "-" },
1582 edge.start_vertex_id,
1583 edge.end_vertex_id
1584 ),
1585 None => format!("c{}:e{}?MISSING", coedge.id, coedge.edge_id),
1586 }
1587 })
1588 .collect();
1589 eprintln!(
1590 "{label} face {} ({:?}) loop {index}: {}",
1591 face.id,
1592 face.name,
1593 walk.join(" ")
1594 );
1595 }
1596 }
1597 }
1598}