axiolid_construct/
extrude_exact.rs

1//! Exact analytic linear-extrusion constructors.
2
3use std::f64::consts::TAU;
4
5use axiolid_brep::{EdgeName, ExactBRep, ExactBRepBuilder, FaceName, SweptFace};
6use axiolid_contracts::{GeomError, GeomResult, Operation};
7use axiolid_core::{Frame2, Frame3, Interval, Point2, Point3, Scalar, Tolerance, Vec2, Vec3};
8use axiolid_curve::{Circle2, Circle3, Curve2, Curve3, Ellipse2, Ellipse3, Line2, Line3};
9use axiolid_profile::{CircleProfile, ContourProfile, EllipseProfile, Profile, RectangleProfile};
10use axiolid_surface::{Cylinder, EllipticalCylinder, Plane, Surface};
11use axiolid_topology::{
12    audit_brep, Edge, EdgeId, EdgeUse, Face, FaceBound, FaceId, Loop, LoopId, Orientation, Shell,
13    Solid, Vertex, VertexId,
14};
15
16use crate::center_line_exact::center_line_contour;
17use crate::contour_lower::{contour_to_arc_ring, orient_arc_ring};
18use crate::extrude_arc::extrude_arc_rings;
19use crate::profile_lower::lower_derived;
20use crate::section_lower::{rectangle_contour, section_contour};
21use crate::BACKEND_ID;
22
23#[derive(Debug)]
24pub(crate) struct RingTopology {
25    bottom_edges: Vec<EdgeId>,
26    top_edges: Vec<EdgeId>,
27    vertical_edges: Vec<EdgeId>,
28    bottom_points: Vec<Point3>,
29}
30
31/// Extrude a supported profile into an exact, closed analytic B-rep.
32///
33/// Initial exact families are deliberately narrow: sharp filled/hollow
34/// rectangles under a forward non-coplanar linear extrusion, and filled circles
35/// along the positive profile normal. Every other family returns a typed refusal
36/// instead of taking the existing tessellation path.
37pub fn extrude_profile_exact(
38    profile: &Profile,
39    direction: Vec3,
40    depth: Scalar,
41    tolerance: Tolerance,
42) -> GeomResult<ExactBRep> {
43    let offset = extrusion_offset(direction, depth, tolerance)?;
44    match profile {
45        Profile::Rectangle(rectangle) => extrude_rectangle(rectangle, offset, tolerance),
46        Profile::Circle(circle) => extrude_circle(circle, offset),
47        Profile::Ellipse(ellipse) => extrude_ellipse(ellipse, offset),
48        Profile::Section(section) => {
49            let contour = section_contour(section)?;
50            extrude_contour(&contour, offset, tolerance)
51        }
52        Profile::Contour(contour) => extrude_contour(contour, offset, tolerance),
53        Profile::Derived { basis, transform } => {
54            // Lower to a concrete profile, then extrude that. Recursing on
55            // the LOWERED profile (not the basis) keeps one code path for
56            // each shape kind instead of a transform-aware variant of each.
57            let lowered = lower_derived(basis, transform, tolerance)?;
58            extrude_profile_exact(&lowered, direction, depth, tolerance)
59        }
60        Profile::Composite(members) => extrude_composite(members, offset, tolerance),
61        Profile::CenterLine(center_line) => {
62            // Offsetting produces a contour, which the contour path already
63            // extrudes exactly -- including the arc walls.
64            let contour = center_line_contour(center_line, tolerance)?;
65            extrude_contour(&contour, offset, tolerance)
66        }
67        _ => Err(unsupported("unknown profile extrusion")),
68    }
69}
70
71fn normalize_direction(direction: Vec3) -> Option<Vec3> {
72    if !direction.is_finite() {
73        return None;
74    }
75
76    // Scaling first avoids underflow/overflow in the squared norm while
77    // preserving every finite nonzero direction, including subnormals.
78    let scale = direction.abs().max_element();
79    if scale == 0.0 {
80        return None;
81    }
82    let scaled = direction / scale;
83    Some(scaled / scaled.length())
84}
85
86// Compare IEEE-754 magnitudes so underflow cannot be optimized as real arithmetic.
87const MAGNITUDE_BITS: u64 = 0x7fff_ffff_ffff_ffff;
88
89fn loses_nonzero_component(input: Vec3, output: Vec3) -> bool {
90    (input.x.to_bits() & MAGNITUDE_BITS != 0 && output.x.to_bits() & MAGNITUDE_BITS == 0)
91        || (input.y.to_bits() & MAGNITUDE_BITS != 0 && output.y.to_bits() & MAGNITUDE_BITS == 0)
92        || (input.z.to_bits() & MAGNITUDE_BITS != 0 && output.z.to_bits() & MAGNITUDE_BITS == 0)
93}
94
95fn extrusion_offset(direction: Vec3, depth: Scalar, tolerance: Tolerance) -> GeomResult<Vec3> {
96    if !depth.is_finite() || depth <= 0.0 {
97        return Err(GeomError::InvalidInput(format!(
98            "exact extrusion depth must be positive and finite, got {depth}"
99        )));
100    }
101    let normalized = normalize_direction(direction).ok_or_else(|| {
102        GeomError::InvalidInput("exact extrusion direction must be finite and non-zero".to_owned())
103    })?;
104    if loses_nonzero_component(direction, normalized) {
105        return Err(GeomError::Degenerate(
106            "exact extrusion direction cannot be normalized without losing a nonzero component"
107                .to_owned(),
108        ));
109    }
110    let offset = normalized * depth;
111    if !offset.is_finite() || loses_nonzero_component(normalized, offset) {
112        return Err(GeomError::Degenerate(
113            "exact extrusion direction could not be scaled without range loss".to_owned(),
114        ));
115    }
116    if offset.z <= tolerance.linear() {
117        return Err(unsupported("non-forward planar extrusion"));
118    }
119    Ok(offset)
120}
121
122fn extrude_rectangle(
123    rectangle: &RectangleProfile,
124    offset: Vec3,
125    tolerance: Tolerance,
126) -> GeomResult<ExactBRep> {
127    if rectangle.outer_radius.is_some() || rectangle.inner_radius.is_some() {
128        // Rounded corners are exact quarter arcs, so the contour path builds
129        // them as cylinder walls; the dedicated path below only knows
130        // straight edges.
131        return extrude_contour(&rectangle_contour(rectangle)?, offset, tolerance);
132    }
133    if !rectangle.x.is_finite()
134        || !rectangle.y.is_finite()
135        || rectangle.x <= 0.0
136        || rectangle.y <= 0.0
137    {
138        return Err(GeomError::InvalidInput(format!(
139            "exact rectangle profile must have positive finite extents, got {} x {}",
140            rectangle.x, rectangle.y
141        )));
142    }
143
144    let (hx, hy) = (rectangle.x / 2.0, rectangle.y / 2.0);
145    let mut rings = vec![vec![
146        Point2::new(-hx, -hy),
147        Point2::new(hx, -hy),
148        Point2::new(hx, hy),
149        Point2::new(-hx, hy),
150    ]];
151    if let Some(thickness) = rectangle.thickness {
152        if !thickness.is_finite()
153            || thickness <= 0.0
154            || 2.0 * thickness >= rectangle.x
155            || 2.0 * thickness >= rectangle.y
156        {
157            return Err(GeomError::InvalidInput(format!(
158                "exact hollow rectangle wall thickness {thickness} does not fit inside {} x {}",
159                rectangle.x, rectangle.y
160            )));
161        }
162        let (ix, iy) = (hx - thickness, hy - thickness);
163        if tolerance.eq(ix, 0.0) || tolerance.eq(iy, 0.0) {
164            return Err(GeomError::Degenerate(
165                "exact hollow rectangle has a collapsed inner ring".to_owned(),
166            ));
167        }
168        // Clockwise inner ring: its side normals point into the through-passage.
169        rings.push(vec![
170            Point2::new(-ix, -iy),
171            Point2::new(-ix, iy),
172            Point2::new(ix, iy),
173            Point2::new(ix, -iy),
174        ]);
175    }
176
177    extrude_polygon_rings(&rings, offset)
178}
179
180/// Extrude closed polygon rings into an exact prism.
181///
182/// The ring-to-B-rep assembly is general over polygon rings, so it is shared
183/// rather than duplicated: a chamfered box is a prism over a pentagon, and a
184/// filleted one differs only in that an edge becomes an arc. Ring 0 is the
185/// outer boundary; any further rings are through-holes.
186pub(crate) fn extrude_polygon_rings(rings: &[Vec<Point2>], offset: Vec3) -> GeomResult<ExactBRep> {
187    extrude_polygon_rings_named(rings, offset, &mut |_| None)
188}
189
190/// Extrude polygon rings, naming each side wall from its own geometry.
191///
192/// `name_side` receives the wall's two base corners and returns the name
193/// that wall should carry, or `None` to leave it unnamed. Passing the
194/// geometry rather than an index is deliberate: the caller recovering
195/// provenance from a boolean has no index to match against, only position.
196pub(crate) fn extrude_polygon_rings_named(
197    rings: &[Vec<Point2>],
198    offset: Vec3,
199    name_side: &mut dyn FnMut((Point2, Point2)) -> Option<FaceName>,
200) -> GeomResult<ExactBRep> {
201    let ring_count = rings.len();
202    let edge_count = ring_count * 12;
203    let pcurve_count = ring_count * 24;
204    let face_count = 2 + ring_count * 4;
205    let mut builder = ExactBRepBuilder::default();
206    reserve(
207        &mut builder,
208        ring_count * 8,
209        edge_count,
210        2 * ring_count + 4 * ring_count,
211        face_count,
212        edge_count,
213        pcurve_count,
214        face_count,
215    )?;
216
217    let ring_topology = rings
218        .iter()
219        .map(|ring| add_polygon_ring(&mut builder, ring.as_slice(), offset))
220        .collect::<GeomResult<Vec<_>>>()?;
221
222    let bottom_surface = builder.add_surface(Surface::Plane(Plane {
223        frame: identity_frame3(Vec3::ZERO),
224    }));
225    let top_surface = builder.add_surface(Surface::Plane(Plane {
226        frame: identity_frame3(offset),
227    }));
228
229    let mut bottom_bounds = Vec::with_capacity(ring_count);
230    let mut top_bounds = Vec::with_capacity(ring_count);
231    for (ring_index, ring) in ring_topology.iter().enumerate() {
232        let bottom_loop = add_cap_loop(&mut builder, &ring.bottom_edges, &ring.bottom_points);
233        let top_loop = add_cap_loop(&mut builder, &ring.top_edges, &ring.bottom_points);
234        bottom_bounds.push(FaceBound {
235            loop_id: bottom_loop,
236            orientation: Orientation::Forward,
237            outer: ring_index == 0,
238        });
239        top_bounds.push(FaceBound {
240            loop_id: top_loop,
241            orientation: Orientation::Forward,
242            outer: ring_index == 0,
243        });
244    }
245
246    let bottom_face = builder.topology_mut().add_face(Face {
247        surface: Some(bottom_surface),
248        bounds: bottom_bounds,
249        orientation: Orientation::Reversed,
250    });
251    let top_face = builder.topology_mut().add_face(Face {
252        surface: Some(top_surface),
253        bounds: top_bounds,
254        orientation: Orientation::Forward,
255    });
256
257    let mut faces = vec![bottom_face, top_face];
258    for (ring_index, ring) in ring_topology.iter().enumerate() {
259        let source = &rings[ring_index];
260        for edge_index in 0..ring.bottom_edges.len() {
261            let face = add_planar_side(&mut builder, ring, edge_index, offset)?;
262            // The wall's own base corners are what the caller matches
263            // against its inputs, so they are what gets handed over.
264            let start = source[edge_index];
265            let end = source[(edge_index + 1) % source.len()];
266            if let Some(name) = name_side((start, end)) {
267                builder.set_face_name(face, name);
268            }
269            faces.push(face);
270        }
271    }
272    finish_closed(builder, faces)
273}
274
275fn add_polygon_ring(
276    builder: &mut ExactBRepBuilder,
277    ring: &[Point2],
278    offset: Vec3,
279) -> GeomResult<RingTopology> {
280    add_polygon_ring_with_blends(builder, ring, offset, &[])
281}
282
283/// Build ring topology where some edges are blend arcs rather than chords.
284///
285/// A blend edge MUST carry a `Circle3`, not the straight chord between its
286/// tangent points. The blend face attaches an arc pcurve to that same edge,
287/// so a chord here makes the face boundary and the edge disagree by the
288/// sagitta -- a defect no topological check can see, since the loop still
289/// closes and every handle still resolves.
290pub(crate) fn add_polygon_ring_with_blends(
291    builder: &mut ExactBRepBuilder,
292    ring: &[Point2],
293    offset: Vec3,
294    blends: &[Option<(BlendCorner, Scalar)>],
295) -> GeomResult<RingTopology> {
296    let bottom_points: Vec<_> = ring
297        .iter()
298        .map(|point| Point3::new(point.x, point.y, 0.0))
299        .collect();
300    let top_points: Vec<_> = bottom_points.iter().map(|point| *point + offset).collect();
301    let bottom_vertices: Vec<_> = bottom_points
302        .iter()
303        .map(|&position| builder.topology_mut().add_vertex(Vertex { position }))
304        .collect();
305    let top_vertices: Vec<_> = top_points
306        .iter()
307        .map(|&position| builder.topology_mut().add_vertex(Vertex { position }))
308        .collect();
309
310    let mut bottom_edges = Vec::with_capacity(ring.len());
311    let mut top_edges = Vec::with_capacity(ring.len());
312    let mut vertical_edges = Vec::with_capacity(ring.len());
313    for index in 0..ring.len() {
314        let next = (index + 1) % ring.len();
315        // A blend edge follows its arc; every other edge is a chord.
316        if let Some(Some((blend, radius))) = blends.get(index) {
317            let (bottom_edge, top_edge) = add_blend_arc_edges(
318                builder,
319                blend,
320                *radius,
321                (bottom_vertices[index], bottom_vertices[next]),
322                (top_vertices[index], top_vertices[next]),
323                offset,
324            );
325            bottom_edges.push(bottom_edge);
326            top_edges.push(top_edge);
327        } else {
328            bottom_edges.push(add_line_edge(
329                builder,
330                bottom_vertices[index],
331                bottom_vertices[next],
332                bottom_points[index],
333                bottom_points[next] - bottom_points[index],
334            ));
335            top_edges.push(add_line_edge(
336                builder,
337                top_vertices[index],
338                top_vertices[next],
339                top_points[index],
340                top_points[next] - top_points[index],
341            ));
342        }
343        vertical_edges.push(add_line_edge(
344            builder,
345            bottom_vertices[index],
346            top_vertices[index],
347            bottom_points[index],
348            offset,
349        ));
350    }
351    Ok(RingTopology {
352        bottom_edges,
353        top_edges,
354        vertical_edges,
355        bottom_points,
356    })
357}
358
359pub(crate) fn add_line_edge(
360    builder: &mut ExactBRepBuilder,
361    start: VertexId,
362    end: VertexId,
363    origin: Point3,
364    direction: Vec3,
365) -> EdgeId {
366    let curve = builder.add_curve3(Curve3::Line(Line3 { origin, direction }));
367    let edge = builder.topology_mut().add_edge(Edge {
368        start,
369        end,
370        curve: Some(curve),
371    });
372    builder.set_edge_interval(edge, Interval::UNIT);
373    edge
374}
375
376fn add_cap_loop(builder: &mut ExactBRepBuilder, edges: &[EdgeId], points: &[Point3]) -> LoopId {
377    add_cap_loop_with_blends(builder, edges, points, &[])
378}
379
380/// A cap loop whose pcurves follow blend arcs where the ring has them.
381///
382/// The cap surface is the z-plane, so parameter space IS the cross-section
383/// plane and a straight edge maps to a `Line2`. A blend edge must map to a
384/// `Circle2`: using the chord would make the cap boundary disagree with both
385/// the blend face and the edge's own 3D curve, by the sagitta. The loop would
386/// still close and every handle would still resolve, so only a geometric
387/// audit can see the difference.
388fn add_cap_loop_with_blends(
389    builder: &mut ExactBRepBuilder,
390    edges: &[EdgeId],
391    points: &[Point3],
392    blends: &[Option<(BlendCorner, Scalar)>],
393) -> LoopId {
394    let mut uses = Vec::with_capacity(edges.len());
395    let mut intervals = Vec::with_capacity(edges.len());
396    for (index, &edge) in edges.iter().enumerate() {
397        let next = (index + 1) % edges.len();
398        let (pcurve, interval) = match blends.get(index) {
399            Some(Some((blend, radius))) => {
400                let start = blend.start - blend.centre;
401                let length = start.length();
402                let x = if length == 0.0 {
403                    Vec2::X
404                } else {
405                    start / length
406                };
407                let curve = Curve2::Circle(Circle2 {
408                    frame: Frame2 {
409                        origin: Vec2::new(blend.centre.x, blend.centre.y),
410                        x,
411                        y: Vec2::new(-x.y, x.x),
412                    },
413                    radius: *radius,
414                });
415                (curve, Interval::new(0.0, blend.sweep))
416            }
417            _ => {
418                let origin = points[index].truncate();
419                let direction = (points[next] - points[index]).truncate();
420                (Curve2::Line(Line2 { origin, direction }), Interval::UNIT)
421            }
422        };
423        let pcurve = builder.add_curve2(pcurve);
424        uses.push(EdgeUse {
425            edge,
426            orientation: Orientation::Forward,
427            pcurve: Some(pcurve),
428        });
429        intervals.push(interval);
430    }
431    add_loop(builder, uses, intervals)
432}
433
434fn add_planar_side(
435    builder: &mut ExactBRepBuilder,
436    ring: &RingTopology,
437    index: usize,
438    offset: Vec3,
439) -> GeomResult<FaceId> {
440    let next = (index + 1) % ring.bottom_edges.len();
441    let origin = ring.bottom_points[index];
442    let edge_vector = ring.bottom_points[next] - origin;
443    let x = edge_vector.normalize();
444    let z = edge_vector.cross(offset).normalize();
445    let y = z.cross(x);
446    if !(x.is_finite() && y.is_finite() && z.is_finite()) {
447        return Err(GeomError::Degenerate(
448            "exact rectangle extrusion produced a degenerate side plane".to_owned(),
449        ));
450    }
451    let surface = builder.add_surface(Surface::Plane(Plane {
452        frame: Frame3 { origin, x, y, z },
453    }));
454    let edge_length = edge_vector.length();
455    let offset_uv = Vec2::new(offset.dot(x), offset.dot(y));
456    let pcurves = [
457        Curve2::Line(Line2 {
458            origin: Vec2::ZERO,
459            direction: Vec2::new(edge_length, 0.0),
460        }),
461        Curve2::Line(Line2 {
462            origin: Vec2::new(edge_length, 0.0),
463            direction: offset_uv,
464        }),
465        Curve2::Line(Line2 {
466            origin: offset_uv,
467            direction: Vec2::new(edge_length, 0.0),
468        }),
469        Curve2::Line(Line2 {
470            origin: Vec2::ZERO,
471            direction: offset_uv,
472        }),
473    ];
474    let edge_uses = [
475        (ring.bottom_edges[index], Orientation::Forward),
476        (ring.vertical_edges[next], Orientation::Forward),
477        (ring.top_edges[index], Orientation::Reversed),
478        (ring.vertical_edges[index], Orientation::Reversed),
479    ];
480    let mut uses = Vec::with_capacity(4);
481    let mut intervals = Vec::with_capacity(4);
482    for ((edge, orientation), pcurve) in edge_uses.into_iter().zip(pcurves) {
483        let pcurve = builder.add_curve2(pcurve);
484        uses.push(EdgeUse {
485            edge,
486            orientation,
487            pcurve: Some(pcurve),
488        });
489        intervals.push(match orientation {
490            Orientation::Forward => Interval::UNIT,
491            Orientation::Reversed => Interval::new(1.0, 0.0),
492        });
493    }
494    let loop_id = add_loop(builder, uses, intervals);
495    Ok(builder.topology_mut().add_face(Face {
496        surface: Some(surface),
497        bounds: vec![FaceBound {
498            loop_id,
499            orientation: Orientation::Forward,
500            outer: true,
501        }],
502        orientation: Orientation::Forward,
503    }))
504}
505
506fn extrude_circle(circle: &CircleProfile, offset: Vec3) -> GeomResult<ExactBRep> {
507    if circle.thickness.is_some() {
508        return Err(unsupported("annular circle extrusion"));
509    }
510    if !circle.radius.is_finite() || circle.radius <= 0.0 {
511        return Err(GeomError::InvalidInput(format!(
512            "exact circle profile radius must be positive and finite, got {}",
513            circle.radius
514        )));
515    }
516    if offset.x != 0.0 || offset.y != 0.0 {
517        return Err(unsupported("oblique circle extrusion"));
518    }
519
520    let depth = offset.z;
521    let frame_bottom = identity_frame3(Vec3::ZERO);
522    let frame_top = identity_frame3(offset);
523    let frame2 = Frame2 {
524        origin: Vec2::ZERO,
525        x: Vec2::X,
526        y: Vec2::Y,
527    };
528    let bottom_point = Vec3::new(circle.radius, 0.0, 0.0);
529    let top_point = bottom_point + offset;
530
531    let mut builder = ExactBRepBuilder::default();
532    reserve(&mut builder, 2, 3, 3, 3, 3, 8, 3)?;
533    let bottom_vertex = builder.topology_mut().add_vertex(Vertex {
534        position: bottom_point,
535    });
536    let top_vertex = builder.topology_mut().add_vertex(Vertex {
537        position: top_point,
538    });
539
540    let bottom_curve = builder.add_curve3(Curve3::Circle(Circle3 {
541        frame: frame_bottom,
542        radius: circle.radius,
543    }));
544    let bottom_edge = builder.topology_mut().add_edge(Edge {
545        start: bottom_vertex,
546        end: bottom_vertex,
547        curve: Some(bottom_curve),
548    });
549    builder.set_edge_interval(bottom_edge, Interval::new(0.0, TAU));
550
551    let top_curve = builder.add_curve3(Curve3::Circle(Circle3 {
552        frame: frame_top,
553        radius: circle.radius,
554    }));
555    let top_edge = builder.topology_mut().add_edge(Edge {
556        start: top_vertex,
557        end: top_vertex,
558        curve: Some(top_curve),
559    });
560    builder.set_edge_interval(top_edge, Interval::new(0.0, TAU));
561
562    let seam_curve = builder.add_curve3(Curve3::Line(Line3 {
563        origin: bottom_point,
564        direction: offset,
565    }));
566    let seam_edge = builder.topology_mut().add_edge(Edge {
567        start: bottom_vertex,
568        end: top_vertex,
569        curve: Some(seam_curve),
570    });
571    builder.set_edge_interval(seam_edge, Interval::UNIT);
572
573    let bottom_loop = add_circle_cap_loop(&mut builder, bottom_edge, frame2, circle.radius);
574    let top_loop = add_circle_cap_loop(&mut builder, top_edge, frame2, circle.radius);
575
576    let side_curves = [
577        Curve2::Line(Line2 {
578            origin: Vec2::ZERO,
579            direction: Vec2::X,
580        }),
581        Curve2::Line(Line2 {
582            origin: Vec2::new(TAU, 0.0),
583            direction: Vec2::new(0.0, depth),
584        }),
585        Curve2::Line(Line2 {
586            origin: Vec2::new(0.0, depth),
587            direction: Vec2::X,
588        }),
589        Curve2::Line(Line2 {
590            origin: Vec2::ZERO,
591            direction: Vec2::new(0.0, depth),
592        }),
593    ];
594    let side_edges = [
595        (bottom_edge, Orientation::Forward, Interval::new(0.0, TAU)),
596        (seam_edge, Orientation::Forward, Interval::UNIT),
597        (top_edge, Orientation::Reversed, Interval::new(TAU, 0.0)),
598        (seam_edge, Orientation::Reversed, Interval::new(1.0, 0.0)),
599    ];
600    let mut side_uses = Vec::with_capacity(4);
601    let mut side_intervals = Vec::with_capacity(4);
602    for ((edge, orientation, interval), curve) in side_edges.into_iter().zip(side_curves) {
603        let pcurve = builder.add_curve2(curve);
604        side_uses.push(EdgeUse {
605            edge,
606            orientation,
607            pcurve: Some(pcurve),
608        });
609        side_intervals.push(interval);
610    }
611    let side_loop = add_loop(&mut builder, side_uses, side_intervals);
612
613    let bottom_surface = builder.add_surface(Surface::Plane(Plane {
614        frame: frame_bottom,
615    }));
616    let top_surface = builder.add_surface(Surface::Plane(Plane { frame: frame_top }));
617    let side_surface = builder.add_surface(Surface::Cylinder(Cylinder {
618        frame: frame_bottom,
619        radius: circle.radius,
620    }));
621    let bottom_face = add_single_bound_face(
622        &mut builder,
623        bottom_surface,
624        bottom_loop,
625        Orientation::Reversed,
626    );
627    let top_face = add_single_bound_face(&mut builder, top_surface, top_loop, Orientation::Forward);
628    let side_face =
629        add_single_bound_face(&mut builder, side_surface, side_loop, Orientation::Forward);
630    finish_closed(builder, vec![bottom_face, top_face, side_face])
631}
632
633fn add_circle_cap_loop(
634    builder: &mut ExactBRepBuilder,
635    edge: EdgeId,
636    frame: Frame2,
637    radius: Scalar,
638) -> LoopId {
639    let pcurve = builder.add_curve2(Curve2::Circle(Circle2 { frame, radius }));
640    add_loop(
641        builder,
642        vec![EdgeUse {
643            edge,
644            orientation: Orientation::Forward,
645            pcurve: Some(pcurve),
646        }],
647        vec![Interval::new(0.0, TAU)],
648    )
649}
650
651pub(crate) fn add_single_bound_face(
652    builder: &mut ExactBRepBuilder,
653    surface: axiolid_brep::SurfaceId,
654    loop_id: LoopId,
655    orientation: Orientation,
656) -> FaceId {
657    builder.topology_mut().add_face(Face {
658        surface: Some(surface),
659        bounds: vec![FaceBound {
660            loop_id,
661            orientation: Orientation::Forward,
662            outer: true,
663        }],
664        orientation,
665    })
666}
667
668pub(crate) fn add_loop(
669    builder: &mut ExactBRepBuilder,
670    uses: Vec<EdgeUse<axiolid_brep::Curve2Id>>,
671    intervals: Vec<Interval>,
672) -> LoopId {
673    debug_assert_eq!(uses.len(), intervals.len());
674    let loop_id = builder.topology_mut().add_loop(Loop { edges: uses });
675    for (use_index, interval) in intervals.into_iter().enumerate() {
676        builder.set_pcurve_interval(loop_id, use_index, interval);
677    }
678    loop_id
679}
680
681pub(crate) fn finish_closed(
682    mut builder: ExactBRepBuilder,
683    faces: Vec<FaceId>,
684) -> GeomResult<ExactBRep> {
685    let shell = builder.topology_mut().add_shell(Shell {
686        faces: faces
687            .into_iter()
688            .map(|face| (face, Orientation::Forward))
689            .collect(),
690        closed: true,
691    });
692    builder.topology_mut().add_solid(Solid {
693        outer: shell,
694        voids: Vec::new(),
695    });
696    let exact = builder
697        .finish()
698        .map_err(|error| GeomError::BackendContractViolation {
699            backend: BACKEND_ID,
700            detail: format!("exact extrusion assembly failed: {error}"),
701        })?;
702    let health = audit_brep(exact.topology());
703    if !health.is_closed_manifold() {
704        return Err(GeomError::BackendContractViolation {
705            backend: BACKEND_ID,
706            detail: format!("exact extrusion is not a closed manifold: {health:?}"),
707        });
708    }
709    Ok(exact)
710}
711
712#[allow(clippy::too_many_arguments)]
713pub(crate) fn reserve(
714    builder: &mut ExactBRepBuilder,
715    vertices: usize,
716    edges: usize,
717    loops: usize,
718    faces: usize,
719    curves3: usize,
720    curves2: usize,
721    surfaces: usize,
722) -> GeomResult<()> {
723    builder
724        .topology_mut()
725        .try_reserve(vertices, edges, loops, faces, 1, 1)
726        .map_err(|_| GeomError::BudgetExceeded { resource: "memory" })?;
727    builder
728        .try_reserve(curves3, curves2, surfaces, edges, curves2)
729        .map_err(|_| GeomError::BudgetExceeded { resource: "memory" })
730}
731
732pub(crate) fn identity_frame3(origin: Point3) -> Frame3 {
733    Frame3 {
734        origin,
735        x: Vec3::X,
736        y: Vec3::Y,
737        z: Vec3::Z,
738    }
739}
740
741fn unsupported(input: &'static str) -> GeomError {
742    GeomError::UnsupportedInput {
743        backend: BACKEND_ID,
744        operation: Operation::Sweep,
745        input,
746    }
747}
748
749/// Extrude a ring where one side is a cylindrical blend instead of a plane.
750///
751/// Mirrors `extrude_polygon_rings` for the planar walls and caps, then adds
752/// the blend as a single face whose surface is `Surface::Cylinder`. The two
753/// vertical edges bounding it are shared with the neighbouring planar walls,
754/// which is what makes the shell closed and the blend tangent: same edges,
755/// same vertices, no seam.
756pub(crate) use crate::feature::BlendCorner;
757
758pub(crate) fn extrude_with_cylindrical_blend(
759    ring: &[Point2],
760    offset: Vec3,
761    blend_index: usize,
762    blend: &BlendCorner,
763    radius: Scalar,
764) -> GeomResult<ExactBRep> {
765    let mut blends: Vec<Option<(BlendCorner, Scalar)>> = (0..ring.len()).map(|_| None).collect();
766    blends[blend_index] = Some((
767        BlendCorner {
768            centre: blend.centre,
769            start: blend.start,
770            end: blend.end,
771            sweep: blend.sweep,
772        },
773        radius,
774    ));
775    extrude_with_cylindrical_blends(ring, offset, &blends)
776}
777
778/// Extrude a ring where any subset of edges is a cylindrical blend.
779///
780/// `blends[i]` is `Some` when edge `i` of the ring is a blend arc rather than
781/// a straight wall. Taking a table rather than one index is what lets several
782/// corners be filleted in a single solid without a second traversal.
783pub(crate) fn extrude_with_cylindrical_blends(
784    ring: &[Point2],
785    offset: Vec3,
786    blends: &[Option<(BlendCorner, Scalar)>],
787) -> GeomResult<ExactBRep> {
788    let mut builder = ExactBRepBuilder::default();
789    let n = ring.len();
790    reserve(
791        &mut builder,
792        n * 2,
793        n * 3,
794        2 + n,
795        2 + n,
796        n * 3,
797        n * 6,
798        2 + n,
799    )?;
800
801    // Blend edges must be arcs, so the table has to reach ring construction.
802    let topology = add_polygon_ring_with_blends(&mut builder, ring, offset, blends)?;
803
804    let bottom_surface = builder.add_surface(Surface::Plane(Plane {
805        frame: identity_frame3(Vec3::ZERO),
806    }));
807    let top_surface = builder.add_surface(Surface::Plane(Plane {
808        frame: identity_frame3(offset),
809    }));
810    let bottom_loop = add_cap_loop_with_blends(
811        &mut builder,
812        &topology.bottom_edges,
813        &topology.bottom_points,
814        blends,
815    );
816    let top_loop = add_cap_loop_with_blends(
817        &mut builder,
818        &topology.top_edges,
819        &topology.bottom_points,
820        blends,
821    );
822
823    let bottom_face = builder.topology_mut().add_face(Face {
824        surface: Some(bottom_surface),
825        bounds: vec![FaceBound {
826            loop_id: bottom_loop,
827            orientation: Orientation::Forward,
828            outer: true,
829        }],
830        orientation: Orientation::Reversed,
831    });
832    let top_face = builder.topology_mut().add_face(Face {
833        surface: Some(top_surface),
834        bounds: vec![FaceBound {
835            loop_id: top_loop,
836            orientation: Orientation::Forward,
837            outer: true,
838        }],
839        orientation: Orientation::Forward,
840    });
841    builder.set_face_name(bottom_face, FaceName::swept(SweptFace::StartCap));
842    builder.set_face_name(top_face, FaceName::swept(SweptFace::EndCap));
843
844    let mut faces = vec![bottom_face, top_face];
845    let side_count = topology.bottom_edges.len();
846    for edge_index in 0..side_count {
847        // `edge_index` is the profile edge ordinal this wall was swept from.
848        // It survives a rebuild with a different face count, which is exactly
849        // what an arena index does not.
850        let ordinal = u32::try_from(edge_index).map_err(|_| {
851            GeomError::Degenerate("profile edge count exceeds u32 capacity".to_owned())
852        })?;
853        if let Some((blend, radius)) = blends.get(edge_index).and_then(Option::as_ref) {
854            let face =
855                add_cylindrical_blend(&mut builder, &topology, edge_index, offset, blend, *radius)?;
856            // The blend replaced the edge between the two walls it is tangent
857            // to. Naming it after that edge -- rather than after its own
858            // position -- is what lets the same fillet be re-applied after an
859            // upstream edit: the edge name is still derivable from the profile.
860            let previous = (edge_index + side_count - 1) % side_count;
861            let previous_ordinal = u32::try_from(previous).map_err(|_| {
862                GeomError::Degenerate("profile edge count exceeds u32 capacity".to_owned())
863            })?;
864            builder.set_face_name(
865                face,
866                FaceName::blend(EdgeName::between(
867                    FaceName::swept(SweptFace::Side(previous_ordinal)),
868                    FaceName::swept(SweptFace::Side(ordinal)),
869                )),
870            );
871            faces.push(face);
872        } else {
873            let face = add_planar_side(&mut builder, &topology, edge_index, offset)?;
874            builder.set_face_name(face, FaceName::swept(SweptFace::Side(ordinal)));
875            faces.push(face);
876        }
877    }
878    finish_closed(builder, faces)
879}
880
881/// The blend face: a cylinder tangent to both neighbouring walls.
882///
883/// The cylinder's axis is the blend centre extruded along the offset, and its
884/// radius is the fillet radius. Tangency holds because the centre was placed
885/// at the perpendicular distance `radius` from each adjacent wall -- it is a
886/// property of the construction, not something verified here.
887///
888/// The face is bounded by the same four edges a planar wall would use, so the
889/// blend shares vertices and edges with its neighbours and the shell closes.
890fn add_cylindrical_blend(
891    builder: &mut ExactBRepBuilder,
892    ring: &RingTopology,
893    index: usize,
894    offset: Vec3,
895    blend: &BlendCorner,
896    radius: Scalar,
897) -> GeomResult<FaceId> {
898    let next = (index + 1) % ring.bottom_edges.len();
899    let centre = Point3::new(blend.centre.x, blend.centre.y, 0.0);
900    // Frame x-axis points at the arc start, so the surface parameter u is the
901    // angle measured from there and the pcurve intervals are the sweep.
902    let start = ring.bottom_points[index];
903    let x = (start - centre).normalize();
904    let z = offset.normalize();
905    let y = z.cross(x);
906    if !(x.is_finite() && y.is_finite() && z.is_finite()) {
907        return Err(GeomError::Degenerate(
908            "fillet blend produced a degenerate cylinder frame".to_owned(),
909        ));
910    }
911    let surface = builder.add_surface(Surface::Cylinder(Cylinder {
912        frame: Frame3 {
913            origin: centre,
914            x,
915            y,
916            z,
917        },
918        radius,
919    }));
920
921    let height = offset.length();
922    let pcurves = [
923        // Bottom arc: u sweeps the blend angle at v = 0.
924        Curve2::Line(Line2 {
925            origin: Vec2::ZERO,
926            direction: Vec2::new(blend.sweep, 0.0),
927        }),
928        Curve2::Line(Line2 {
929            origin: Vec2::new(blend.sweep, 0.0),
930            direction: Vec2::new(0.0, height),
931        }),
932        Curve2::Line(Line2 {
933            origin: Vec2::new(0.0, height),
934            direction: Vec2::new(blend.sweep, 0.0),
935        }),
936        Curve2::Line(Line2 {
937            origin: Vec2::ZERO,
938            direction: Vec2::new(0.0, height),
939        }),
940    ];
941    let edge_uses = [
942        (ring.bottom_edges[index], Orientation::Forward),
943        (ring.vertical_edges[next], Orientation::Forward),
944        (ring.top_edges[index], Orientation::Reversed),
945        (ring.vertical_edges[index], Orientation::Reversed),
946    ];
947
948    let mut uses = Vec::with_capacity(4);
949    let mut intervals = Vec::with_capacity(4);
950    for ((edge, orientation), pcurve) in edge_uses.into_iter().zip(pcurves) {
951        let pcurve = builder.add_curve2(pcurve);
952        uses.push(EdgeUse {
953            edge,
954            orientation,
955            pcurve: Some(pcurve),
956        });
957        intervals.push(match orientation {
958            Orientation::Forward => Interval::UNIT,
959            Orientation::Reversed => Interval::new(1.0, 0.0),
960        });
961    }
962    let loop_id = add_loop(builder, uses, intervals);
963    Ok(builder.topology_mut().add_face(Face {
964        surface: Some(surface),
965        bounds: vec![FaceBound {
966            loop_id,
967            orientation: Orientation::Forward,
968            outer: true,
969        }],
970        orientation: Orientation::Forward,
971    }))
972}
973
974/// The bottom and top arc edges of one cylindrical blend.
975///
976/// Both lie on the blend cylinder, so each is a `Circle3` whose frame x-axis
977/// points at the arc start -- the same convention `add_cylindrical_blend`
978/// uses, which is what lets the edge interval be the sweep itself.
979fn add_blend_arc_edges(
980    builder: &mut ExactBRepBuilder,
981    blend: &BlendCorner,
982    radius: Scalar,
983    bottom: (VertexId, VertexId),
984    top: (VertexId, VertexId),
985    offset: Vec3,
986) -> (EdgeId, EdgeId) {
987    let centre = Point3::new(blend.centre.x, blend.centre.y, 0.0);
988    let start = Point3::new(blend.start.x, blend.start.y, 0.0);
989    let x = (start - centre).normalize_or_zero();
990    let z = offset.normalize_or_zero();
991    let y = z.cross(x);
992
993    let mut arc_edge = |origin: Point3, ends: (VertexId, VertexId)| {
994        let curve = builder.add_curve3(Curve3::Circle(Circle3 {
995            frame: Frame3 { origin, x, y, z },
996            radius,
997        }));
998        let edge = builder.topology_mut().add_edge(Edge {
999            start: ends.0,
1000            end: ends.1,
1001            curve: Some(curve),
1002        });
1003        // The circle is parameterised by angle from the frame x-axis, which
1004        // points at the arc start, so the interval IS the sweep.
1005        builder.set_edge_interval(edge, Interval::new(0.0, blend.sweep));
1006        edge
1007    };
1008
1009    let bottom_edge = arc_edge(centre, bottom);
1010    let top_edge = arc_edge(centre + offset, top);
1011    (bottom_edge, top_edge)
1012}
1013
1014/// Extrude an arbitrary exact contour.
1015///
1016/// Lowers the contour onto the arc-ring extruder, which already builds exact
1017/// planar walls for straight segments and cylindrical walls for circular
1018/// ones. Curve kinds it cannot carry exactly are refused by the lowering
1019/// rather than sampled into chords.
1020///
1021/// Holes are refused: the arc cap-loop builder emits a single bound, so a
1022/// hole would be silently dropped from the caps while still appearing in the
1023/// walls -- a solid that closes and is wrong.
1024fn extrude_contour(
1025    contour: &ContourProfile,
1026    offset: Vec3,
1027    tolerance: Tolerance,
1028) -> GeomResult<ExactBRep> {
1029    // Outer runs counter-clockwise, holes clockwise, so every ring's wall
1030    // normals point out of the material. Orienting here rather than demanding
1031    // it of the caller means an imported contour with either winding builds
1032    // the same solid.
1033    let outer = orient_arc_ring(&contour_to_arc_ring(&contour.outer, tolerance)?, true)?;
1034    let mut rings = Vec::with_capacity(1 + contour.holes.len());
1035    rings.push(outer);
1036    for hole in &contour.holes {
1037        rings.push(orient_arc_ring(
1038            &contour_to_arc_ring(hole, tolerance)?,
1039            false,
1040        )?);
1041    }
1042
1043    // A section of only straight segments is a polygon, and the polygon path
1044    // carries face naming the arc path does not. Routing it there keeps one
1045    // behaviour for one shape.
1046    if rings
1047        .iter()
1048        .all(|ring| ring.vertices.iter().all(|vertex| vertex.bulge == 0.0))
1049    {
1050        let polygons: Vec<Vec<Point2>> = rings
1051            .iter()
1052            .map(|ring| ring.vertices.iter().map(|vertex| vertex.point).collect())
1053            .collect();
1054        return extrude_polygon_rings(&polygons, offset);
1055    }
1056    extrude_arc_rings(&rings, offset)
1057}
1058
1059/// Extrude a composite profile: union the members exactly, then extrude
1060/// each connected piece. Disjoint members come back as one solid each in
1061/// one `ExactBRep` (#111).
1062fn extrude_composite(
1063    members: &[Profile],
1064    offset: Vec3,
1065    tolerance: Tolerance,
1066) -> GeomResult<ExactBRep> {
1067    let regions = crate::profile_lower::composite_regions(members, tolerance)?;
1068    let mut pieces = Vec::with_capacity(regions.len());
1069    for region in regions {
1070        let mut rings = Vec::with_capacity(1 + region.holes.len());
1071        rings.push(orient_arc_ring(&region.outer, true)?);
1072        for hole in &region.holes {
1073            rings.push(orient_arc_ring(hole, false)?);
1074        }
1075        // A straight-edged piece takes the polygon path, as a contour does.
1076        if rings
1077            .iter()
1078            .all(|ring| ring.vertices.iter().all(|vertex| vertex.bulge == 0.0))
1079        {
1080            let polygons: Vec<Vec<Point2>> = rings
1081                .iter()
1082                .map(|ring| ring.vertices.iter().map(|vertex| vertex.point).collect())
1083                .collect();
1084            pieces.push(extrude_polygon_rings(&polygons, offset)?);
1085        } else {
1086            pieces.push(extrude_arc_rings(&rings, offset)?);
1087        }
1088    }
1089    crate::assemble::merge_solids(pieces)
1090}
1091
1092/// Extrude an ellipse profile into an elliptical-cylinder prism.
1093///
1094/// Mirrors `extrude_circle`: one seam edge, two closed cap edges, a single
1095/// curved wall. The wall is a genuine `EllipticalCylinder`, not a spline
1096/// approximation, and the cap pcurve is an `Ellipse2` whose parameter matches
1097/// the wall's `u` -- both use `(a cos t, b sin t)`, which is what lets the
1098/// cap boundary and the wall boundary agree exactly on the shared edge.
1099fn extrude_ellipse(ellipse: &EllipseProfile, offset: Vec3) -> GeomResult<ExactBRep> {
1100    let (a, b) = (ellipse.semi_axis_x, ellipse.semi_axis_y);
1101    if !a.is_finite() || !b.is_finite() || a <= 0.0 || b <= 0.0 {
1102        return Err(GeomError::InvalidInput(format!(
1103            "exact ellipse profile semi-axes must be positive and finite, got {a} x {b}"
1104        )));
1105    }
1106    if offset.x != 0.0 || offset.y != 0.0 {
1107        return Err(unsupported("oblique ellipse extrusion"));
1108    }
1109
1110    let frame_bottom = identity_frame3(Vec3::ZERO);
1111    let frame_top = identity_frame3(offset);
1112    let frame2 = Frame2 {
1113        origin: Vec2::ZERO,
1114        x: Vec2::X,
1115        y: Vec2::Y,
1116    };
1117    // The seam sits at t = 0, which is (a, 0) for this parameterisation.
1118    let bottom_point = Vec3::new(a, 0.0, 0.0);
1119    let top_point = bottom_point + offset;
1120
1121    let mut builder = ExactBRepBuilder::default();
1122    reserve(&mut builder, 2, 3, 3, 3, 3, 8, 3)?;
1123    let bottom_vertex = builder.topology_mut().add_vertex(Vertex {
1124        position: bottom_point,
1125    });
1126    let top_vertex = builder.topology_mut().add_vertex(Vertex {
1127        position: top_point,
1128    });
1129
1130    let bottom_curve = builder.add_curve3(Curve3::Ellipse(Ellipse3 {
1131        frame: frame_bottom,
1132        semi_axis_x: a,
1133        semi_axis_y: b,
1134    }));
1135    let bottom_edge = builder.topology_mut().add_edge(Edge {
1136        start: bottom_vertex,
1137        end: bottom_vertex,
1138        curve: Some(bottom_curve),
1139    });
1140    builder.set_edge_interval(bottom_edge, Interval::new(0.0, TAU));
1141
1142    let top_curve = builder.add_curve3(Curve3::Ellipse(Ellipse3 {
1143        frame: frame_top,
1144        semi_axis_x: a,
1145        semi_axis_y: b,
1146    }));
1147    let top_edge = builder.topology_mut().add_edge(Edge {
1148        start: top_vertex,
1149        end: top_vertex,
1150        curve: Some(top_curve),
1151    });
1152    builder.set_edge_interval(top_edge, Interval::new(0.0, TAU));
1153
1154    let seam_curve = builder.add_curve3(Curve3::Line(Line3 {
1155        origin: bottom_point,
1156        direction: offset,
1157    }));
1158    let seam_edge = builder.topology_mut().add_edge(Edge {
1159        start: bottom_vertex,
1160        end: top_vertex,
1161        curve: Some(seam_curve),
1162    });
1163    builder.set_edge_interval(seam_edge, Interval::UNIT);
1164
1165    let bottom_loop = add_ellipse_cap_loop(&mut builder, bottom_edge, frame2, a, b);
1166    let top_loop = add_ellipse_cap_loop(&mut builder, top_edge, frame2, a, b);
1167
1168    // The wall's parameter domain is u in [0, TAU] by v in [0, depth], so the
1169    // trim rectangle is the same as the circular case: only the SURFACE
1170    // differs, because both parameterise u the same way.
1171    let depth = offset.z;
1172    let side_curves = [
1173        Curve2::Line(Line2 {
1174            origin: Vec2::ZERO,
1175            direction: Vec2::X,
1176        }),
1177        Curve2::Line(Line2 {
1178            origin: Vec2::new(TAU, 0.0),
1179            direction: Vec2::new(0.0, depth),
1180        }),
1181        Curve2::Line(Line2 {
1182            origin: Vec2::new(0.0, depth),
1183            direction: Vec2::X,
1184        }),
1185        Curve2::Line(Line2 {
1186            origin: Vec2::ZERO,
1187            direction: Vec2::new(0.0, depth),
1188        }),
1189    ];
1190    let side_edges = [
1191        (bottom_edge, Orientation::Forward, Interval::new(0.0, TAU)),
1192        (seam_edge, Orientation::Forward, Interval::UNIT),
1193        (top_edge, Orientation::Reversed, Interval::new(TAU, 0.0)),
1194        (seam_edge, Orientation::Reversed, Interval::new(1.0, 0.0)),
1195    ];
1196    let mut side_uses = Vec::with_capacity(4);
1197    let mut side_intervals = Vec::with_capacity(4);
1198    for ((edge, orientation, interval), curve) in side_edges.into_iter().zip(side_curves) {
1199        let pcurve = builder.add_curve2(curve);
1200        side_uses.push(EdgeUse {
1201            edge,
1202            orientation,
1203            pcurve: Some(pcurve),
1204        });
1205        side_intervals.push(interval);
1206    }
1207    let side_loop = add_loop(&mut builder, side_uses, side_intervals);
1208
1209    let bottom_surface = builder.add_surface(Surface::Plane(Plane {
1210        frame: frame_bottom,
1211    }));
1212    let top_surface = builder.add_surface(Surface::Plane(Plane { frame: frame_top }));
1213    let side_surface = builder.add_surface(Surface::EllipticalCylinder(EllipticalCylinder {
1214        frame: frame_bottom,
1215        semi_axis_x: a,
1216        semi_axis_y: b,
1217    }));
1218    let bottom_face = add_single_bound_face(
1219        &mut builder,
1220        bottom_surface,
1221        bottom_loop,
1222        Orientation::Reversed,
1223    );
1224    let top_face = add_single_bound_face(&mut builder, top_surface, top_loop, Orientation::Forward);
1225    let side_face =
1226        add_single_bound_face(&mut builder, side_surface, side_loop, Orientation::Forward);
1227    finish_closed(builder, vec![bottom_face, top_face, side_face])
1228}
1229
1230/// A closed cap loop whose single edge is trimmed by an ellipse pcurve.
1231fn add_ellipse_cap_loop(
1232    builder: &mut ExactBRepBuilder,
1233    edge: EdgeId,
1234    frame: Frame2,
1235    semi_axis_x: Scalar,
1236    semi_axis_y: Scalar,
1237) -> LoopId {
1238    let pcurve = builder.add_curve2(Curve2::Ellipse(Ellipse2 {
1239        frame,
1240        semi_axis_x,
1241        semi_axis_y,
1242    }));
1243    add_loop(
1244        builder,
1245        vec![EdgeUse {
1246            edge,
1247            orientation: Orientation::Forward,
1248            pcurve: Some(pcurve),
1249        }],
1250        vec![Interval::new(0.0, TAU)],
1251    )
1252}
1253
1254/// Lower a contour-shaped profile to its `ContourProfile`.
1255///
1256/// Shared by extrusion and revolution so the two cannot disagree about what
1257/// a `Section`, `CenterLine`, `Derived` or `Composite` actually denotes: a
1258/// second copy of this mapping is exactly how one sweep starts building a
1259/// different shape from the other.
1260pub fn profile_to_contour(profile: &Profile, tolerance: Tolerance) -> GeomResult<ContourProfile> {
1261    match profile {
1262        Profile::Contour(contour) => Ok(contour.clone()),
1263        Profile::Rectangle(rectangle) => rectangle_contour(rectangle),
1264        Profile::Circle(circle) => crate::section_lower::circle_contour(circle),
1265        Profile::Section(section) => section_contour(section),
1266        Profile::CenterLine(center_line) => center_line_contour(center_line, tolerance),
1267        Profile::Derived { basis, transform } => {
1268            let lowered = lower_derived(basis, transform, tolerance)?;
1269            profile_to_contour(&lowered, tolerance)
1270        }
1271        // `lower_composite` unions its members into POINT rings, because the
1272        // union operates on polygons. Those rings have already lost their
1273        // segment kinds, so rebuilding a contour from them would fabricate
1274        // straight edges where the members had arcs. Refused rather than
1275        // silently re-approximated; the extrusion path consumes the rings
1276        // directly and does not need this.
1277        Profile::Composite(_) => Err(unsupported(
1278            "composite profile does not lower to an exact contour",
1279        )),
1280        _ => Err(unsupported("profile does not lower to a contour")),
1281    }
1282}