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            Ok(ProfileSegment {
250                curve: Curve2::Circle(Circle2 {
251                    frame: Frame2 {
252                        origin: apply(transform, circle.frame.origin),
253                        x: transform.matrix2 * circle.frame.x,
254                        y: transform.matrix2 * circle.frame.y,
255                    },
256                    radius: circle.radius * scale,
257                }),
258                domain: segment.domain,
259                same_sense,
260            })
261        }
262        _ => Err(unsupported(
263            "derived contour over a segment kind that cannot be transformed exactly",
264        )),
265    }
266}
267
268/// Build a closed contour of straight segments through `points`.
269fn polygon_contour(points: &[Point2]) -> Contour {
270    let count = points.len();
271    let segments = (0..count)
272        .map(|index| {
273            let from = points[index];
274            let to = points[(index + 1) % count];
275            ProfileSegment {
276                curve: Curve2::Line(Line2 {
277                    origin: from,
278                    direction: to - from,
279                }),
280                domain: Interval::UNIT,
281                same_sense: true,
282            }
283        })
284        .collect();
285    Contour::new(segments)
286}
287
288/// Signed area; positive when the ring runs counter-clockwise.
289fn signed_area(points: &[Point2]) -> Scalar {
290    let count = points.len();
291    (0..count)
292        .map(|index| {
293            let a = points[index];
294            let b = points[(index + 1) % count];
295            a.perp_dot(b)
296        })
297        .sum::<Scalar>()
298        / 2.0
299}
300
301/// Reverse a ring in place if it runs clockwise.
302///
303/// The extruders assume counter-clockwise outer rings; a mirrored transform
304/// produces clockwise ones, which would build the solid inside-out.
305fn orient_counter_clockwise(points: &mut [Point2]) {
306    if signed_area(points) < 0.0 {
307        points.reverse();
308    }
309}
310
311/// Union the members of a composite profile into one section.
312///
313/// Members of an `IfcCompositeProfileDef` act as a single section and may
314/// touch or overlap, so they are UNIONED. Concatenating them as rings would
315/// double-count shared area and emit self-intersecting walls.
316///
317/// Measured behaviour of the union: overlapping members merge to one polygon,
318/// edge-touching members merge to one, four bars arranged as a picture frame
319/// merge to one polygon carrying one hole, and disjoint members stay two
320/// polygons -- which is the case this refuses.
321pub fn lower_composite(
322    members: &[Profile],
323    tolerance: Tolerance,
324) -> GeomResult<(Vec<Point2>, Vec<Vec<Point2>>)> {
325    if members.is_empty() {
326        return Err(GeomError::InvalidInput(
327            "a composite profile needs at least one member".to_owned(),
328        ));
329    }
330
331    let mut rings = Vec::with_capacity(members.len());
332    for member in members {
333        rings.push(Ring {
334            points: member_ring(member, tolerance)?,
335        });
336    }
337
338    let polygons = union_soup(&rings, tolerance).map_err(|error| {
339        GeomError::InvalidInput(format!("composite member union failed: {error:?}"))
340    })?;
341
342    match polygons.len() {
343        0 => Err(GeomError::Degenerate(
344            "composite profile members union to nothing".to_owned(),
345        )),
346        1 => {
347            let polygon = &polygons[0];
348            Ok((
349                polygon.outer.points.clone(),
350                polygon.holes.iter().map(|h| h.points.clone()).collect(),
351            ))
352        }
353        // Disjoint members are two separate bodies. `Solid` holds one outer
354        // shell plus voids, so there is nowhere honest to put the second.
355        _ => Err(unsupported("composite profile whose members are disjoint")),
356    }
357}
358
359/// One connected piece of a composite section.
360#[derive(Debug, Clone, PartialEq)]
361pub struct CompositeRegion {
362    /// Outer boundary, counter-clockwise.
363    pub outer: ArcRing,
364    /// Openings, clockwise.
365    pub holes: Vec<ArcRing>,
366}
367
368/// Union a composite's members exactly and return every connected piece.
369///
370/// Each member lowers to an exact contour (rectangles of every kind,
371/// circles, sections, contours with arcs and holes, derived profiles), and
372/// the union is taken over one [`ArcArrangement`] of all their rings: a
373/// point belongs to the section when some member's outer ring contains it
374/// and none of that member's holes does. Arcs stay arcs; nothing is sampled.
375///
376/// # Errors
377///
378/// A member that does not lower to a contour (an ellipse, a nested
379/// composite), an invalid ring, or members that union to nothing.
380pub fn composite_regions(
381    members: &[Profile],
382    tolerance: Tolerance,
383) -> GeomResult<Vec<CompositeRegion>> {
384    if members.is_empty() {
385        return Err(GeomError::InvalidInput(
386            "a composite profile needs at least one member".to_owned(),
387        ));
388    }
389    let mut rings = Vec::new();
390    // Per member: its outer ring's index, and its holes' indices.
391    let mut layout = Vec::with_capacity(members.len());
392    for member in members {
393        if matches!(member, Profile::Composite(_)) {
394            return Err(unsupported("composite profile nested in a composite"));
395        }
396        let contour = crate::extrude_exact::profile_to_contour(member, tolerance)?;
397        let outer = rings.len();
398        rings.push(crate::contour_lower::contour_to_arc_ring(
399            &contour.outer,
400            tolerance,
401        )?);
402        let mut holes = Vec::with_capacity(contour.holes.len());
403        for hole in &contour.holes {
404            holes.push(rings.len());
405            rings.push(crate::contour_lower::contour_to_arc_ring(hole, tolerance)?);
406        }
407        layout.push((outer, holes));
408    }
409    weld_ring_vertices(&mut rings, tolerance);
410    let arrangement = ArcArrangement::new(&rings, tolerance).map_err(|error| {
411        GeomError::InvalidInput(format!("composite member rings are invalid: {error:?}"))
412    })?;
413    let regions = arrangement
414        .regions(|inside| {
415            layout
416                .iter()
417                .any(|(outer, holes)| inside[*outer] && !holes.iter().any(|hole| inside[*hole]))
418        })
419        .map_err(|error| {
420            GeomError::InvalidInput(format!("composite member union failed: {error:?}"))
421        })?;
422    if regions.is_empty() {
423        return Err(GeomError::Degenerate(
424            "composite profile members union to nothing".to_owned(),
425        ));
426    }
427    Ok(regions
428        .iter()
429        .map(|region| CompositeRegion {
430            outer: without_zero_pieces(arrangement.ring(&region.outer)),
431            holes: region
432                .holes
433                .iter()
434                .map(|hole| without_zero_pieces(arrangement.ring(hole)))
435                .collect(),
436        })
437        .collect())
438}
439
440/// Drop every piece whose ends round to the same point.
441///
442/// Where an arc is tangent to a line at a shared corner (a disc capping a
443/// bar of its own diameter), the arrangement can split the line at the
444/// tangency and round the split onto the corner, linking a piece of zero
445/// length. It carries no boundary, so removing the vertex that starts it
446/// leaves the same ring.
447fn without_zero_pieces(mut ring: ArcRing) -> ArcRing {
448    let mut index = 0;
449    while ring.vertices.len() > 1 && index < ring.vertices.len() {
450        let next = (index + 1) % ring.vertices.len();
451        if ring.vertices[index].point == ring.vertices[next].point {
452            ring.vertices.remove(index);
453        } else {
454            index += 1;
455        }
456    }
457    ring
458}
459
460/// Move every ring vertex within the linear tolerance of one seen before it
461/// onto that earlier vertex.
462///
463/// Members authored to meet do not always meet in `f64`: a circle's
464/// quarter-arc end at angle `pi/2` evaluates `cos` to `6e-17`, a few ulps
465/// off the rectangle corner it was placed on. The arrangement is exact, so
466/// it keeps both points and links a zero-length piece between them. Welding
467/// first makes the shared corner one vertex, as the author meant.
468fn weld_ring_vertices(rings: &mut [ArcRing], tolerance: Tolerance) {
469    let limit = tolerance.linear();
470    let mut seen: Vec<Point2> = Vec::new();
471    for ring in rings.iter_mut() {
472        for vertex in &mut ring.vertices {
473            match seen
474                .iter()
475                .find(|point| (**point - vertex.point).length() <= limit)
476            {
477                Some(point) => vertex.point = *point,
478                None => seen.push(vertex.point),
479            }
480        }
481    }
482}
483
484/// One composite member as a closed polygon ring.
485///
486/// Members are reduced to rings because the union operates on polygons. A
487/// member carrying arcs is refused rather than sampled: chord-sampling here
488/// would defeat the exactness the contour path was built to preserve.
489fn member_ring(member: &Profile, tolerance: Tolerance) -> GeomResult<Vec<Point2>> {
490    let lowered;
491    let resolved = match member {
492        Profile::Derived { basis, transform } => {
493            lowered = lower_derived(basis, transform, tolerance)?;
494            &lowered
495        }
496        other => other,
497    };
498
499    match resolved {
500        Profile::Rectangle(rectangle) => {
501            if rectangle.thickness.is_some()
502                || rectangle.outer_radius.is_some()
503                || rectangle.inner_radius.is_some()
504            {
505                return Err(unsupported(
506                    "composite member with a hollow or rounded rectangle",
507                ));
508            }
509            let (half_x, half_y) = (rectangle.x / 2.0, rectangle.y / 2.0);
510            Ok(vec![
511                Point2::new(-half_x, -half_y),
512                Point2::new(half_x, -half_y),
513                Point2::new(half_x, half_y),
514                Point2::new(-half_x, half_y),
515            ])
516        }
517        Profile::Contour(contour) => {
518            if !contour.holes.is_empty() {
519                return Err(unsupported("composite member carrying its own holes"));
520            }
521            let mut points: Vec<Point2> = Vec::with_capacity(contour.outer.segments.len());
522            for segment in &contour.outer.segments {
523                match &segment.curve {
524                    Curve2::Line(line) => {
525                        let t = if segment.same_sense {
526                            segment.domain.start
527                        } else {
528                            segment.domain.end
529                        };
530                        points.push(line.origin + line.direction * t);
531                    }
532                    _ => return Err(unsupported("composite member with a curved segment")),
533                }
534            }
535            orient_counter_clockwise(&mut points);
536            Ok(points)
537        }
538        _ => Err(unsupported(
539            "composite member of an unsupported profile kind",
540        )),
541    }
542}