Skip to main content

axiolid_construct/
extrude_exact.rs

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