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