Skip to main content

axiolid_construct/
profile_lower.rs

1//! Lower `Derived` and `Composite` profiles onto the concrete builders
2//! (ADR 0054).
3//!
4//! # Derived
5//!
6//! A derived profile is a basis plus an affine transform. Lowering it means
7//! pushing the transform down onto the basis geometry, which is only sound
8//! when the transform preserves the SHAPE KIND of what it moves:
9//!
10//! - A circle stays a circle only under a CONFORMAL (similarity) linear part.
11//!   A non-uniform scale or a shear turns it into an ellipse, which this
12//!   crate cannot extrude, so those are refused rather than silently
13//!   relabelled. Note a shear has determinant 1, so a determinant check alone
14//!   would let it through.
15//! - A negative determinant MIRRORS, reversing ring orientation. The
16//!   extruders assume counter-clockwise outer rings, so mirrored rings are
17//!   reversed on lowering rather than handed over inside-out.
18//!
19//! # Composite
20//!
21//! `Profile::Composite` is the `IfcCompositeProfileDef` case: several
22//! profiles acting as ONE section. Members may touch or overlap, so they are
23//! unioned rather than concatenated -- concatenating overlapping members
24//! would double-count the shared area and produce self-intersecting walls.
25//!
26//! The exact extrusion and revolution use [`composite_regions`], which unions
27//! the members' exact contours -- arcs and member holes included -- over one
28//! [`ArcArrangement`] (ADR 0072) and returns every connected piece. Disjoint
29//! members become separate solids in one `ExactBRep` (#111).
30//! [`lower_composite`] is the older polygon union, kept for its callers; it
31//! refuses arcs, member holes and disjoint members.
32
33use axiolid_contracts::{GeomError, GeomResult, Operation};
34use axiolid_core::{Frame2, Interval, Point2, Scalar, Tolerance, Transform2, Vec2};
35use axiolid_curve::{Circle2, Curve2, Line2};
36use axiolid_overlay::{union_soup, ArcArrangement, ArcRing, Ring};
37use axiolid_profile::{
38    CircleProfile, Contour, ContourProfile, Profile, ProfileSegment, RectangleProfile,
39};
40
41use crate::BACKEND_ID;
42
43fn unsupported(input: &'static str) -> GeomError {
44    GeomError::UnsupportedInput {
45        backend: BACKEND_ID,
46        operation: Operation::Sweep,
47        input,
48    }
49}
50
51/// Whether the linear part of `transform` is a similarity.
52///
53/// `M^T M` is a positive multiple of the identity exactly when `M` scales all
54/// directions equally and preserves angles. Checking the determinant is NOT
55/// enough: a shear has determinant 1 and still distorts circles into
56/// ellipses.
57fn conformal_scale(transform: &Transform2, tolerance: Tolerance) -> Option<Scalar> {
58    let x = transform.matrix2.x_axis;
59    let y = transform.matrix2.y_axis;
60    let xx = x.dot(x);
61    let yy = y.dot(y);
62    let xy = x.dot(y);
63    if xx <= 0.0 || yy <= 0.0 {
64        return None;
65    }
66    // Scale-relative comparison: the entries are squared lengths, so the
67    // slack must be relative to them rather than an absolute epsilon.
68    let slack = tolerance.linear() * xx.max(yy).max(1.0);
69    if xy.abs() > slack || (xx - yy).abs() > slack {
70        return None;
71    }
72    Some(xx.sqrt())
73}
74
75/// Apply an affine transform to a planar point.
76fn apply(transform: &Transform2, point: Point2) -> Point2 {
77    transform.transform_point2(point)
78}
79
80/// Push an affine transform down onto a basis profile.
81///
82/// Returns a profile that needs no further transformation. Nesting is handled
83/// by composing transforms rather than recursing on already-lowered output,
84/// so a deeply derived profile costs one pass.
85pub fn lower_derived(
86    basis: &Profile,
87    transform: &Transform2,
88    tolerance: Tolerance,
89) -> GeomResult<Profile> {
90    match basis {
91        // Composing first keeps the recursion one level deep regardless of
92        // how many times a profile was re-placed.
93        Profile::Derived {
94            basis: inner,
95            transform: inner_transform,
96        } => lower_derived(inner, &(*transform * *inner_transform), tolerance),
97
98        Profile::Rectangle(rectangle) => lower_rectangle(rectangle, transform, tolerance),
99        Profile::Circle(circle) => lower_circle(circle, transform, tolerance),
100        Profile::Contour(contour) => Ok(Profile::Contour(ContourProfile {
101            outer: lower_contour(&contour.outer, transform, tolerance)?,
102            holes: contour
103                .holes
104                .iter()
105                .map(|hole| lower_contour(hole, transform, tolerance))
106                .collect::<GeomResult<Vec<_>>>()?,
107        })),
108        Profile::Composite(members) => Ok(Profile::Composite(
109            members
110                .iter()
111                .map(|member| lower_derived(member, transform, tolerance))
112                .collect::<GeomResult<Vec<_>>>()?,
113        )),
114        _ => Err(unsupported("derived profile over an unsupported basis")),
115    }
116}
117
118/// A transformed rectangle is a rectangle only under a similarity.
119///
120/// Under a general affine map it becomes a parallelogram, which the rectangle
121/// profile cannot express, so it is lowered to an explicit contour instead of
122/// being refused: the contour path handles arbitrary polygons exactly.
123fn lower_rectangle(
124    rectangle: &RectangleProfile,
125    transform: &Transform2,
126    tolerance: Tolerance,
127) -> GeomResult<Profile> {
128    if rectangle.thickness.is_some()
129        || rectangle.outer_radius.is_some()
130        || rectangle.inner_radius.is_some()
131    {
132        // Rounded corners and a hollow core both lower to an exact contour.
133        // Transforming THAT keeps each arc an arc under a similarity and
134        // refuses the ellipse a shear would make, through the same check
135        // every other contour goes through.
136        let contour = crate::section_lower::rectangle_contour(rectangle)?;
137        return lower_derived(&Profile::Contour(contour), transform, tolerance);
138    }
139    if !rectangle.x.is_finite()
140        || !rectangle.y.is_finite()
141        || rectangle.x <= 0.0
142        || rectangle.y <= 0.0
143    {
144        return Err(GeomError::InvalidInput(format!(
145            "derived rectangle extents must be positive and finite, got {} x {}",
146            rectangle.x, rectangle.y
147        )));
148    }
149
150    let (half_x, half_y) = (rectangle.x / 2.0, rectangle.y / 2.0);
151    let corners = [
152        Point2::new(-half_x, -half_y),
153        Point2::new(half_x, -half_y),
154        Point2::new(half_x, half_y),
155        Point2::new(-half_x, half_y),
156    ];
157    let mut moved: Vec<Point2> = corners.iter().map(|p| apply(transform, *p)).collect();
158    orient_counter_clockwise(&mut moved);
159    let _ = tolerance;
160    Ok(Profile::Contour(ContourProfile {
161        outer: polygon_contour(&moved),
162        holes: Vec::new(),
163    }))
164}
165
166/// A transformed circle is a circle only under a similarity.
167fn lower_circle(
168    circle: &CircleProfile,
169    transform: &Transform2,
170    tolerance: Tolerance,
171) -> GeomResult<Profile> {
172    let Some(scale) = conformal_scale(transform, tolerance) else {
173        // A shear or non-uniform scale makes this an ellipse. Relabelling it
174        // a circle would report a wrong radius; building it as a polygon
175        // would silently discard exactness.
176        return Err(unsupported(
177            "derived circle under a non-conformal transform is an ellipse",
178        ));
179    };
180    if transform.translation != Vec2::ZERO {
181        // A circle profile sits at the origin, so an off-origin circle has
182        // nowhere to record its centre -- but its exact contour does: four
183        // quarter arcs about the moved centre (#111).
184        let contour = crate::section_lower::circle_contour(circle)?;
185        return Ok(Profile::Contour(ContourProfile {
186            outer: lower_contour(&contour.outer, transform, tolerance)?,
187            holes: contour
188                .holes
189                .iter()
190                .map(|hole| lower_contour(hole, transform, tolerance))
191                .collect::<GeomResult<Vec<_>>>()?,
192        }));
193    }
194    Ok(Profile::Circle(CircleProfile {
195        radius: circle.radius * scale,
196        thickness: circle.thickness.map(|value| value * scale),
197    }))
198}
199
200/// Transform every segment of a contour, preserving each segment's kind.
201///
202/// Segments are transformed as GEOMETRY, not sampled: a line stays a line and
203/// a circular arc stays a circular arc. Taking only the segment endpoints
204/// would quietly turn every arc into a chord, which is the same silent
205/// approximation the contour path already refuses.
206fn lower_contour(
207    contour: &Contour,
208    transform: &Transform2,
209    tolerance: Tolerance,
210) -> GeomResult<Contour> {
211    let mirrors = transform.matrix2.determinant() < 0.0;
212    let mut segments = Vec::with_capacity(contour.segments.len());
213    for segment in &contour.segments {
214        segments.push(lower_segment(segment, transform, tolerance, mirrors)?);
215    }
216    if mirrors {
217        // A mirror reverses the boundary's sense, so the ring would come out
218        // clockwise and the solid inside-out. Reversing the segment order and
219        // each segment's own sense restores the original orientation.
220        segments.reverse();
221    }
222    Ok(Contour::new(segments))
223}
224
225fn lower_segment(
226    segment: &ProfileSegment,
227    transform: &Transform2,
228    tolerance: Tolerance,
229    mirrors: bool,
230) -> GeomResult<ProfileSegment> {
231    let same_sense = segment.same_sense != mirrors;
232    match &segment.curve {
233        Curve2::Line(line) => Ok(ProfileSegment {
234            curve: Curve2::Line(Line2 {
235                origin: apply(transform, line.origin),
236                // A direction is a vector, so it takes the linear part only;
237                // translating it would move the line off its own points.
238                direction: transform.matrix2 * line.direction,
239            }),
240            domain: segment.domain,
241            same_sense,
242        }),
243        Curve2::Circle(circle) => {
244            let Some(scale) = conformal_scale(transform, tolerance) else {
245                return Err(unsupported(
246                    "derived contour arc under a non-conformal transform is elliptical",
247                ));
248            };
249            // The scale goes into the radius once: the frame's axes stay
250            // unit, as a circle's are, or the arc would be scaled twice.
251            Ok(ProfileSegment {
252                curve: Curve2::Circle(Circle2 {
253                    frame: Frame2 {
254                        origin: apply(transform, circle.frame.origin),
255                        x: transform.matrix2 * circle.frame.x / scale,
256                        y: transform.matrix2 * circle.frame.y / scale,
257                    },
258                    radius: circle.radius * scale,
259                }),
260                domain: segment.domain,
261                same_sense,
262            })
263        }
264        _ => Err(unsupported(
265            "derived contour over a segment kind that cannot be transformed exactly",
266        )),
267    }
268}
269
270/// Build a closed contour of straight segments through `points`.
271fn polygon_contour(points: &[Point2]) -> Contour {
272    let count = points.len();
273    let segments = (0..count)
274        .map(|index| {
275            let from = points[index];
276            let to = points[(index + 1) % count];
277            ProfileSegment {
278                curve: Curve2::Line(Line2 {
279                    origin: from,
280                    direction: to - from,
281                }),
282                domain: Interval::UNIT,
283                same_sense: true,
284            }
285        })
286        .collect();
287    Contour::new(segments)
288}
289
290/// Signed area; positive when the ring runs counter-clockwise.
291fn signed_area(points: &[Point2]) -> Scalar {
292    let count = points.len();
293    (0..count)
294        .map(|index| {
295            let a = points[index];
296            let b = points[(index + 1) % count];
297            a.perp_dot(b)
298        })
299        .sum::<Scalar>()
300        / 2.0
301}
302
303/// Reverse a ring in place if it runs clockwise.
304///
305/// The extruders assume counter-clockwise outer rings; a mirrored transform
306/// produces clockwise ones, which would build the solid inside-out.
307fn orient_counter_clockwise(points: &mut [Point2]) {
308    if signed_area(points) < 0.0 {
309        points.reverse();
310    }
311}
312
313/// Union the members of a composite profile into one section.
314///
315/// Members of an `IfcCompositeProfileDef` act as a single section and may
316/// touch or overlap, so they are UNIONED. Concatenating them as rings would
317/// double-count shared area and emit self-intersecting walls.
318///
319/// Measured behaviour of the union: overlapping members merge to one polygon,
320/// edge-touching members merge to one, four bars arranged as a picture frame
321/// merge to one polygon carrying one hole, and disjoint members stay two
322/// polygons -- which is the case this refuses.
323pub fn lower_composite(
324    members: &[Profile],
325    tolerance: Tolerance,
326) -> GeomResult<(Vec<Point2>, Vec<Vec<Point2>>)> {
327    if members.is_empty() {
328        return Err(GeomError::InvalidInput(
329            "a composite profile needs at least one member".to_owned(),
330        ));
331    }
332
333    let mut rings = Vec::with_capacity(members.len());
334    for member in members {
335        rings.push(Ring {
336            points: member_ring(member, tolerance)?,
337        });
338    }
339
340    let polygons = union_soup(&rings, tolerance).map_err(|error| {
341        GeomError::InvalidInput(format!("composite member union failed: {error:?}"))
342    })?;
343
344    match polygons.len() {
345        0 => Err(GeomError::Degenerate(
346            "composite profile members union to nothing".to_owned(),
347        )),
348        1 => {
349            let polygon = &polygons[0];
350            Ok((
351                polygon.outer.points.clone(),
352                polygon.holes.iter().map(|h| h.points.clone()).collect(),
353            ))
354        }
355        // Disjoint members are two separate bodies. `Solid` holds one outer
356        // shell plus voids, so there is nowhere honest to put the second.
357        _ => Err(unsupported("composite profile whose members are disjoint")),
358    }
359}
360
361/// One connected piece of a composite section.
362#[derive(Debug, Clone, PartialEq)]
363pub struct CompositeRegion {
364    /// Outer boundary, counter-clockwise.
365    pub outer: ArcRing,
366    /// Openings, clockwise.
367    pub holes: Vec<ArcRing>,
368}
369
370/// Union a composite's members exactly and return every connected piece.
371///
372/// Each member lowers to an exact contour (rectangles of every kind,
373/// circles, sections, contours with arcs and holes, derived profiles), and
374/// the union is taken over one [`ArcArrangement`] of all their rings: a
375/// point belongs to the section when some member's outer ring contains it
376/// and none of that member's holes does. Arcs stay arcs; nothing is sampled.
377///
378/// # Errors
379///
380/// A member that does not lower to a contour (an ellipse, a nested
381/// composite), an invalid ring, or members that union to nothing.
382pub fn composite_regions(
383    members: &[Profile],
384    tolerance: Tolerance,
385) -> GeomResult<Vec<CompositeRegion>> {
386    if members.is_empty() {
387        return Err(GeomError::InvalidInput(
388            "a composite profile needs at least one member".to_owned(),
389        ));
390    }
391    let mut rings = Vec::new();
392    // Per member: its outer ring's index, and its holes' indices.
393    let mut layout = Vec::with_capacity(members.len());
394    for member in members {
395        if matches!(member, Profile::Composite(_)) {
396            return Err(unsupported("composite profile nested in a composite"));
397        }
398        let contour = crate::extrude_exact::profile_to_contour(member, tolerance)?;
399        let outer = rings.len();
400        rings.push(crate::contour_lower::contour_to_arc_ring(
401            &contour.outer,
402            tolerance,
403        )?);
404        let mut holes = Vec::with_capacity(contour.holes.len());
405        for hole in &contour.holes {
406            holes.push(rings.len());
407            rings.push(crate::contour_lower::contour_to_arc_ring(hole, tolerance)?);
408        }
409        layout.push((outer, holes));
410    }
411    weld_ring_vertices(&mut rings, tolerance);
412    let arrangement = ArcArrangement::new(&rings, tolerance).map_err(|error| {
413        GeomError::InvalidInput(format!("composite member rings are invalid: {error:?}"))
414    })?;
415    let regions = arrangement
416        .regions(|inside| {
417            layout
418                .iter()
419                .any(|(outer, holes)| inside[*outer] && !holes.iter().any(|hole| inside[*hole]))
420        })
421        .map_err(|error| {
422            GeomError::InvalidInput(format!("composite member union failed: {error:?}"))
423        })?;
424    if regions.is_empty() {
425        return Err(GeomError::Degenerate(
426            "composite profile members union to nothing".to_owned(),
427        ));
428    }
429    Ok(regions
430        .iter()
431        .map(|region| CompositeRegion {
432            outer: without_zero_pieces(arrangement.ring(&region.outer)),
433            holes: region
434                .holes
435                .iter()
436                .map(|hole| without_zero_pieces(arrangement.ring(hole)))
437                .collect(),
438        })
439        .collect())
440}
441
442/// Drop every piece whose ends round to the same point.
443///
444/// Where an arc is tangent to a line at a shared corner (a disc capping a
445/// bar of its own diameter), the arrangement can split the line at the
446/// tangency and round the split onto the corner, linking a piece of zero
447/// length. It carries no boundary, so removing the vertex that starts it
448/// leaves the same ring.
449fn without_zero_pieces(mut ring: ArcRing) -> ArcRing {
450    let mut index = 0;
451    while ring.vertices.len() > 1 && index < ring.vertices.len() {
452        let next = (index + 1) % ring.vertices.len();
453        if ring.vertices[index].point == ring.vertices[next].point {
454            ring.vertices.remove(index);
455        } else {
456            index += 1;
457        }
458    }
459    ring
460}
461
462/// Move every ring vertex within the linear tolerance of one seen before it
463/// onto that earlier vertex.
464///
465/// Members authored to meet do not always meet in `f64`: a circle's
466/// quarter-arc end at angle `pi/2` evaluates `cos` to `6e-17`, a few ulps
467/// off the rectangle corner it was placed on. The arrangement is exact, so
468/// it keeps both points and links a zero-length piece between them. Welding
469/// first makes the shared corner one vertex, as the author meant.
470fn weld_ring_vertices(rings: &mut [ArcRing], tolerance: Tolerance) {
471    let limit = tolerance.linear();
472    let mut seen: Vec<Point2> = Vec::new();
473    for ring in rings.iter_mut() {
474        for vertex in &mut ring.vertices {
475            match seen
476                .iter()
477                .find(|point| (**point - vertex.point).length() <= limit)
478            {
479                Some(point) => vertex.point = *point,
480                None => seen.push(vertex.point),
481            }
482        }
483    }
484}
485
486/// One composite member as a closed polygon ring.
487///
488/// Members are reduced to rings because the union operates on polygons. A
489/// member carrying arcs is refused rather than sampled: chord-sampling here
490/// would defeat the exactness the contour path was built to preserve.
491fn member_ring(member: &Profile, tolerance: Tolerance) -> GeomResult<Vec<Point2>> {
492    let lowered;
493    let resolved = match member {
494        Profile::Derived { basis, transform } => {
495            lowered = lower_derived(basis, transform, tolerance)?;
496            &lowered
497        }
498        other => other,
499    };
500
501    match resolved {
502        Profile::Rectangle(rectangle) => {
503            if rectangle.thickness.is_some()
504                || rectangle.outer_radius.is_some()
505                || rectangle.inner_radius.is_some()
506            {
507                return Err(unsupported(
508                    "composite member with a hollow or rounded rectangle",
509                ));
510            }
511            let (half_x, half_y) = (rectangle.x / 2.0, rectangle.y / 2.0);
512            Ok(vec![
513                Point2::new(-half_x, -half_y),
514                Point2::new(half_x, -half_y),
515                Point2::new(half_x, half_y),
516                Point2::new(-half_x, half_y),
517            ])
518        }
519        Profile::Contour(contour) => {
520            if !contour.holes.is_empty() {
521                return Err(unsupported("composite member carrying its own holes"));
522            }
523            let mut points: Vec<Point2> = Vec::with_capacity(contour.outer.segments.len());
524            for segment in &contour.outer.segments {
525                match &segment.curve {
526                    Curve2::Line(line) => {
527                        let t = if segment.same_sense {
528                            segment.domain.start
529                        } else {
530                            segment.domain.end
531                        };
532                        points.push(line.origin + line.direction * t);
533                    }
534                    _ => return Err(unsupported("composite member with a curved segment")),
535                }
536            }
537            orient_counter_clockwise(&mut points);
538            Ok(points)
539        }
540        _ => Err(unsupported(
541            "composite member of an unsupported profile kind",
542        )),
543    }
544}