ifc_lite_geometry/analytic/
mod.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 source descriptions for IFC geometry, in representation-local IFC units.
6//! A description does not claim to include later boolean operations.
7
8mod curve;
9mod frame;
10mod helpers;
11mod profile;
12mod profile_topology;
13mod topology_interval;
14pub use profile::{extract_analytic_profile, AnalyticProfile, AnalyticProfileLoop, ProfileLoopKind};
15mod extrusion;
16pub use extrusion::{extract_analytic_extrusion, AnalyticExtrusion};
17#[cfg(test)]
18mod tests;
19
20use crate::{Error, Result};
21use ifc_lite_core::{AttributeValue, DecodedEntity, EntityDecoder, IfcType};
22pub use frame::{AnalyticPoint, AnalyticSegmentFrame};
23
24/// An ordered directrix primitive. Angles are radians in the circle's local frame.
25#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
26#[serde(tag = "type", rename_all = "snake_case")]
27pub enum AnalyticCurveSegment {
28    Line {
29        start: [f64; 3],
30        end: [f64; 3],
31    },
32    Arc {
33        center: [f64; 3],
34        normal: [f64; 3],
35        x_axis: [f64; 3],
36        radius: f64,
37        start_angle: f64,
38        sweep_angle: f64,
39    },
40}
41
42impl AnalyticCurveSegment {
43    fn is_finite(&self) -> bool {
44        match self {
45            Self::Line { start, end } => start.iter().chain(end).all(|v| v.is_finite()),
46            Self::Arc {
47                center,
48                normal,
49                x_axis,
50                radius,
51                start_angle,
52                sweep_angle,
53            } => {
54                center
55                    .iter()
56                    .chain(normal)
57                    .chain(x_axis)
58                    .all(|v| v.is_finite())
59                    && radius.is_finite()
60                    && *radius > 0.0
61                    && start_angle.is_finite()
62                    && sweep_angle.is_finite()
63            }
64        }
65    }
66
67    /// Reverse traversal without changing the underlying circle frame.
68    pub fn reversed(&self) -> Self {
69        match *self {
70            Self::Line { start, end } => Self::Line {
71                start: end,
72                end: start,
73            },
74            Self::Arc {
75                center,
76                normal,
77                x_axis,
78                radius,
79                start_angle,
80                sweep_angle,
81            } => Self::Arc {
82                center,
83                normal,
84                x_axis,
85                radius,
86                start_angle: start_angle + sweep_angle,
87                sweep_angle: -sweep_angle,
88            },
89        }
90    }
91
92    /// Restrict this segment to a fraction of its parameter interval.
93    pub fn subsegment(&self, start: f64, end: f64) -> Self {
94        match *self {
95            Self::Line { start: a, end: b } => {
96                let p = |t: f64| std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t);
97                Self::Line {
98                    start: p(start),
99                    end: p(end),
100                }
101            }
102            Self::Arc {
103                center,
104                normal,
105                x_axis,
106                radius,
107                start_angle,
108                sweep_angle,
109            } => Self::Arc {
110                center,
111                normal,
112                x_axis,
113                radius,
114                start_angle: start_angle + sweep_angle * start,
115                sweep_angle: sweep_angle * (end - start),
116            },
117        }
118    }
119}
120
121/// Whether every part of the authored directrix has an exact supported form.
122#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
123#[serde(tag = "type", content = "reason", rename_all = "snake_case")]
124pub enum AnalyticStatus {
125    Complete,
126    Unsupported(String),
127}
128
129/// The authored `IfcSweptDiskSolid` before any enclosing boolean operation.
130#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
131pub struct AnalyticSweptDisk {
132    pub directrix_id: u32,
133    pub radius: f64,
134    pub inner_radius: Option<f64>,
135    pub segments: Vec<AnalyticCurveSegment>,
136    pub status: AnalyticStatus,
137}
138
139/// Extract an exact directrix where supported. Unsupported curve types return a
140/// status and no partial segments; malformed required attributes return an error.
141pub fn extract_swept_disk(
142    entity: &DecodedEntity,
143    decoder: &mut EntityDecoder,
144) -> Result<AnalyticSweptDisk> {
145    if entity.ifc_type != IfcType::IfcSweptDiskSolid {
146        return Err(Error::geometry("expected IfcSweptDiskSolid".to_string()));
147    }
148    let directrix_id = entity
149        .get_ref(0)
150        .ok_or_else(|| Error::geometry("IfcSweptDiskSolid missing Directrix".to_string()))?;
151    let radius = read_optional_numeric(entity.get(1), "Radius")?
152        .filter(|r| r.is_finite() && *r > 0.0)
153        .ok_or_else(|| Error::geometry("IfcSweptDiskSolid has invalid Radius".to_string()))?;
154    let inner_radius = read_optional_numeric(entity.get(2), "InnerRadius")?;
155    if inner_radius.is_some_and(|r| !r.is_finite() || r <= 0.0 || r >= radius) {
156        return Err(Error::geometry(
157            "IfcSweptDiskSolid has invalid InnerRadius".to_string(),
158        ));
159    }
160    let directrix = decoder
161        .resolve_ref(entity.get(0).unwrap())?
162        .ok_or_else(|| {
163            Error::geometry("IfcSweptDiskSolid Directrix cannot be resolved".to_string())
164        })?;
165    let mut walk = curve::CurveWalk::default();
166    let start = read_optional_numeric(entity.get(3), "StartParam")?;
167    let end = read_optional_numeric(entity.get(4), "EndParam")?;
168    let has_trim = start.is_some() || end.is_some();
169    let extracted = if directrix.ifc_type == IfcType::IfcLine && (start.is_none() || end.is_none())
170    {
171        None // An unbounded IfcLine has no authored finite swept extent.
172    } else if has_trim && directrix.ifc_type.is_subtype_of(IfcType::IfcCompositeCurve) {
173        walk.composite_trimmed(&directrix, decoder, start, end)?
174    } else {
175        walk.extract(&directrix, decoder)?
176    };
177    let (mut segments, mut status) = match extracted {
178        Some(segments) => (segments, AnalyticStatus::Complete),
179        None => (
180            Vec::new(),
181            AnalyticStatus::Unsupported(format!(
182                "unsupported directrix {}",
183                directrix.ifc_type.as_str()
184            )),
185        ),
186    };
187    if start.is_some_and(|v| !v.is_finite()) || end.is_some_and(|v| !v.is_finite()) {
188        return Err(Error::geometry(
189            "IfcSweptDiskSolid has non-finite trim parameter".to_string(),
190        ));
191    }
192    if matches!(status, AnalyticStatus::Complete) && has_trim {
193        match directrix.ifc_type {
194            IfcType::IfcPolyline => {
195                segments = helpers::trim_indexed_segments(&segments, start, end)?;
196            }
197            IfcType::IfcCompositeCurve | IfcType::IfcCompositeCurveOnSurface => {}
198            IfcType::IfcTrimmedCurve => {
199                if !trimmed_line_solid_bounds_redundant(&directrix, &segments, start, end, decoder)?
200                {
201                    status = AnalyticStatus::Unsupported(
202                        "solid-level trim on trimmed directrix is not redundant".to_string(),
203                    );
204                }
205            }
206            IfcType::IfcCircle => {
207                let scale = decoder.plane_angle_to_radians();
208                if !scale.is_finite() || scale <= 0.0 {
209                    return Err(Error::geometry(
210                        "invalid plane angle unit scale".to_string(),
211                    ));
212                }
213                let begin = start.unwrap_or(0.0) * scale;
214                let finish = end.unwrap_or(std::f64::consts::TAU / scale) * scale;
215                let mut sweep = finish - begin;
216                if sweep < 0.0 {
217                    sweep += std::f64::consts::TAU;
218                }
219                if !begin.is_finite()
220                    || !sweep.is_finite()
221                    || sweep <= 0.0
222                    || sweep > std::f64::consts::TAU + 1e-9
223                {
224                    status = AnalyticStatus::Unsupported(
225                        "invalid circular directrix parameter range".to_string(),
226                    );
227                } else if let Some(AnalyticCurveSegment::Arc {
228                    start_angle,
229                    sweep_angle,
230                    ..
231                }) = segments.first_mut()
232                {
233                    *start_angle = begin;
234                    *sweep_angle = sweep;
235                }
236            }
237            IfcType::IfcLine => {
238                // An IfcLine parameter is distance along its IfcVector, not a
239                // normalized segment fraction. The extractor's bare line spans 0..1.
240                let line = segments
241                    .first()
242                    .cloned()
243                    .ok_or_else(|| Error::geometry("empty line".to_string()))?;
244                segments = vec![line.subsegment(start.unwrap_or(0.0), end.unwrap_or(1.0))];
245            }
246            _ => {
247                status = AnalyticStatus::Unsupported(
248                    "solid-level trim for this directrix is unsupported".to_string(),
249                )
250            }
251        }
252    }
253    if matches!(status, AnalyticStatus::Complete)
254        && segments.iter().any(|segment| !segment.is_finite())
255    {
256        return Err(Error::geometry(
257            "analytic directrix has non-finite geometry".to_string(),
258        ));
259    }
260    if matches!(status, AnalyticStatus::Complete) && segments.is_empty() {
261        status = AnalyticStatus::Unsupported("directrix has no segments".to_string());
262    }
263    if !matches!(status, AnalyticStatus::Complete) {
264        segments.clear();
265    }
266    Ok(AnalyticSweptDisk {
267        directrix_id,
268        radius,
269        inner_radius,
270        segments,
271        status,
272    })
273}
274
275/// The mesh path ignores solid-level bounds on an `IfcTrimmedCurve`. Keep a
276/// complete analytic description only when those bounds select its existing
277/// trimmed line endpoints, so the description agrees with the rendered solid.
278fn trimmed_line_solid_bounds_redundant(
279    directrix: &DecodedEntity,
280    segments: &[AnalyticCurveSegment],
281    start: Option<f64>,
282    end: Option<f64>,
283    decoder: &mut EntityDecoder,
284) -> Result<bool> {
285    let (Some(start), Some(end), [AnalyticCurveSegment::Line { start: a, end: b }]) =
286        (start, end, segments)
287    else {
288        return Ok(false);
289    };
290    let Some(basis_attr) = directrix.get(0) else {
291        return Ok(false);
292    };
293    let Some(basis) = decoder.resolve_ref(basis_attr)? else {
294        return Ok(false);
295    };
296    if basis.ifc_type != IfcType::IfcLine {
297        return Ok(false);
298    }
299    let (_, direction) = helpers::line_basis(&basis, decoder)?;
300    let magnitude = (direction.iter().map(|v| v * v).sum::<f64>()).sqrt();
301    if magnitude <= 1e-12 {
302        return Ok(false);
303    }
304    let span = a
305        .iter()
306        .zip(b)
307        .map(|(x, y)| (y - x) * (y - x))
308        .sum::<f64>()
309        .sqrt()
310        / magnitude;
311    let close = |x: f64, y: f64| (x - y).abs() <= 1e-8 * x.abs().max(y.abs()).max(1.0);
312    // IfcTrimmedCurve's own domain starts at zero, independent of where its
313    // Trim1 lies on the basis curve. Solid bounds are relative to that domain.
314    Ok(close(start, 0.0) && close(end, span))
315}
316
317/// STEP typed numeric wrappers are decoded by `AttributeValue::as_float` in
318/// `ifc-lite-core`. Distinguish an omitted optional value from a present value
319/// with malformed contents, which must not silently widen a directrix.
320fn read_optional_numeric(value: Option<&AttributeValue>, field: &str) -> Result<Option<f64>> {
321    match value {
322        None | Some(AttributeValue::Null | AttributeValue::Derived) => Ok(None),
323        Some(value) => value
324            .as_float()
325            .map(Some)
326            .ok_or_else(|| Error::geometry(format!("IfcSweptDiskSolid has invalid {field}"))),
327    }
328}
329
330/// The IFC parameter span of a bounded curve used as an `IfcCompositeCurve`
331/// `ParentCurve`: `N - 1` for an `IfcPolyline` of `N` points, the trimmed
332/// length in `IfcVector` units for a trimmed `IfcLine`, and the swept angle in
333/// the project's plane-angle unit for a trimmed `IfcCircle`. A composite
334/// curve's parameter is the running sum of these spans, so this is the one
335/// place both the analytic description and the swept-solid mesher read it
336/// from (#5566). `None` means the parent's parametrisation is not supported.
337pub(crate) fn composite_parent_parameter_span(
338    parent: &DecodedEntity,
339    decoder: &mut EntityDecoder,
340) -> Result<Option<f64>> {
341    let mut walk = curve::CurveWalk::default();
342    let Some(pieces) = walk.extract(parent, decoder)? else {
343        return Ok(None);
344    };
345    let Some(spans) = helpers::parent_piece_spans(parent, &pieces, decoder)? else {
346        return Ok(None);
347    };
348    let span: f64 = spans.iter().sum();
349    Ok(span.is_finite().then_some(span))
350}