ifc_lite_processing/analytic_export/
quantities.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//! Nominal circular-section quantities from exact world-space directrices.
6
7use super::{check_swept_disk, DirectrixMetrics, SweptDiskCheckOptions, SweptDiskFindingCode, SweptDiskOccurrence};
8use ifc_lite_geometry::analytic::{AnalyticExtrusion, AnalyticStatus, ProfileLoopKind};
9use serde::Serialize;
10
11/// Derived measurements in square and cubic metres for one uncut source solid.
12/// The volume and lateral areas are section × centreline estimates; they do
13/// not account for self-overlap, mitred joins, or later boolean operations.
14#[derive(Debug, Clone, PartialEq, Serialize)]
15#[non_exhaustive]
16pub struct SweptDiskNominalQuantities {
17    /// Circular or annular material cross-section, in m².
18    pub cross_section_area: f64,
19    /// Cross-section area × exact directrix length, in m³.
20    pub nominal_volume: f64,
21    /// Outer circumference × exact directrix length, in m².
22    pub outer_lateral_area: f64,
23    /// Inner circumference × exact directrix length, in m² when hollow.
24    pub inner_lateral_area: Option<f64>,
25}
26
27impl SweptDiskNominalQuantities {
28    pub(super) fn from_occurrence(
29        disk: &SweptDiskOccurrence,
30        metrics: &DirectrixMetrics,
31    ) -> Option<Self> {
32        // A CSG operand is a source description, not the product's visible
33        // geometry. Its quantities must not look like final product quantities.
34        if disk.source_modified || !disk.radius.is_finite() || disk.radius <= 0.0 {
35            return None;
36        }
37        let inner = disk.inner_radius.unwrap_or(0.0);
38        if !inner.is_finite()
39            || disk.inner_radius.is_some_and(|radius| radius <= 0.0)
40            || inner >= disk.radius
41        {
42            return None;
43        }
44        let length = metrics.total_length;
45        if !length.is_finite() || length <= 0.0 {
46            return None;
47        }
48        // Reuse the source-geometry check: gaps, degenerate segments and arcs
49        // tighter than the disk cannot represent one nominal swept solid.
50        // Sharp tangent changes remain valid mitred joins.
51        let checks = check_swept_disk(disk, &SweptDiskCheckOptions::default()).ok()?;
52        if checks.skipped_reason.is_some() || checks.findings.iter().any(|finding|
53            finding.code != SweptDiskFindingCode::TangentDiscontinuity
54        ) {
55            return None;
56        }
57        // Difference of squares in factored form avoids cancellation for a
58        // thin annulus whose InnerRadius is close to Radius.
59        let cross_section_area =
60            std::f64::consts::PI * (disk.radius - inner) * (disk.radius + inner);
61        let nominal_volume = cross_section_area * length;
62        let outer_lateral_area = std::f64::consts::TAU * disk.radius * length;
63        let inner_lateral_area = disk
64            .inner_radius
65            .map(|radius| std::f64::consts::TAU * radius * length);
66        if !cross_section_area.is_finite()
67            || !nominal_volume.is_finite()
68            || !outer_lateral_area.is_finite()
69            || inner_lateral_area.is_some_and(|area| !area.is_finite())
70        {
71            return None;
72        }
73        Some(Self {
74            cross_section_area,
75            nominal_volume,
76            outer_lateral_area,
77            inner_lateral_area,
78        })
79    }
80}
81
82/// Nominal source-solid quantities in raw IFC file-length units. A product's
83/// occurrence transform, openings, and other boolean operations are excluded.
84#[derive(Debug, Clone, PartialEq, Serialize)]
85#[non_exhaustive]
86pub struct ExtrusionNominalQuantities {
87    /// Material area in squared IFC file-length units, with inner loops removed.
88    pub profile_area: f64,
89    /// Perpendicular distance between profile planes in IFC file-length units.
90    pub projected_height: f64,
91    /// Profile area × projected height in cubed IFC file-length units.
92    pub nominal_volume: f64,
93}
94
95/// Derive source quantities only when the profile and extrusion are complete.
96/// The projected height accounts for an oblique `ExtrudedDirection`: IFC Depth
97/// measures along that unit direction, rather than along the profile normal.
98pub fn extrusion_nominal_quantities(
99    extrusion: &AnalyticExtrusion,
100) -> Option<ExtrusionNominalQuantities> {
101    if extrusion.status != AnalyticStatus::Complete {
102        return None;
103    }
104    let profile = extrusion.profile.as_ref()?;
105    if profile.status != AnalyticStatus::Complete || profile.profile_type.as_deref() != Some("AREA")
106    {
107        return None;
108    }
109    let mut profile_area = 0.0;
110    let mut outer_count = 0;
111    for boundary in &profile.loops {
112        let area = boundary.signed_area.abs();
113        if !area.is_finite() || area <= 0.0 {
114            return None;
115        }
116        match boundary.kind {
117            ProfileLoopKind::Outer => {
118                outer_count += 1;
119                profile_area += area;
120            }
121            ProfileLoopKind::Inner => profile_area -= area,
122        }
123    }
124    if outer_count != 1 || !profile_area.is_finite() || profile_area <= 0.0 {
125        return None;
126    }
127    let depth = extrusion.depth?;
128    let axis = extrusion.axis_unit_vector?;
129    let projected_height = depth * axis[2].abs();
130    let nominal_volume = profile_area * projected_height;
131    if !projected_height.is_finite() || projected_height <= 0.0 || !nominal_volume.is_finite() {
132        return None;
133    }
134    Some(ExtrusionNominalQuantities {
135        profile_area,
136        projected_height,
137        nominal_volume,
138    })
139}