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