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 // Star selections (any >=3 corner) keep the sequential build outright:
582 // round_convex_corner's surgery is built against sequential geometry, and
583 // a mitered star would lose its sphere patch.
584 let miter_operation = if chain_corner_count > 0 && corners.is_empty() {
585 let mut any_convex = false;
586 let mut any_concave = false;
587 for point in edge_points {
588 match resolve_edge_by_point(solid, *point)
589 .and_then(|edge_id| analyze_edge(solid, edge_id, radius))
590 {
591 Ok(cross) if cross.convex => any_convex = true,
592 Ok(_) => any_concave = true,
593 // Unknown edge class: let the sequential path produce its own
594 // (more specific) error or result.
595 Err(_) => {
596 any_convex = true;
597 any_concave = true;
598 break;
599 }
600 }
601 }
602 match (any_convex, any_concave) {
603 (true, false) => Some(crate::BooleanOperation::Intersect),
604 (false, true) => Some(crate::BooleanOperation::Union),
605 _ => None,
606 }
607 } else {
608 None
609 };
610
611 // Fillet/chamfer each edge in turn, resolving its point on the evolving
612 // solid (ids shift as earlier fillets rewrite topology; the midpoint of an
613 // edge is untouched by the corner surgery of the others). This is the
614 // baseline build used directly for non-miter selections AND as the
615 // fallback when the miter composition below cannot reassemble.
616 // The composition's per-edge lane: cutter-first ONLY when a corner
617 // closure will run afterwards and read cutter-shaped topology (a star or
618 // a chain corner). A selection with no shared vertex — a lone closed
619 // rim, a lone chamfer, disconnected edges — has no such closure, so each
620 // edge gets the march first there too.
621 let composition_lane = if corners.is_empty() && chain_corner_count == 0 {
622 Lane::GeneralFirst
623 } else {
624 Lane::CutterFirst
625 };
626 let build_sequential = || -> Result<BrepSolid, String> {
627 let mut sequential = solid.clone();
628 for (index, point) in edge_points.iter().enumerate() {
629 let edge_id = resolve_edge_by_point(&sequential, *point)?;
630 // Earlier cutters in this loop TRIM the edges that share a corner
631 // with them; extend this cutter back over what they took so the
632 // two removal volumes union through the corner (§6.9.6 miter)
633 // instead of leaving a wedge of material standing behind a flush
634 // end cap. Untouched edges get a zero pad and the historical
635 // flush cutter.
636 let ends = tool_ends_to_original_extent(
637 &sequential,
638 edge_id,
639 radius,
640 original_extents[index],
641 &corners,
642 );
643 // This edge's blend wall carries the name of THIS input edge
644 // (`edge_names[index]`); a smooth chain that engulfs several edges
645 // is named after the FIRST such input edge processed here (input
646 // order), the chain's representative.
647 let edge_name = per_edge_name(edge_names, name, index);
648 sequential = fillet_or_chamfer(
649 &sequential,
650 edge_id,
651 radius,
652 chamfer,
653 edge_name,
654 ends,
655 composition_lane,
656 )?;
657 }
658 Ok(sequential)
659 };
660
661 let mut result = if let Some(operation) = miter_operation {
662 let options = crate::BooleanOptions::default();
663 let mut combined: Result<Option<BrepSolid>, String> = Ok(None);
664 for (index, point) in edge_points.iter().enumerate() {
665 let edge_id = resolve_edge_by_point(solid, *point)?;
666 let edge_name = per_edge_name(edge_names, name, index);
667 let blended = {
668 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
669 check_mixed_concavity(solid, edge_id, entry)?;
670 check_support_extent(solid, edge_id, radius, entry)?;
671 fillet_or_chamfer(
672 solid,
673 edge_id,
674 radius,
675 chamfer,
676 edge_name,
677 ToolEnds::default(),
678 Lane::CutterFirst,
679 )?
680 };
681 combined = match combined {
682 Err(e) => Err(e),
683 Ok(None) => Ok(Some(blended)),
684 Ok(Some(previous)) => {
685 crate::boolean_operation(&previous, &blended, operation, &options)
686 .map(Some)
687 .map_err(|error| {
688 format!("fillet_edges: chain-corner miter composition failed: {error}")
689 })
690 }
691 };
692 if combined.is_err() {
693 break;
694 }
695 }
696 // A §6.9.6 miter (per-edge blends intersected/unioned through the
697 // shared chain corners) can fail to reassemble on faces whose
698 // fragmented boundary does not close — e.g. a planar cap whose ENTIRE
699 // perimeter is selected, where fragment_face reports an "incomplete
700 // run". Rather than let `fillet_edges` silently DROP a selected edge
701 // to recover a valid subset (the reported defect: not every edge of
702 // the face gets a fillet), fall back to the sequential build, which
703 // blends EVERY selected edge (chain corners get a flat bulkhead
704 // instead of a miter). Only if that also fails to produce a valid
705 // solid do we surface the miter error so the caller's
706 // maximal-valid-subset search can still run.
707 match combined {
708 Ok(Some(mitered)) => mitered,
709 Ok(None) => return Err("fillet_edges: no edges selected".into()),
710 Err(miter_err) => match build_sequential() {
711 Ok(seq) if seq.validate().is_empty() => seq,
712 _ => return Err(miter_err),
713 },
714 }
715 } else {
716 build_sequential()?
717 };
718
719 // A re-entrant vertex of a selected face perimeter has two selected,
720 // convex cap-wall edges and one UNSELECTED concave wall-wall edge. The
721 // two cutter volumes only touch there, so the boolean miter leaves a
722 // triangular planar end-cap instead of carrying the rolling ball around
723 // the corner. Close that exact orthogonal class with its horn-torus
724 // sector (major radius = minor radius = fillet radius).
725 if !chamfer {
726 for (corner, adjacent) in &chain_corners {
727 let has_concave_unselected_edge = solid.edges.iter().any(|edge| {
728 if selected_edge_ids.contains(&edge.id) {
729 return false;
730 }
731 let Ok(a) = edge.curve.evaluate(edge.t0) else {
732 return false;
733 };
734 let Ok(b) = edge.curve.evaluate(edge.t1) else {
735 return false;
736 };
737 (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
738 && analyze_edge(solid, edge.id, radius)
739 .map(|cross| !cross.convex)
740 .unwrap_or(false)
741 });
742 if !has_concave_unselected_edge {
743 continue;
744 }
745 let corner_name = corner_face_name(edge_names, name, adjacent);
746 if let Ok(rounded) = crate::blend::round_concave_chain_corner(
747 &result,
748 solid,
749 *corner,
750 [
751 selected_edge_ids[adjacent[0]],
752 selected_edge_ids[adjacent[1]],
753 ],
754 radius,
755 corner_name.as_deref(),
756 ) {
757 result = rounded;
758 }
759 }
760 }
761
762 // 3. Round the convex corners (fillets only — chamfers keep sharp
763 // vertices). A corner that cannot be rounded is left as the edge
764 // fillets so the group still succeeds. Each corner patch is named after
765 // the selected edges meeting there (`{fid}:BLEND:CORNER:{e_a}+…`) so no
766 // two corners collide and the patch stays under the `{fid}:BLEND` prefix.
767 if !chamfer {
768 for (ci, corner) in corners.iter().enumerate() {
769 let corner_name = corner_face_name(edge_names, name, &corner_edges[ci]);
770 if let Ok(rounded) =
771 crate::blend::round_convex_corner(&result, *corner, radius, corner_name.as_deref())
772 {
773 result = rounded;
774 }
775 }
776 }
777
778 // Heal any residual vertex/edge gaps introduced by the corner-rounding
779 // surgery (the per-edge results are already healed inside fillet_or_chamfer).
780 heal_edge_vertex_gaps(&mut result, radius)?;
781 Ok(result)
782}
783
784/// Variable-radius fillet/chamfer of a GROUP of edges (§4.9.5), the app entry
785/// for tapered blends: each selected edge (resolved by a point on it) is
786/// blended with the SAME radius profile `radii` — a list of (edge-fraction,
787/// radius) stops in [0,1] — applied along that edge's own parameterization.
788/// Edges are blended independently (no shared-vertex corner rounding; a
789/// variable-radius star has no single tangent ball), so this is the tapered
790/// counterpart of `fillet_edges` for the constant case.
791pub fn fillet_edges_variable(
792 solid: &BrepSolid,
793 edge_points: &[Vec3],
794 edge_names: Option<&[String]>,
795 radii: &[(f64, f64)],
796 chamfer: bool,
797 name: Option<&str>,
798) -> Result<BrepSolid, String> {
799 if edge_points.is_empty() {
800 return Err("fillet_edges_variable: no edges selected".into());
801 }
802 let per_edge_stops: Vec<Vec<(f64, f64)>> = vec![radii.to_vec(); edge_points.len()];
803 fillet_edges_variable_impl(
804 solid,
805 edge_points,
806 edge_names,
807 &per_edge_stops,
808 chamfer,
809 name,
810 "fillet_edges_variable",
811 true,
812 )
813}
814
815/// The shared variable-radius group core: each selected edge `i` is blended
816/// with ITS OWN stop list `per_edge_stops[i]` (the legacy entry replicates one
817/// list; the law entries sample a chain-abscissa [`crate::law::RadiusLaw`] per
818/// edge). `allow_tapered_sequential` keeps the legacy entry's sequential
819/// fallback for tapered chains (whose end state is the honest
820/// "mismatched radii" validation gate); the law entries pass `false` because
821/// their stop fractions are computed against the ORIGINAL edges — the
822/// sequential build re-resolves edges on the evolving solid whose shared
823/// corners are already TRIMMED by earlier blends, which would silently distort
824/// the law's abscissa mapping (constant stops are immune, so they may still
825/// fall back).
826#[allow(clippy::too_many_arguments)]
827fn fillet_edges_variable_impl(
828 solid: &BrepSolid,
829 edge_points: &[Vec3],
830 edge_names: Option<&[String]>,
831 per_edge_stops: &[Vec<(f64, f64)>],
832 chamfer: bool,
833 name: Option<&str>,
834 entry: &str,
835 allow_tapered_sequential: bool,
836) -> Result<BrepSolid, String> {
837 debug_assert_eq!(per_edge_stops.len(), edge_points.len());
838 // A law that is one constant everywhere IS the constant-radius fillet:
839 // hand it to the constant group, whose corners are constructed (the
840 // stripe network) rather than composed by boolean.
841 if let Some(constant) = per_edge_stops
842 .first()
843 .and_then(|stops| stops.first())
844 .map(|(_, radius)| *radius)
845 {
846 let uniform = per_edge_stops.iter().all(|stops| {
847 stops
848 .iter()
849 .all(|(_, radius)| (radius - constant).abs() <= 1e-12 * (1.0 + constant.abs()))
850 });
851 if uniform && constant > 0.0 {
852 return fillet_edges(solid, edge_points, edge_names, constant, chamfer, name);
853 }
854 }
855 let max_radius = per_edge_stops
856 .iter()
857 .flat_map(|stops| stops.iter())
858 .map(|(_, r)| r.abs())
859 .fold(0.0_f64, f64::max);
860
861 // Chain corners miter exactly like the constant-radius group (§6.9.6):
862 // per-edge blends on the ORIGINAL solid composed by boolean — Intersect
863 // when every edge is convex, Union when every edge is concave. Mixed or
864 // unclassifiable selections keep the sequential build. Variable blends
865 // never round star vertices, so unlike the constant group there is no
866 // sequential-only star path to protect.
867 let mut chain_corner = false;
868 {
869 use rustc_hash::FxHashSet as HashSet;
870 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
871 for (i, point) in edge_points.iter().enumerate() {
872 if let Ok(edge_id) = resolve_edge_by_point(solid, *point) {
873 if let Some(edge) = solid.edges.iter().find(|e| e.id == edge_id) {
874 if let (Ok(a), Ok(b)) =
875 (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1))
876 {
877 endpoints.push((a, i));
878 endpoints.push((b, i));
879 }
880 }
881 }
882 }
883 let mut used = vec![false; endpoints.len()];
884 for i in 0..endpoints.len() {
885 if used[i] {
886 continue;
887 }
888 used[i] = true;
889 let mut edges_here: HashSet<usize> = HashSet::default();
890 edges_here.insert(endpoints[i].1);
891 for j in (i + 1)..endpoints.len() {
892 if used[j] {
893 continue;
894 }
895 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
896 used[j] = true;
897 edges_here.insert(endpoints[j].1);
898 }
899 }
900 if edges_here.len() == 2 {
901 chain_corner = true;
902 }
903 }
904 }
905 let miter_operation = if chain_corner {
906 let probe_radius = if max_radius > 0.0 { max_radius } else { 1.0 };
907 let mut any_convex = false;
908 let mut any_concave = false;
909 for point in edge_points {
910 match resolve_edge_by_point(solid, *point)
911 .and_then(|edge_id| analyze_edge(solid, edge_id, probe_radius))
912 {
913 Ok(cross) if cross.convex => any_convex = true,
914 Ok(_) => any_concave = true,
915 Err(_) => {
916 any_convex = true;
917 any_concave = true;
918 break;
919 }
920 }
921 }
922 match (any_convex, any_concave) {
923 (true, false) => Some(crate::BooleanOperation::Intersect),
924 (false, true) => Some(crate::BooleanOperation::Union),
925 _ => None,
926 }
927 } else {
928 None
929 };
930
931 // Try the miter first; the variable blend's FITTED boundary curves are
932 // only ~1e-3 accurate at blend-blend tangencies (unlike the exact
933 // constant-radius cylinders), so the composition can fail — fall back to
934 // the sequential build then, which is never worse than the pre-miter
935 // behavior. Tightening the taper surface's endpoint fitting is the
936 // documented follow-up that would make the miter stick.
937 let miter_attempt: Option<BrepSolid> = if let Some(operation) = miter_operation {
938 let options = crate::BooleanOptions::default();
939 let mut combined: Option<BrepSolid> = None;
940 let mut failed = false;
941 for (index, point) in edge_points.iter().enumerate() {
942 let Ok(edge_id) = resolve_edge_by_point(solid, *point) else {
943 failed = true;
944 break;
945 };
946 let edge_name = per_edge_name(edge_names, name, index);
947 let Ok(blended) = crate::blend::blend_edge_variable(
948 solid,
949 edge_id,
950 &per_edge_stops[index],
951 chamfer,
952 edge_name,
953 ) else {
954 failed = true;
955 break;
956 };
957 let next = match combined.take() {
958 None => blended,
959 Some(previous) => {
960 match crate::boolean_operation(&previous, &blended, operation, &options) {
961 Ok(next) => next,
962 Err(_) => {
963 failed = true;
964 break;
965 }
966 }
967 }
968 };
969 combined = Some(next);
970 }
971 if failed {
972 None
973 } else {
974 combined.filter(|s| s.validate().is_empty())
975 }
976 } else {
977 None
978 };
979 let mut result = match miter_attempt {
980 Some(mitered) => mitered,
981 None => {
982 // Any stop list that is NOT radius-uniform (the same 1e-12
983 // relative criterion `blend_edge_variable` uses for its exact
984 // constant-radius degeneration) makes the sequential build
985 // abscissa-distorting on trimmed chain edges; law entries refuse
986 // instead of silently shifting the law.
987 let tapered = per_edge_stops.iter().any(|stops| {
988 stops.first().is_some_and(|(_, first)| {
989 stops
990 .iter()
991 .any(|(_, r)| (r - first).abs() > 1e-12 * (1.0 + first.abs()))
992 })
993 });
994 if chain_corner && tapered && !allow_tapered_sequential {
995 return Err(format!(
996 "{entry}: the tapered blends across the selected chain's shared corners \
997 did not compose to a valid solid (the fitted blend boundaries could not \
998 be mitered); fillet fewer edges per operation or reduce the taper"
999 ));
1000 }
1001 let mut sequential = solid.clone();
1002 for (index, point) in edge_points.iter().enumerate() {
1003 let edge_id = resolve_edge_by_point(&sequential, *point)?;
1004 let edge_name = per_edge_name(edge_names, name, index);
1005 sequential = crate::blend::blend_edge_variable(
1006 &sequential,
1007 edge_id,
1008 &per_edge_stops[index],
1009 chamfer,
1010 edge_name,
1011 )?;
1012 }
1013 sequential
1014 }
1015 };
1016 // Heal §6.9 surgery so re-trimmed edges meet their vertices exactly; scale
1017 // the heal bound by the largest radius stop in the taper profile.
1018 heal_edge_vertex_gaps(&mut result, max_radius)?;
1019 // Final honesty gate: a genuinely TAPERED chain (different radii at the
1020 // shared corner) has mismatched trim stations there — the blends cannot
1021 // meet without a transition patch (not implemented), and the sequential
1022 // surgery silently left broken topology before this gate existed.
1023 let issues = result.validate();
1024 if !issues.is_empty() {
1025 let detail = if allow_tapered_sequential {
1026 "tapered blends meet at a shared chain vertex with \
1027 mismatched radii — the radius-transition corner patch is not implemented; \
1028 fillet the edges in separate operations or use matching stop radii"
1029 } else {
1030 "the composed radius-law blend produced invalid topology — the blends \
1031 across a shared chain corner failed to reassemble"
1032 };
1033 return Err(format!(
1034 "{entry}: {detail} ({} validation issues, first: {})",
1035 issues.len(),
1036 issues
1037 .first()
1038 .map(|issue| issue.message.clone())
1039 .unwrap_or_default()
1040 ));
1041 }
1042 Ok(result)
1043}
1044
1045/// One selected edge placed on the ordered chain, with its arc-length
1046/// parameterization (the caller-side mapping from chain abscissa to the
1047/// `blend_edge_variable` per-edge parameter-fraction stop seam).
1048struct ChainLink {
1049 /// Position of this edge in the caller's `edge_points` selection.
1050 input_index: usize,
1051 /// True when the edge's own t0→t1 parameter direction runs WITH the chain.
1052 forward: bool,
1053 /// Cumulative chord-length table from the edge's t0 end:
1054 /// `(parameter fraction, arc length)`, uniformly spaced in fraction.
1055 arc: Vec<(f64, f64)>,
1056 /// Total arc length of the edge.
1057 length: f64,
1058 /// Chain abscissa at the link's ENTRY vertex (the end reached first when
1059 /// walking the chain from its start).
1060 abscissa: f64,
1061}
1062
1063/// Cumulative chord-length table of one edge, `(parameter fraction, arc
1064/// length)` at uniform fractions. The sample count doubles until two
1065/// successive total-length estimates agree within `tol` (chord length
1066/// converges O(N⁻²) for smooth curves, so the agreement of the N and 2N
1067/// estimates bounds the remaining error at the same order); straight edges
1068/// converge on the first doubling. The 16-sample start resolves any
1069/// single-span arc of up to half a turn to sub-percent before refinement; the
1070/// 4096 cap (8 doublings) guards adversarial curves — beyond it the table is
1071/// two orders denser than the blend march's station grid, so finer chords
1072/// cannot move any station's sampled radius meaningfully.
1073fn edge_arc_table(
1074 curve: &NurbsCurve,
1075 t0: f64,
1076 t1: f64,
1077 tol: f64,
1078) -> Result<(Vec<(f64, f64)>, f64), String> {
1079 let build = |n: usize| -> Result<(Vec<(f64, f64)>, f64), String> {
1080 let mut table = Vec::with_capacity(n + 1);
1081 let mut cumulative = 0.0;
1082 let mut previous = curve.evaluate(t0)?;
1083 table.push((0.0, 0.0));
1084 for j in 1..=n {
1085 let fraction = j as f64 / n as f64;
1086 let point = curve.evaluate(t0 + (t1 - t0) * fraction)?;
1087 cumulative += point.sub(previous).length();
1088 previous = point;
1089 table.push((fraction, cumulative));
1090 }
1091 Ok((table, cumulative))
1092 };
1093 let mut n = 16usize;
1094 let (mut table, mut length) = build(n)?;
1095 while n < 4096 {
1096 n *= 2;
1097 let (next_table, next_length) = build(n)?;
1098 let converged = (next_length - length).abs() <= tol;
1099 table = next_table;
1100 length = next_length;
1101 if converged {
1102 break;
1103 }
1104 }
1105 Ok((table, length))
1106}
1107
1108/// Arc length from the edge's t0 end at `fraction` of its parameter span,
1109/// linearly interpolated in the uniform chord table.
1110fn arc_length_at_fraction(table: &[(f64, f64)], fraction: f64) -> f64 {
1111 let fraction = fraction.clamp(0.0, 1.0);
1112 let intervals = table.len() - 1;
1113 let scaled = fraction * intervals as f64;
1114 let index = (scaled.floor() as usize).min(intervals - 1);
1115 let local = scaled - index as f64;
1116 let (_, a) = table[index];
1117 let (_, b) = table[index + 1];
1118 a + (b - a) * local
1119}
1120
1121/// Resolve the selected edges and order them into ONE OPEN CHAIN with
1122/// cumulative arc-length abscissas. The chain starts at the free endpoint
1123/// belonging to the EARLIEST-selected end edge (so users get the natural
1124/// "first pick carries the law start" orientation); a single selected edge is
1125/// its own chain oriented t0→t1 (which also admits a closed edge — the law's
1126/// end radii must then match, enforced downstream by `blend_edge_variable`).
1127/// Branching (a vertex shared by 3+ selected edges), closed rings of several
1128/// edges, and disconnected selections refuse with named errors.
1129fn resolve_selected_chain(
1130 solid: &BrepSolid,
1131 edge_points: &[Vec3],
1132 entry: &str,
1133 tol: f64,
1134) -> Result<Vec<ChainLink>, String> {
1135 // Endpoint identity uses the kernel-wide COINCIDENCE_DISTANCE_FLOOR — the
1136 // same band the constant-radius group's corner detector applies.
1137 let band = crate::tolerance::COINCIDENCE_DISTANCE_FLOOR;
1138
1139 struct Resolved {
1140 edge_id: u64,
1141 start: Vec3,
1142 end: Vec3,
1143 arc: Vec<(f64, f64)>,
1144 length: f64,
1145 }
1146 let mut resolved: Vec<Resolved> = Vec::with_capacity(edge_points.len());
1147 for point in edge_points {
1148 let edge_id = resolve_edge_by_point(solid, *point)?;
1149 if resolved.iter().any(|r| r.edge_id == edge_id) {
1150 return Err(format!("{entry}: the same edge was selected more than once"));
1151 }
1152 let edge = solid
1153 .edges
1154 .iter()
1155 .find(|e| e.id == edge_id)
1156 .ok_or_else(|| format!("{entry}: resolved edge vanished"))?;
1157 let (arc, length) = edge_arc_table(&edge.curve, edge.t0, edge.t1, tol)?;
1158 if !(length > 0.0) {
1159 return Err(format!("{entry}: selected edge has zero length"));
1160 }
1161 resolved.push(Resolved {
1162 edge_id,
1163 start: edge.curve.evaluate(edge.t0)?,
1164 end: edge.curve.evaluate(edge.t1)?,
1165 arc,
1166 length,
1167 });
1168 }
1169 let n = resolved.len();
1170 if n == 1 {
1171 let only = resolved.remove(0);
1172 return Ok(vec![ChainLink {
1173 input_index: 0,
1174 forward: true,
1175 arc: only.arc,
1176 length: only.length,
1177 abscissa: 0.0,
1178 }]);
1179 }
1180
1181 // Cluster the 2n endpoints; each entry is (edge index, is_start_end).
1182 let mut clusters: Vec<(Vec3, Vec<(usize, bool)>)> = Vec::new();
1183 for (i, r) in resolved.iter().enumerate() {
1184 for (point, is_start) in [(r.start, true), (r.end, false)] {
1185 match clusters
1186 .iter_mut()
1187 .find(|(anchor, _)| anchor.sub(point).length() < band)
1188 {
1189 Some((_, members)) => members.push((i, is_start)),
1190 None => clusters.push((point, vec![(i, is_start)])),
1191 }
1192 }
1193 }
1194 if clusters.iter().any(|(_, members)| members.len() > 2) {
1195 return Err(format!(
1196 "{entry}: selected edges must form one open chain \
1197 (a vertex is shared by three or more selected edges)"
1198 ));
1199 }
1200 let free: Vec<usize> = clusters
1201 .iter()
1202 .enumerate()
1203 .filter(|(_, (_, members))| members.len() == 1)
1204 .map(|(c, _)| c)
1205 .collect();
1206 if free.len() != 2 {
1207 return Err(format!(
1208 "{entry}: selected edges must form one OPEN chain \
1209 (closed rings and disconnected selections are not supported)"
1210 ));
1211 }
1212 // Start at the free end whose edge appears EARLIEST in the selection.
1213 let start_cluster = *free
1214 .iter()
1215 .min_by_key(|&&c| clusters[c].1[0].0)
1216 .expect("two free ends");
1217
1218 // Walk the chain.
1219 let mut links: Vec<ChainLink> = Vec::with_capacity(n);
1220 let mut visited = vec![false; n];
1221 let mut abscissa = 0.0_f64;
1222 let mut cluster = start_cluster;
1223 for _ in 0..n {
1224 let Some(&(edge_index, entered_at_start)) = clusters[cluster]
1225 .1
1226 .iter()
1227 .find(|(edge_index, _)| !visited[*edge_index])
1228 else {
1229 return Err(format!(
1230 "{entry}: selected edges are not connected into one chain"
1231 ));
1232 };
1233 visited[edge_index] = true;
1234 let r = &resolved[edge_index];
1235 links.push(ChainLink {
1236 input_index: edge_index,
1237 forward: entered_at_start,
1238 arc: r.arc.clone(),
1239 length: r.length,
1240 abscissa,
1241 });
1242 abscissa += r.length;
1243 let exit_point = if entered_at_start { r.end } else { r.start };
1244 cluster = clusters
1245 .iter()
1246 .position(|(anchor, _)| anchor.sub(exit_point).length() < band)
1247 .ok_or_else(|| format!("{entry}: chain walk lost an endpoint cluster"))?;
1248 }
1249 if visited.iter().any(|v| !v) {
1250 return Err(format!(
1251 "{entry}: selected edges are not connected into one chain"
1252 ));
1253 }
1254 Ok(links)
1255}
1256
1257/// Sample the law into one edge's `(parameter fraction, radius)` stop list —
1258/// the caller-side bridge from chain abscissa into the `radius_at` closure
1259/// seam that `blend_edge_variable` builds over its stops.
1260///
1261/// `scale` maps chain abscissa into the law's own abscissa units
1262/// (`law.total_length() / chain_length` — proportional, so a law built from
1263/// the measured chain lengths maps 1:1). The base stop count derives from
1264/// the law's curvature: piecewise-linear sampling of a C1, piecewise-C2
1265/// function over step `h` errs at most `h²·max|r''|/8`, so
1266/// `h = sqrt(8·tol/max|r''|)` holds the sampling error under the SSI fit
1267/// tolerance the variable lane is built to. That bound is exact for
1268/// arc-length-linear (straight) edges; curved edges bend the
1269/// parameter→abscissa map, so each interval is additionally midpoint-checked
1270/// against the law and bisected on violation (up to 8 halvings — a 4⁸ ≈ 6·10⁴
1271/// error reduction, decisive for any C1 law). The base count is capped at
1272/// 256 intervals: 4× the blend march's 64-station density
1273/// (blend/stations.rs), beyond which denser stops cannot move any station's
1274/// sampled radius by more than the fit tolerance.
1275fn law_stops_for_link(
1276 link: &ChainLink,
1277 law: &crate::law::RadiusLaw,
1278 scale: f64,
1279 tol: f64,
1280) -> Vec<(f64, f64)> {
1281 let radius_at_fraction = |fraction: f64| -> f64 {
1282 let arc = arc_length_at_fraction(&link.arc, fraction);
1283 let chain_s = if link.forward {
1284 link.abscissa + arc
1285 } else {
1286 link.abscissa + (link.length - arc)
1287 };
1288 law.radius_at(chain_s * scale)
1289 };
1290 // Curvature of the law in edge-fraction units: d²r/df² ≤
1291 // max|r''|·(scale·length)² for the arc-length-linear map.
1292 let curvature = law
1293 .max_second_derivative(link.abscissa * scale, (link.abscissa + link.length) * scale)
1294 * (scale * link.length).powi(2);
1295 let base = if curvature * 0.125 <= tol {
1296 1usize
1297 } else {
1298 ((curvature / (8.0 * tol)).sqrt().ceil() as usize).clamp(1, 256)
1299 };
1300 let mut stops: Vec<(f64, f64)> = (0..=base)
1301 .map(|j| {
1302 let fraction = j as f64 / base as f64;
1303 (fraction, radius_at_fraction(fraction))
1304 })
1305 .collect();
1306 // Midpoint refinement for curved parameter→abscissa maps.
1307 let mut depth = 0usize;
1308 while depth < 8 {
1309 let mut refined: Vec<(f64, f64)> = Vec::with_capacity(stops.len());
1310 let mut inserted = false;
1311 for pair in stops.windows(2) {
1312 refined.push(pair[0]);
1313 let mid_fraction = 0.5 * (pair[0].0 + pair[1].0);
1314 let law_mid = radius_at_fraction(mid_fraction);
1315 let linear_mid = 0.5 * (pair[0].1 + pair[1].1);
1316 if (law_mid - linear_mid).abs() > tol {
1317 refined.push((mid_fraction, law_mid));
1318 inserted = true;
1319 }
1320 }
1321 refined.push(*stops.last().expect("at least two stops"));
1322 stops = refined;
1323 if !inserted {
1324 break;
1325 }
1326 depth += 1;
1327 }
1328 stops
1329}
1330
1331/// Fillet (or chamfer) a chain of edges under a composable radius law
1332/// evaluated on the chain's cumulative arc-length abscissa (the OCCT
1333/// `Law_Composite` model; see [`crate::law::RadiusLaw`]). The selected edges
1334/// must form ONE OPEN CHAIN (or be a single edge); the chain starts at the
1335/// free end of the earliest-selected end edge, and the law's abscissa maps
1336/// proportionally onto the chain's measured arc length (a law built with the
1337/// chain's own lengths — e.g. [`crate::law::RadiusLaw::from_vertex_radii`] —
1338/// maps 1:1). Endpoint radii are met exactly; radii at shared chain vertices
1339/// match by the law's continuity, so the per-edge blends miter through the
1340/// corners; a chain whose blends cannot be mitered REFUSES rather than
1341/// distorting the law through the sequential rebuild.
1342pub fn fillet_edges_variable_law(
1343 solid: &BrepSolid,
1344 edge_points: &[Vec3],
1345 edge_names: Option<&[String]>,
1346 law: &crate::law::RadiusLaw,
1347 chamfer: bool,
1348 name: Option<&str>,
1349) -> Result<BrepSolid, String> {
1350 const ENTRY: &str = "fillet_edges_variable_law";
1351 if edge_points.is_empty() {
1352 return Err(format!("{ENTRY}: no edges selected"));
1353 }
1354 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
1355 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
1356 fillet_variable_law_on_chain(
1357 solid,
1358 edge_points,
1359 edge_names,
1360 &chain,
1361 law,
1362 chamfer,
1363 name,
1364 ENTRY,
1365 tolerances.intersection_fit,
1366 )
1367}
1368
1369/// The natural per-vertex user model: radius `vertex_radii[i]` at chain
1370/// vertex `i` (in CHAIN order, starting at the free end of the
1371/// earliest-selected edge), smoothly interpolated along the chain
1372/// (monotone C1 — every vertex radius met exactly, no overshoot). Requires
1373/// exactly one radius per chain vertex (`edges + 1`).
1374pub fn fillet_edges_variable_vertex_radii(
1375 solid: &BrepSolid,
1376 edge_points: &[Vec3],
1377 edge_names: Option<&[String]>,
1378 vertex_radii: &[f64],
1379 chamfer: bool,
1380 name: Option<&str>,
1381) -> Result<BrepSolid, String> {
1382 const ENTRY: &str = "fillet_edges_variable_vertex_radii";
1383 if edge_points.is_empty() {
1384 return Err(format!("{ENTRY}: no edges selected"));
1385 }
1386 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
1387 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
1388 let lengths: Vec<f64> = chain.iter().map(|link| link.length).collect();
1389 let law = crate::law::RadiusLaw::from_vertex_radii(&lengths, vertex_radii)
1390 .map_err(|error| format!("{ENTRY}: {error}"))?;
1391 fillet_variable_law_on_chain(
1392 solid,
1393 edge_points,
1394 edge_names,
1395 &chain,
1396 &law,
1397 chamfer,
1398 name,
1399 ENTRY,
1400 tolerances.intersection_fit,
1401 )
1402}
1403
1404/// Shared law-entry tail: sample per-edge stop lists from the law over the
1405/// resolved chain and run the variable group core (miter-or-refuse: no
1406/// sequential fallback for tapered chains — see `fillet_edges_variable_impl`).
1407#[allow(clippy::too_many_arguments)]
1408fn fillet_variable_law_on_chain(
1409 solid: &BrepSolid,
1410 edge_points: &[Vec3],
1411 edge_names: Option<&[String]>,
1412 chain: &[ChainLink],
1413 law: &crate::law::RadiusLaw,
1414 chamfer: bool,
1415 name: Option<&str>,
1416 entry: &str,
1417 tol: f64,
1418) -> Result<BrepSolid, String> {
1419 let chain_length: f64 = chain.iter().map(|link| link.length).sum();
1420 let scale = law.total_length() / chain_length;
1421 let mut per_edge_stops: Vec<Vec<(f64, f64)>> = vec![Vec::new(); edge_points.len()];
1422 for link in chain {
1423 per_edge_stops[link.input_index] = law_stops_for_link(link, law, scale, tol);
1424 }
1425 fillet_edges_variable_impl(
1426 solid,
1427 edge_points,
1428 edge_names,
1429 &per_edge_stops,
1430 chamfer,
1431 name,
1432 entry,
1433 false,
1434 )
1435}
1436
1437/// Asymmetric (two-distance) chamfer of a GROUP of edges, the app entry: each
1438/// selected edge (resolved by a point on it) gets a `d1 × d2` bevel (§6.11).
1439/// Edges are chamfered independently — asymmetric chamfers keep sharp vertices,
1440/// so there is no shared-corner blending.
1441pub fn chamfer_edges_asymmetric(
1442 solid: &BrepSolid,
1443 edge_points: &[Vec3],
1444 edge_names: Option<&[String]>,
1445 d1: f64,
1446 d2: f64,
1447 name: Option<&str>,
1448) -> Result<BrepSolid, String> {
1449 if edge_points.is_empty() {
1450 return Err("chamfer_edges_asymmetric: no edges selected".into());
1451 }
1452 let mut result = solid.clone();
1453 // `edge_names[index]` is keyed by INPUT position; the loop resolves each
1454 // point on the EVOLVING `result`, but enumerates the input points in order,
1455 // so the index alignment holds.
1456 for (index, point) in edge_points.iter().enumerate() {
1457 let edge_id = resolve_edge_by_point(&result, *point)?;
1458 let edge_name = per_edge_name(edge_names, name, index);
1459 result = chamfer_edge_asymmetric(&result, edge_id, d1, d2, edge_name)?;
1460 }
1461 Ok(result)
1462}
1463
1464/// Distance-angle chamfer of a GROUP of edges, the app entry: each selected
1465/// edge (resolved by a point on it) gets a setback `d1` on face 1 and a chamfer
1466/// face at `angle_rad` from face 1 (§6.11); `d2` is constructed per edge.
1467pub fn chamfer_edges_angle(
1468 solid: &BrepSolid,
1469 edge_points: &[Vec3],
1470 edge_names: Option<&[String]>,
1471 d1: f64,
1472 angle_rad: f64,
1473 name: Option<&str>,
1474) -> Result<BrepSolid, String> {
1475 if edge_points.is_empty() {
1476 return Err("chamfer_edges_angle: no edges selected".into());
1477 }
1478 let mut result = solid.clone();
1479 for (index, point) in edge_points.iter().enumerate() {
1480 let edge_id = resolve_edge_by_point(&result, *point)?;
1481 let edge_name = per_edge_name(edge_names, name, index);
1482 result = chamfer_edge_angle(&result, edge_id, d1, angle_rad, edge_name)?;
1483 }
1484 Ok(result)
1485}