Skip to main content

axiolid_construct/
feature.rs

1//! Chamfer and fillet on a straight vertical edge of an exact prism (#68).
2//!
3//! # The contract
4//!
5//! An edge is selected by the CORNER it belongs to, not by an opaque index:
6//! a caller naming "the corner nearest this point" survives a topology change
7//! that renumbers edges, while an index does not.
8//!
9//! # What is exact here
10//!
11//! Chamfering a vertical edge of a prism replaces one corner of its profile
12//! with a straight cut, so the result is a prism over a polygon with one more
13//! vertex. That is exactly the shape `extrude_polygon_rings` already builds --
14//! no new surface families, no approximation.
15//!
16//! A constant-radius FILLET replaces the corner with a circular arc, so the
17//! wall becomes a cylindrical face. That is representable, but stitching a
18//! cylindrical wall into the prism assembly is not the same construction, so
19//! it is refused here rather than approximated by a polyline. Refusing is the
20//! honest answer: a caller asking for a fillet and receiving a many-segment
21//! chamfer would have no way to tell.
22//!
23//! # Refusals
24//!
25//! Variable radius, edge loops, curved edges, and fillets all return typed
26//! refusals naming the missing capability, never a silent no-op. A no-op is
27//! the worst outcome: the caller believes the feature was applied.
28
29use axiolid_brep::{EdgeName, ExactBRep, FaceName, SweptFace};
30use axiolid_contracts::{GeomError, GeomResult, Operation};
31use axiolid_core::{Point2, Scalar, Tolerance, Vec2, Vec3};
32use axiolid_profile::{Profile, RectangleProfile};
33
34use crate::extrude_exact::{
35    extrude_polygon_rings, extrude_with_cylindrical_blend, extrude_with_cylindrical_blends,
36};
37use crate::BACKEND_ID;
38
39fn unsupported(input: &'static str) -> GeomError {
40    GeomError::UnsupportedInput {
41        backend: BACKEND_ID,
42        operation: Operation::Sweep,
43        input,
44    }
45}
46
47/// Which edge a feature applies to.
48///
49/// Selecting by position rather than index keeps a caller's request stable
50/// across a topology change that renumbers edges.
51#[derive(Debug, Clone, PartialEq)]
52pub enum EdgeSelector {
53    /// The vertical edge at the profile corner nearest this point.
54    NearestCorner(Point2),
55    /// The edge carrying this persistent structural name.
56    ///
57    /// Unlike [`Self::NearestCorner`], this does not depend on the caller
58    /// knowing where the edge currently is. A name obtained from a previous
59    /// result still selects the same edge after the profile has been resized
60    /// or the solid rebuilt, which is what re-applying a feature requires.
61    Named(EdgeName),
62}
63
64impl EdgeSelector {
65    /// Resolve this selector to a profile corner index.
66    ///
67    /// Both variants answer the same question -- which corner of the profile
68    /// ring -- so the construction below stays one code path.
69    fn corner_index(&self, corners: &[Point2]) -> GeomResult<usize> {
70        match self {
71            Self::NearestCorner(target) => corners
72                .iter()
73                .enumerate()
74                .min_by(|(_, a), (_, b)| {
75                    (**a - *target)
76                        .length_squared()
77                        .total_cmp(&(**b - *target).length_squared())
78                })
79                .map(|(index, _)| index)
80                .ok_or_else(|| GeomError::Degenerate("profile has no corners".to_owned())),
81            // A corner edge is where two consecutive walls meet. The name
82            // states that pair, so resolving it is a search for the corner
83            // whose adjacent side ordinals match -- no geometry involved, which
84            // is exactly why it survives an edit that moves the corner.
85            Self::Named(name) => {
86                let count = corners.len();
87                (0..count)
88                    .find(|index| {
89                        let previous = (index + count - 1) % count;
90                        let (Ok(ordinal), Ok(previous_ordinal)) =
91                            (u32::try_from(*index), u32::try_from(previous))
92                        else {
93                            return false;
94                        };
95                        let candidate = EdgeName::between(
96                            FaceName::swept(SweptFace::Side(previous_ordinal)),
97                            FaceName::swept(SweptFace::Side(ordinal)),
98                        );
99                        candidate == *name
100                    })
101                    .ok_or_else(|| {
102                        unsupported("edge name does not resolve to a corner of this profile")
103                    })
104            }
105        }
106    }
107}
108
109/// How much material a feature removes.
110#[derive(Debug, Clone, Copy, PartialEq)]
111pub enum FeatureSize {
112    /// Constant setback measured along both faces meeting at the edge.
113    ConstantDistance(Scalar),
114    /// Constant blend radius. Representable, not yet constructible here.
115    ConstantRadius(Scalar),
116}
117
118/// Chamfer one vertical edge of an exact extruded rectangle.
119///
120/// Supported: a sharp filled rectangle extruded along +z, chamfered at one
121/// corner by a constant distance. Everything else is refused by name.
122pub fn chamfer_extruded_profile(
123    profile: &Profile,
124    direction: Vec3,
125    depth: Scalar,
126    edge: EdgeSelector,
127    size: FeatureSize,
128    tolerance: Tolerance,
129) -> GeomResult<ExactBRep> {
130    let distance = match size {
131        FeatureSize::ConstantDistance(distance) => distance,
132        // A fillet needs a cylindrical wall stitched into the prism assembly,
133        // which is a different construction. Approximating it with a
134        // multi-segment chamfer would be indistinguishable to the caller.
135        FeatureSize::ConstantRadius(_) => {
136            return Err(unsupported("constant-radius fillet on an extruded solid"))
137        }
138    };
139    if !distance.is_finite() || distance <= 0.0 {
140        return Err(GeomError::InvalidInput(format!(
141            "chamfer distance must be positive and finite, got {distance}"
142        )));
143    }
144
145    let Profile::Rectangle(rectangle) = profile else {
146        return Err(unsupported("chamfer on a non-rectangle profile"));
147    };
148    let RectangleProfile {
149        x,
150        y,
151        thickness,
152        outer_radius,
153        inner_radius,
154    } = *rectangle;
155    if thickness.is_some() {
156        return Err(unsupported("chamfer on a hollow profile"));
157    }
158    if outer_radius.is_some() || inner_radius.is_some() {
159        return Err(unsupported("chamfer on an already-rounded profile"));
160    }
161    if !x.is_finite() || !y.is_finite() || x <= 0.0 || y <= 0.0 {
162        return Err(GeomError::InvalidInput(format!(
163            "chamfer profile must have positive finite extents, got {x} x {y}"
164        )));
165    }
166    if !depth.is_finite() || depth <= 0.0 {
167        return Err(GeomError::InvalidInput(format!(
168            "chamfer extrusion depth must be positive and finite, got {depth}"
169        )));
170    }
171    // Only a +z extrusion keeps the vertical edges vertical, which is what
172    // makes the chamfered result another prism.
173    if direction.normalize_or_zero().dot(Vec3::Z) < 1.0 - tolerance.linear() {
174        return Err(unsupported("chamfer on an oblique extrusion"));
175    }
176
177    let (half_x, half_y) = (x / 2.0, y / 2.0);
178    // Counter-clockwise, matching the outer ring extrude_rectangle builds.
179    let corners = [
180        Point2::new(-half_x, -half_y),
181        Point2::new(half_x, -half_y),
182        Point2::new(half_x, half_y),
183        Point2::new(-half_x, half_y),
184    ];
185
186    // The setback is measured along each adjacent edge, so it cannot reach
187    // the neighbouring corner: at exactly half an edge the two chamfers meet
188    // and the edge vanishes, which is a different topology.
189    if distance * 2.0 >= x || distance * 2.0 >= y {
190        return Err(GeomError::Degenerate(format!(
191            "chamfer distance {distance} consumes an entire edge of the {x} x {y} profile"
192        )));
193    }
194
195    let index = edge.corner_index(&corners)?;
196
197    // Replace the corner with two points, each set back along one adjacent
198    // edge. The corner's own vertex disappears: that is the chamfer.
199    let previous = corners[(index + corners.len() - 1) % corners.len()];
200    let corner = corners[index];
201    let next = corners[(index + 1) % corners.len()];
202    let into_previous = (previous - corner).normalize();
203    let into_next = (next - corner).normalize();
204
205    let mut ring = Vec::with_capacity(corners.len() + 1);
206    for (position, point) in corners.iter().enumerate() {
207        if position == index {
208            // Order matters: entering along the previous edge, leaving along
209            // the next one, so the ring keeps its counter-clockwise winding.
210            ring.push(corner + into_previous * distance);
211            ring.push(corner + into_next * distance);
212        } else {
213            ring.push(*point);
214        }
215    }
216
217    extrude_polygon_rings(&[ring], Vec3::Z * depth)
218}
219
220/// Fillet one vertical edge of an exact extruded rectangle.
221///
222/// The blend is a real cylindrical face, not a many-segment approximation.
223/// A vertical edge of a +z prism sweeps its cross-section unchanged, so the
224/// blend surface is `Surface::Cylinder` with its axis parallel to z, stitched
225/// between the two planar walls it is tangent to.
226///
227/// # Tangency is constructed, not asserted
228///
229/// The blend axis sits on the internal angle bisector at distance
230/// `r / sin(theta/2)` from the corner, where `theta` is the interior angle.
231/// At that distance the perpendicular from the axis to each adjacent wall is
232/// exactly `r`, so the cylinder meets both walls tangentially by
233/// construction. For the right angle of a rectangle this is `r * sqrt(2)`.
234/// Nothing is checked afterwards, so nothing can drift.
235///
236/// # Errors
237///
238/// Refuses a radius that reaches past either neighbouring corner, and the
239/// same input families the chamfer refuses.
240pub fn fillet_extruded_profile(
241    profile: &Profile,
242    direction: Vec3,
243    depth: Scalar,
244    edge: EdgeSelector,
245    size: FeatureSize,
246    tolerance: Tolerance,
247) -> GeomResult<ExactBRep> {
248    let radius = match size {
249        FeatureSize::ConstantRadius(radius) => radius,
250        FeatureSize::ConstantDistance(_) => {
251            return Err(unsupported(
252                "chamfer requested through the fillet entry point",
253            ))
254        }
255    };
256    if !radius.is_finite() || radius <= 0.0 {
257        return Err(GeomError::InvalidInput(format!(
258            "fillet radius must be positive and finite, got {radius}"
259        )));
260    }
261
262    let geometry = rectangle_prism(profile, direction, depth, tolerance)?;
263    let (x, y) = geometry;
264
265    // The tangent points sit `radius` back along each adjacent edge, so a
266    // radius of half an edge reaches the neighbouring corner and the wall
267    // between them vanishes -- a different topology, not a fillet.
268    if radius * 2.0 >= x || radius * 2.0 >= y {
269        return Err(GeomError::Degenerate(format!(
270            "fillet radius {radius} reaches past a neighbouring corner of the {x} x {y} profile"
271        )));
272    }
273
274    build_filleted_prism(x, y, depth, radius, edge)
275}
276
277/// Validate the profile family the fillet supports, returning its extents.
278///
279/// Same refusals as the chamfer, for the same reasons: a hollow or
280/// already-rounded profile is a different corner, and an oblique extrusion
281/// does not keep the vertical edges vertical, which is what makes the blend
282/// a cylinder rather than a general swept surface.
283fn rectangle_prism(
284    profile: &Profile,
285    direction: Vec3,
286    depth: Scalar,
287    tolerance: Tolerance,
288) -> GeomResult<(Scalar, Scalar)> {
289    let Profile::Rectangle(rectangle) = profile else {
290        return Err(unsupported("fillet on a non-rectangle profile"));
291    };
292    let RectangleProfile {
293        x,
294        y,
295        thickness,
296        outer_radius,
297        inner_radius,
298    } = *rectangle;
299    if thickness.is_some() {
300        return Err(unsupported("fillet on a hollow profile"));
301    }
302    if outer_radius.is_some() || inner_radius.is_some() {
303        return Err(unsupported("fillet on an already-rounded profile"));
304    }
305    if !x.is_finite() || !y.is_finite() || x <= 0.0 || y <= 0.0 {
306        return Err(GeomError::InvalidInput(format!(
307            "fillet profile must have positive finite extents, got {x} x {y}"
308        )));
309    }
310    if !depth.is_finite() || depth <= 0.0 {
311        return Err(GeomError::InvalidInput(format!(
312            "fillet extrusion depth must be positive and finite, got {depth}"
313        )));
314    }
315    if direction.normalize_or_zero().dot(Vec3::Z) < 1.0 - tolerance.linear() {
316        return Err(unsupported("fillet on an oblique extrusion"));
317    }
318    Ok((x, y))
319}
320
321/// Corner geometry of the fillet: where the blend starts, ends, and centres.
322///
323/// For a right-angled rectangle corner the interior angle is pi/2, so the
324/// centre lies on the bisector at `r / sin(pi/4)` = `r * sqrt(2)`. The
325/// tangent points are the feet of the perpendiculars from that centre, which
326/// land exactly `r` back along each adjacent edge.
327pub struct BlendCorner {
328    pub centre: Point2,
329    pub start: Point2,
330    pub end: Point2,
331    pub sweep: Scalar,
332}
333
334/// Solve the blend for one corner of a closed polygon ring.
335///
336/// # Why the general angle, not the right-angle shortcut
337///
338/// The rectangle-only version summed the two tangent offsets, which lands on
339/// the centre only when the edges meet at a right angle. For an arbitrary
340/// interior angle `theta` the setback along each edge is `r / tan(theta/2)`
341/// and the centre sits `r / sin(theta/2)` along the bisector. Both reduce to
342/// the old expressions at `theta = pi/2`, so rectangles are unchanged.
343///
344/// Returns `None` when the corner is degenerate -- collinear or reversed
345/// edges have no well-defined bisector, and a blend there is not geometry
346/// this can name.
347fn blend_corner(corners: &[Point2], index: usize, radius: Scalar) -> Option<BlendCorner> {
348    let count = corners.len();
349    let previous = corners[(index + count - 1) % count];
350    let corner = corners[index];
351    let next = corners[(index + 1) % count];
352
353    let into_previous = (previous - corner).normalize_or_zero();
354    let into_next = (next - corner).normalize_or_zero();
355    if into_previous == Vec2::ZERO || into_next == Vec2::ZERO {
356        return None;
357    }
358
359    // Interior angle at the corner.
360    let cosine = into_previous.dot(into_next).clamp(-1.0, 1.0);
361    let theta = cosine.acos();
362    let half = theta / 2.0;
363    let (sin_half, tan_half) = (half.sin(), half.tan());
364    // Collinear (theta = pi) or folded back (theta = 0) has no blend.
365    if sin_half.abs() <= f64::EPSILON || tan_half.abs() <= f64::EPSILON {
366        return None;
367    }
368
369    // Tangent points sit `r / tan(theta/2)` back along each edge, which is
370    // where the perpendicular from the centre meets the wall.
371    let setback = radius / tan_half;
372    let start = corner + into_previous * setback;
373    let end = corner + into_next * setback;
374
375    // The centre lies on the interior bisector at `r / sin(theta/2)`.
376    let bisector = (into_previous + into_next).normalize_or_zero();
377    if bisector == Vec2::ZERO {
378        return None;
379    }
380    let centre = corner + bisector * (radius / sin_half);
381
382    // Sweep is measured between the two tangent directions; the frame's own
383    // x-axis is built from the start point, so no absolute start angle is
384    // needed.
385    let start_angle = (start.y - centre.y).atan2(start.x - centre.x);
386    let end_angle = (end.y - centre.y).atan2(end.x - centre.x);
387    // Shortest signed sweep, which for a convex corner is the minor arc.
388    let mut sweep = end_angle - start_angle;
389    while sweep > core::f64::consts::PI {
390        sweep -= core::f64::consts::TAU;
391    }
392    while sweep < -core::f64::consts::PI {
393        sweep += core::f64::consts::TAU;
394    }
395
396    Some(BlendCorner {
397        centre,
398        start,
399        end,
400        sweep,
401    })
402}
403
404/// Build the filleted prism: planar walls plus one cylindrical blend face.
405///
406/// The ring is the rectangle with the filleted corner replaced by its two
407/// tangent points, so the planar walls already stop exactly where the blend
408/// begins. `extrude_polygon_rings` builds those walls and both caps; the
409/// blend is then the one face spanning the gap, and it is a genuine
410/// `Surface::Cylinder` rather than a fan of narrow planes.
411fn build_filleted_prism(
412    x: Scalar,
413    y: Scalar,
414    depth: Scalar,
415    radius: Scalar,
416    edge: EdgeSelector,
417) -> GeomResult<ExactBRep> {
418    let (half_x, half_y) = (x / 2.0, y / 2.0);
419    let corners = [
420        Point2::new(-half_x, -half_y),
421        Point2::new(half_x, -half_y),
422        Point2::new(half_x, half_y),
423        Point2::new(-half_x, half_y),
424    ];
425
426    let index = edge.corner_index(&corners)?;
427
428    let blend = blend_corner(&corners, index, radius)
429        .ok_or_else(|| unsupported("fillet at a degenerate corner"))?;
430
431    // The ring carries the tangent points in place of the sharp corner, in
432    // winding order: enter along the previous edge, leave along the next.
433    let mut ring = Vec::with_capacity(corners.len() + 1);
434    for (position, point) in corners.iter().enumerate() {
435        if position == index {
436            ring.push(blend.start);
437            ring.push(blend.end);
438        } else {
439            ring.push(*point);
440        }
441    }
442
443    extrude_with_cylindrical_blend(&ring, Vec3::Z * depth, index, &blend, radius)
444}
445
446/// Fillet one corner of an arbitrary closed polygon profile.
447///
448/// # Why this exists next to [`fillet_extruded_profile`]
449///
450/// The profile-based entry point takes a `Profile::Rectangle` and so can
451/// only ever round a four-corner box. This one takes the ring directly, so
452/// an L-shape, a hexagon, or any polygon a boolean produced can be
453/// filleted. Corners are addressed by index into `ring`, which is stable
454/// under a rebuild in a way an arena id is not.
455///
456/// # What is still refused
457///
458/// - A radius whose setback does not fit on either adjacent edge. Trimming
459///   past a neighbouring corner would silently delete that corner.
460/// - A degenerate corner: collinear or folded edges have no bisector.
461/// - A reflex corner. The blend there is a fillet on the outside of the
462///   material, which is a different surface and is not derived here.
463pub fn fillet_polygon_corner(
464    ring: &[Point2],
465    corner: usize,
466    radius: Scalar,
467    depth: Scalar,
468) -> GeomResult<ExactBRep> {
469    if ring.len() < 3 {
470        return Err(GeomError::InvalidInput(
471            "fillet needs a ring of at least three corners".to_owned(),
472        ));
473    }
474    if corner >= ring.len() {
475        return Err(GeomError::InvalidInput(format!(
476            "fillet corner {corner} is outside a ring of {} corners",
477            ring.len()
478        )));
479    }
480    if !radius.is_finite() || radius <= 0.0 {
481        return Err(GeomError::InvalidInput(format!(
482            "fillet radius must be positive and finite, got {radius}"
483        )));
484    }
485    if !depth.is_finite() || depth <= 0.0 {
486        return Err(GeomError::InvalidInput(format!(
487            "fillet extrusion depth must be positive and finite, got {depth}"
488        )));
489    }
490    if !ring.iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
491        return Err(GeomError::InvalidInput(
492            "fillet ring has a non-finite corner".to_owned(),
493        ));
494    }
495
496    let count = ring.len();
497    let previous = ring[(corner + count - 1) % count];
498    let here = ring[corner];
499    let next = ring[(corner + 1) % count];
500
501    // A blend only makes sense on a convex corner of a counter-clockwise
502    // ring. On a reflex corner the arc bulges into the material and is a
503    // different surface, so it is refused rather than silently built wrong.
504    let ring_is_ccw = signed_area(ring) > 0.0;
505    let turn = (here - previous).perp_dot(next - here);
506    let convex = if ring_is_ccw { turn > 0.0 } else { turn < 0.0 };
507    if !convex {
508        return Err(unsupported("fillet on a reflex corner"));
509    }
510
511    let blend = blend_corner(ring, corner, radius)
512        .ok_or_else(|| unsupported("fillet at a degenerate corner"))?;
513
514    // The setback must fit on both adjacent edges. If it ran past a
515    // neighbour the blend would swallow that corner, changing the profile
516    // rather than rounding it.
517    let setback = (blend.start - here).length();
518    let to_previous = (previous - here).length();
519    let to_next = (next - here).length();
520    if setback >= to_previous || setback >= to_next {
521        return Err(unsupported("fillet radius larger than an adjacent edge"));
522    }
523
524    // Replace the corner with its two tangent points, so the planar walls
525    // already stop exactly where the blend begins.
526    let mut blended = Vec::with_capacity(count + 1);
527    for (index, point) in ring.iter().enumerate() {
528        if index == corner {
529            blended.push(blend.start);
530            blended.push(blend.end);
531        } else {
532            blended.push(*point);
533        }
534    }
535    let blend_index = corner;
536    extrude_with_cylindrical_blend(&blended, Vec3::Z * depth, blend_index, &blend, radius)
537}
538
539/// Twice the signed area of a closed ring; positive is counter-clockwise.
540fn signed_area(ring: &[Point2]) -> Scalar {
541    let count = ring.len();
542    (0..count)
543        .map(|index| {
544            let a = ring[index];
545            let b = ring[(index + 1) % count];
546            a.x * b.y - b.x * a.y
547        })
548        .sum()
549}
550
551/// Fillet several corners of a polygon profile in one solid.
552///
553/// Each entry is a `(corner index, radius)` pair. Corners are solved
554/// independently -- a blend depends only on its own corner and the two
555/// adjacent vertices -- and every replacement is applied in one pass.
556///
557/// # Why the pairwise check exists
558///
559/// Independence holds only while no two blends overlap. Two filleted
560/// corners sharing an edge each consume `r / tan(theta/2)` of it, so the
561/// pair fits only when the SUM of their setbacks is under the edge length.
562/// Checking each corner alone against the whole edge would admit a pair
563/// that individually fits and jointly self-intersects.
564pub fn fillet_polygon_corners(
565    ring: &[Point2],
566    fillets: &[(usize, Scalar)],
567    depth: Scalar,
568) -> GeomResult<ExactBRep> {
569    if fillets.is_empty() {
570        return Err(GeomError::InvalidInput(
571            "multi-corner fillet needs at least one corner".to_owned(),
572        ));
573    }
574    if ring.len() < 3 {
575        return Err(GeomError::InvalidInput(
576            "fillet profile ring needs at least three points".to_owned(),
577        ));
578    }
579
580    let count = ring.len();
581    let mut seen = vec![false; count];
582    let mut setbacks = vec![0.0; count];
583    let mut blends: Vec<Option<BlendCorner>> = (0..count).map(|_| None).collect();
584
585    for (corner, radius) in fillets.iter().copied() {
586        if corner >= count {
587            return Err(GeomError::InvalidInput(format!(
588                "fillet corner {corner} is outside a ring of {count} points"
589            )));
590        }
591        if seen[corner] {
592            return Err(GeomError::InvalidInput(format!(
593                "fillet corner {corner} given more than once"
594            )));
595        }
596        seen[corner] = true;
597        if !radius.is_finite() || radius <= 0.0 {
598            return Err(GeomError::InvalidInput(format!(
599                "fillet radius must be positive and finite, got {radius}"
600            )));
601        }
602        let blend = blend_corner(ring, corner, radius)
603            .ok_or_else(|| unsupported("fillet at a degenerate corner"))?;
604        setbacks[corner] = (blend.start - ring[corner]).length();
605        blends[corner] = Some(blend);
606    }
607
608    // Reflex corners bulge the blend into material, which is a different
609    // surface, so they are refused here exactly as in the single-corner path.
610    for corner in 0..count {
611        if !seen[corner] {
612            continue;
613        }
614        let previous = ring[(corner + count - 1) % count];
615        let here = ring[corner];
616        let next = ring[(corner + 1) % count];
617        let cross = (here - previous).perp_dot(next - here);
618        if cross <= 0.0 {
619            return Err(unsupported("fillet on a reflex corner"));
620        }
621    }
622
623    // Every edge must hold the setbacks of BOTH its endpoints. An
624    // unfilleted endpoint contributes nothing, which is why the shared
625    // `setbacks` table is zero-initialised rather than skipped.
626    for start in 0..count {
627        let end = (start + 1) % count;
628        let length = (ring[end] - ring[start]).length();
629        if setbacks[start] + setbacks[end] >= length {
630            return Err(unsupported(
631                "fillet radii too large for the edge between two corners",
632            ));
633        }
634    }
635
636    // Replace each filleted corner with its two tangent points. Walking in
637    // ring order keeps the blend table aligned with the rebuilt ring: a
638    // corner contributes two points, so every later blend shifts by one.
639    let mut blended = Vec::with_capacity(count + fillets.len());
640    let mut table: Vec<Option<(BlendCorner, Scalar)>> = Vec::with_capacity(count + fillets.len());
641    let radius_of: Vec<Scalar> = {
642        let mut r = vec![0.0; count];
643        for (corner, radius) in fillets.iter().copied() {
644            r[corner] = radius;
645        }
646        r
647    };
648    for corner in 0..count {
649        match blends[corner].take() {
650            Some(blend) => {
651                // The blend arc is the edge leaving the first tangent point.
652                let end = blend.end;
653                blended.push(blend.start);
654                table.push(Some((blend, radius_of[corner])));
655                // The straight wall leaving the second tangent point runs to
656                // the next corner, so the sharp corner itself is gone.
657                blended.push(end);
658                table.push(None);
659            }
660            None => {
661                blended.push(ring[corner]);
662                table.push(None);
663            }
664        }
665    }
666
667    extrude_with_cylindrical_blends(&blended, Vec3::Z * depth, &table)
668}
669
670/// Chamfer several corners of an arbitrary polygon profile.
671///
672/// Each entry is a `(corner index, setback distance)` pair. The corner's own
673/// vertex is replaced by two points, each `distance` back along one adjacent
674/// edge; the flat between them is the chamfer.
675///
676/// # Why this shares the fillet's checks but not its geometry
677///
678/// A chamfer is the same corner problem with a straight cut instead of an
679/// arc, so the constraints are identical -- reflex corners are a different
680/// surface, and two chamfers sharing an edge must not consume more of it than
681/// it has. What differs is that the setback IS the given distance rather than
682/// `r / tan(theta/2)`, and the resulting wall is planar, so no blend table is
683/// needed.
684pub fn chamfer_polygon_corners(
685    ring: &[Point2],
686    chamfers: &[(usize, Scalar)],
687    depth: Scalar,
688) -> GeomResult<ExactBRep> {
689    if chamfers.is_empty() {
690        return Err(GeomError::InvalidInput(
691            "multi-corner chamfer needs at least one corner".to_owned(),
692        ));
693    }
694    if ring.len() < 3 {
695        return Err(GeomError::InvalidInput(
696            "chamfer profile ring needs at least three points".to_owned(),
697        ));
698    }
699    if !depth.is_finite() || depth <= 0.0 {
700        return Err(GeomError::InvalidInput(format!(
701            "chamfer extrusion depth must be positive and finite, got {depth}"
702        )));
703    }
704
705    let count = ring.len();
706    let mut seen = vec![false; count];
707    let mut setbacks = vec![0.0; count];
708
709    for (corner, distance) in chamfers.iter().copied() {
710        if corner >= count {
711            return Err(GeomError::InvalidInput(format!(
712                "chamfer corner {corner} is outside a ring of {count} points"
713            )));
714        }
715        if seen[corner] {
716            return Err(GeomError::InvalidInput(format!(
717                "chamfer corner {corner} given more than once"
718            )));
719        }
720        seen[corner] = true;
721        if !distance.is_finite() || distance <= 0.0 {
722            return Err(GeomError::InvalidInput(format!(
723                "chamfer distance must be positive and finite, got {distance}"
724            )));
725        }
726        setbacks[corner] = distance;
727    }
728
729    // Reflex corners cut into the material from the outside: a different
730    // operation, refused here exactly as on the fillet path.
731    for corner in 0..count {
732        if !seen[corner] {
733            continue;
734        }
735        let previous = ring[(corner + count - 1) % count];
736        let here = ring[corner];
737        let next = ring[(corner + 1) % count];
738        if (here - previous).perp_dot(next - here) <= 0.0 {
739            return Err(unsupported("chamfer on a reflex corner"));
740        }
741    }
742
743    // Both endpoints of an edge draw from the same edge length.
744    for start in 0..count {
745        let end = (start + 1) % count;
746        let length = (ring[end] - ring[start]).length();
747        if setbacks[start] + setbacks[end] >= length {
748            return Err(unsupported(
749                "chamfer distances too large for the edge between two corners",
750            ));
751        }
752    }
753
754    let mut chamfered = Vec::with_capacity(count + chamfers.len());
755    for corner in 0..count {
756        let here = ring[corner];
757        if !seen[corner] {
758            chamfered.push(here);
759            continue;
760        }
761        let previous = ring[(corner + count - 1) % count];
762        let next = ring[(corner + 1) % count];
763        let into_previous = (previous - here).normalize_or_zero();
764        let into_next = (next - here).normalize_or_zero();
765        if into_previous == Vec2::ZERO || into_next == Vec2::ZERO {
766            return Err(unsupported("chamfer at a degenerate corner"));
767        }
768        // The corner vertex itself disappears: that is the chamfer.
769        chamfered.push(here + into_previous * setbacks[corner]);
770        chamfered.push(here + into_next * setbacks[corner]);
771    }
772
773    extrude_polygon_rings(&[chamfered], Vec3::Z * depth)
774}