ifc_lite_geometry/analytic/
profile.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Exact profile boundaries before extrusion, in IFC file-length units.
6
7use super::{curve::CurveWalk, AnalyticCurveSegment, AnalyticPoint, AnalyticStatus};
8use crate::transform::parse_axis2_placement_2d;
9use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
10use serde::Serialize;
11use std::f64::consts::TAU;
12
13const MAX_LOOPS: usize = 100_000;
14// Application baseline in raw IFC length units. IFC does not prescribe a
15// default Precision. Declared values may tighten joins or widen clearance;
16// keep closure separate so a broad context cannot close an open boundary.
17const DEFAULT_TOPOLOGY_CLEARANCE: f64 = 1e-9;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum ProfileLoopKind { Outer, Inner }
22
23/// One ordered, closed source boundary. Signed area preserves the authored
24/// traversal direction: positive is counterclockwise in the profile XY plane.
25#[derive(Debug, Clone, PartialEq, Serialize)]
26pub struct AnalyticProfileLoop {
27    pub kind: ProfileLoopKind,
28    pub segments: Vec<AnalyticCurveSegment>,
29    pub signed_area: f64,
30    /// Exact boundary length in raw IFC file units.
31    pub perimeter: f64,
32}
33
34/// `IfcProfileDef` geometry before `IfcExtrudedAreaSolid.Position` and mapping.
35/// Its `Position` belongs to the profile and is returned separately, in raw
36/// IFC file units; no approximation from drawing or meshing is used.
37#[derive(Debug, Clone, PartialEq, Serialize)]
38pub struct AnalyticProfile {
39    pub profile_id: u32,
40    pub ifc_type_name: String,
41    #[serde(rename = "ProfileType")]
42    pub profile_type: Option<String>,
43    #[serde(rename = "Position")]
44    pub position_id: Option<u32>,
45    pub profile_position: Option<[f64; 16]>,
46    pub loops: Vec<AnalyticProfileLoop>,
47    pub status: AnalyticStatus,
48}
49
50/// Read supported exact profile loops, preserving an unsupported reason on
51/// the source record rather than publishing a partial or tessellated contour.
52pub fn extract_analytic_profile(profile: &DecodedEntity, decoder: &mut EntityDecoder) -> AnalyticProfile {
53    let mut result = AnalyticProfile {
54        profile_id: profile.id, ifc_type_name: profile.ifc_type.name().to_string(),
55        profile_type: profile.get(0).and_then(|value| value.as_enum()).map(str::to_owned),
56        position_id: None, profile_position: None,
57        loops: Vec::new(), status: AnalyticStatus::Complete,
58    };
59    let parsed = parse_profile(profile, decoder, &mut result);
60    let validated = parsed.and_then(|()| {
61        if matches!(profile.ifc_type,
62            IfcType::IfcArbitraryClosedProfileDef | IfcType::IfcArbitraryProfileDefWithVoids)
63        {
64            let declared = decoder.geometric_context_precision_range()
65                .map_err(|error| format!("profile topology Precision: {error}"))?;
66            let clearance = declared.map_or(DEFAULT_TOPOLOGY_CLEARANCE, |(_, max)| {
67                max.max(DEFAULT_TOPOLOGY_CLEARANCE)
68            });
69            let join_precision = declared.map_or(DEFAULT_TOPOLOGY_CLEARANCE, |(min, _)| {
70                min.min(DEFAULT_TOPOLOGY_CLEARANCE)
71            });
72            super::profile_topology::validate(&result.loops, clearance, join_precision)
73        } else {
74            Ok(())
75        }
76    });
77    if let Err(reason) = validated {
78        result.loops.clear();
79        result.status = AnalyticStatus::Unsupported(reason);
80    }
81    result
82}
83
84fn parse_profile(
85    profile: &DecodedEntity, decoder: &mut EntityDecoder, result: &mut AnalyticProfile,
86) -> Result<(), String> {
87    result.loops = match profile.ifc_type {
88        IfcType::IfcRectangleProfileDef => {
89            parse_profile_position(profile, decoder, result)?;
90            let x = positive(profile.get_float(3), "XDim")? / 2.0;
91            let y = positive(profile.get_float(4), "YDim")? / 2.0;
92            let points = [[-x, -y, 0.0], [x, -y, 0.0], [x, y, 0.0], [-x, y, 0.0]];
93            vec![loop_from_segments(ProfileLoopKind::Outer, (0..4).map(|i| {
94                AnalyticCurveSegment::Line { start: points[i], end: points[(i + 1) % 4] }
95            }).collect())?]
96        }
97        IfcType::IfcCircleProfileDef => {
98            parse_profile_position(profile, decoder, result)?;
99            let radius = positive(profile.get_float(3), "Radius")?;
100            vec![loop_from_segments(ProfileLoopKind::Outer, vec![AnalyticCurveSegment::Arc {
101                center: [0.0; 3], normal: [0.0, 0.0, 1.0], x_axis: [1.0, 0.0, 0.0],
102                radius, start_angle: 0.0, sweep_angle: TAU,
103            }])?]
104        }
105        IfcType::IfcArbitraryClosedProfileDef | IfcType::IfcArbitraryProfileDefWithVoids => {
106            arbitrary_loops(profile, decoder)?
107        }
108        _ => return Err(format!("unsupported profile {}", profile.ifc_type.name())),
109    };
110    Ok(())
111}
112
113fn positive(value: Option<f64>, attribute: &str) -> Result<f64, String> {
114    value.filter(|value| value.is_finite() && *value > 0.0)
115        .ok_or_else(|| format!("invalid or missing {attribute}"))
116}
117
118fn parse_profile_position(
119    profile: &DecodedEntity, decoder: &mut EntityDecoder, result: &mut AnalyticProfile,
120) -> Result<(), String> {
121    let Some(position) = profile.get(2).filter(|value| !value.is_null()) else { return Ok(()) };
122    let id = position.as_entity_ref().ok_or("invalid profile Position reference")?;
123    result.position_id = Some(id);
124    let entity = decoder.decode_by_id(id).map_err(|error| format!("Position #{id}: {error}"))?;
125    if entity.ifc_type != IfcType::IfcAxis2Placement2D {
126        return Err(format!("Position #{id} is not IfcAxis2Placement2D"));
127    }
128    let matrix = parse_axis2_placement_2d(&entity, decoder)
129        .map_err(|error| format!("Position #{id}: {error}"))?;
130    if matrix.iter().any(|value| !value.is_finite()) {
131        return Err(format!("Position #{id} has non-finite coordinates"));
132    }
133    let mut values = [0.0; 16];
134    values.copy_from_slice(matrix.as_slice());
135    result.profile_position = Some(values);
136    Ok(())
137}
138
139fn arbitrary_loops(
140    profile: &DecodedEntity, decoder: &mut EntityDecoder,
141) -> Result<Vec<AnalyticProfileLoop>, String> {
142    let outer = profile.get_ref(2).ok_or("missing OuterCurve")?;
143    let mut loops = vec![curve_loop(outer, ProfileLoopKind::Outer, decoder)?];
144    let mut emitted_segments = loops[0].segments.len();
145    if profile.ifc_type == IfcType::IfcArbitraryProfileDefWithVoids {
146        let references = profile.get(3).and_then(|value| value.as_list())
147            .ok_or("missing or malformed InnerCurves")?;
148        if references.len() > MAX_LOOPS { return Err("InnerCurves exceed work budget".into()); }
149        for reference in references {
150            if emitted_segments >= MAX_LOOPS {
151                return Err("profile segments exceed aggregate work budget".into());
152            }
153            let id = reference.as_entity_ref().ok_or("invalid InnerCurves reference")?;
154            let loop_data = curve_loop(id, ProfileLoopKind::Inner, decoder)?;
155            if loop_data.segments.len() > MAX_LOOPS.saturating_sub(emitted_segments) {
156                return Err("profile segments exceed aggregate work budget".into());
157            }
158            emitted_segments += loop_data.segments.len();
159            loops.push(loop_data);
160        }
161    }
162    Ok(loops)
163}
164
165fn curve_loop(
166    id: u32, kind: ProfileLoopKind, decoder: &mut EntityDecoder,
167) -> Result<AnalyticProfileLoop, String> {
168    let curve = decoder.decode_by_id(id).map_err(|error| format!("curve #{id}: {error}"))?;
169    let segments = CurveWalk::default().extract(&curve, decoder)
170        .map_err(|error| format!("curve #{id}: {error}"))?
171        .ok_or_else(|| format!("curve #{id} is not exact line/arc geometry"))?;
172    loop_from_segments(kind, segments).map_err(|reason| format!("curve #{id}: {reason}"))
173}
174
175fn loop_from_segments(
176    kind: ProfileLoopKind, segments: Vec<AnalyticCurveSegment>,
177) -> Result<AnalyticProfileLoop, String> {
178    if segments.is_empty() || segments.len() > MAX_LOOPS {
179        return Err("profile loop is empty or exceeds work budget".into());
180    }
181    let frames: Vec<_> = segments.iter().map(|segment| segment.endpoint_frame()
182        .ok_or("profile loop has invalid segment geometry")).collect::<Result<_, _>>()?;
183    let origin = frames[0].start;
184    let mut area = 0.0;
185    let mut perimeter = 0.0;
186    for (index, segment) in segments.iter().enumerate() {
187        let frame = &frames[index];
188        let next = &frames[(index + 1) % frames.len()];
189        let tolerance = 1e-9_f64.max(relative_extent(frame.start, frame.end) * 1e-9);
190        if frame.end.distance_to(next.start).is_none_or(|gap| gap > tolerance) {
191            return Err(format!("profile loop has a gap after segment {index}"));
192        }
193        for point in [frame.start, frame.end] {
194            if (point.origin[2] + point.offset[2]).abs() > 1e-9 {
195                return Err("profile loop is not in its XY plane".into());
196            }
197        }
198        area += segment_area(segment, origin)?;
199        perimeter += segment.length().ok_or("profile segment has invalid length")?;
200    }
201    if !area.is_finite() || area == 0.0 || !perimeter.is_finite() || perimeter <= 0.0 {
202        return Err("profile loop has zero or invalid enclosed area".into());
203    }
204    Ok(AnalyticProfileLoop { kind, segments, signed_area: area, perimeter })
205}
206
207fn relative_extent(a: AnalyticPoint, b: AnalyticPoint) -> f64 {
208    a.distance_to(b).unwrap_or(f64::INFINITY)
209}
210
211fn segment_area(segment: &AnalyticCurveSegment, origin: AnalyticPoint) -> Result<f64, String> {
212    let frame = segment.endpoint_frame().ok_or("invalid segment frame")?;
213    let relative = |point: AnalyticPoint| -> [f64; 3] {
214        std::array::from_fn(|axis| (point.origin[axis] - origin.origin[axis])
215            + (point.offset[axis] - origin.offset[axis]))
216    };
217    let a = relative(frame.start);
218    let b = relative(frame.end);
219    match segment {
220        AnalyticCurveSegment::Line { .. } => Ok((a[0] * b[1] - b[0] * a[1]) * 0.5),
221        AnalyticCurveSegment::Arc { center, normal, x_axis, radius, sweep_angle, .. } => {
222            if normal[0].abs() > 1e-9 || normal[1].abs() > 1e-9
223                || (normal[2].abs() - 1.0).abs() > 1e-9 || x_axis[2].abs() > 1e-9 {
224                return Err("profile arc is not planar in XY".into());
225            }
226            let c = [center[0] - origin.origin[0] - origin.offset[0],
227                center[1] - origin.origin[1] - origin.offset[1]];
228            Ok((c[0] * (b[1] - a[1]) - c[1] * (b[0] - a[0])
229                + normal[2] * radius * radius * sweep_angle) * 0.5)
230        }
231    }
232}
233
234#[cfg(test)]
235#[path = "profile_tests.rs"]
236mod tests;