Skip to main content

ifc_geometry/resource/
functions.rs

1//! EXPRESS function coverage for the three IFC geometry resources.
2//!
3//! `Scaffolded` assigns an implementation owner but does not claim the
4//! function is executable yet.
5
6/// Current implementation state of one normative EXPRESS function.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum FunctionStatus {
9    /// Rust's format-neutral math primitive already provides the operation.
10    NativePrimitive,
11    /// An owner module and test target exist; semantics remain to implement.
12    Scaffolded,
13    /// The owner module executes the function's semantics, and a test
14    /// exercises them. Distinct from `Scaffolded` so the registry cannot
15    /// keep understating what the crate does.
16    Implemented,
17    /// The function is EXPRESS plumbing with no Rust counterpart: it exists
18    /// only to re-index a LIST into an ARRAY with a different lower bound.
19    /// A `Vec` already is that array, so implementing it would add a
20    /// conversion that converts nothing. The `owner` names the module that
21    /// consumes the underlying attribute directly.
22    NotApplicable,
23}
24
25/// Auditable owner for one EXPRESS function.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub struct FunctionSupport {
28    /// Case-preserving EXPRESS function name.
29    pub name: &'static str,
30    /// Rust module or primitive responsible for the semantics.
31    pub owner: &'static str,
32    /// Honest implementation state.
33    pub status: FunctionStatus,
34}
35
36const NATIVE: FunctionStatus = FunctionStatus::NativePrimitive;
37/// No row currently uses this: every function is implemented, native, or
38/// not applicable. It stays because `Scaffolded` is the honest default for
39/// the next function added, and deleting it would push a future author
40/// toward over-claiming instead.
41#[allow(dead_code)]
42const SCAFFOLDED: FunctionStatus = FunctionStatus::Scaffolded;
43const NOT_APPLICABLE: FunctionStatus = FunctionStatus::NotApplicable;
44const IMPLEMENTED: FunctionStatus = FunctionStatus::Implemented;
45
46/// All 28 normative functions in deterministic schema order.
47pub const FUNCTIONS: &[FunctionSupport] = &[
48    FunctionSupport {
49        name: "IfcAssociatedSurface",
50        owner: "surface::basis",
51        status: IMPLEMENTED,
52    },
53    FunctionSupport {
54        name: "IfcBaseAxis",
55        owner: "resource::axes",
56        status: IMPLEMENTED,
57    },
58    FunctionSupport {
59        name: "IfcBuild2Axes",
60        owner: "resource::functions",
61        status: IMPLEMENTED,
62    },
63    FunctionSupport {
64        name: "IfcBuildAxes",
65        owner: "transform",
66        status: IMPLEMENTED,
67    },
68    FunctionSupport {
69        name: "IfcConsecutiveSegments",
70        owner: "rules::express",
71        status: IMPLEMENTED,
72    },
73    FunctionSupport {
74        name: "IfcConstraintsParamBSpline",
75        owner: "rules::express",
76        status: IMPLEMENTED,
77    },
78    FunctionSupport {
79        name: "IfcCrossProduct",
80        owner: "axiolid_core::Vec3::cross",
81        status: NATIVE,
82    },
83    FunctionSupport {
84        name: "IfcCurveDim",
85        owner: "rules::dimension",
86        status: IMPLEMENTED,
87    },
88    FunctionSupport {
89        name: "IfcCurveWeightsPositive",
90        owner: "rules::bspline",
91        status: IMPLEMENTED,
92    },
93    FunctionSupport {
94        name: "IfcDotProduct",
95        owner: "axiolid_core::Vec3::dot",
96        status: NATIVE,
97    },
98    FunctionSupport {
99        name: "IfcFirstProjAxis",
100        owner: "transform",
101        status: IMPLEMENTED,
102    },
103    FunctionSupport {
104        name: "IfcGetBasisSurface",
105        owner: "surface::basis",
106        status: IMPLEMENTED,
107    },
108    FunctionSupport {
109        name: "IfcListToArray",
110        owner: "curve::bspline",
111        status: NOT_APPLICABLE,
112    },
113    FunctionSupport {
114        name: "IfcMakeArrayOfArray",
115        owner: "surface::bspline",
116        status: NOT_APPLICABLE,
117    },
118    FunctionSupport {
119        name: "IfcNormalise",
120        owner: "axiolid_core::Vec3::normalize",
121        status: NATIVE,
122    },
123    FunctionSupport {
124        name: "IfcOrthogonalComplement",
125        owner: "resource::functions",
126        status: IMPLEMENTED,
127    },
128    FunctionSupport {
129        name: "IfcSameAxis2Placement",
130        owner: "resource::functions",
131        status: IMPLEMENTED,
132    },
133    FunctionSupport {
134        name: "IfcSameCartesianPoint",
135        owner: "resource::functions",
136        status: IMPLEMENTED,
137    },
138    FunctionSupport {
139        name: "IfcSameDirection",
140        owner: "resource::functions",
141        status: IMPLEMENTED,
142    },
143    FunctionSupport {
144        name: "IfcSameValue",
145        owner: "resource::functions",
146        status: IMPLEMENTED,
147    },
148    FunctionSupport {
149        name: "IfcScalarTimesVector",
150        owner: "axiolid_core::Vec3::mul",
151        status: NATIVE,
152    },
153    FunctionSupport {
154        name: "IfcSecondProjAxis",
155        owner: "transform",
156        status: IMPLEMENTED,
157    },
158    FunctionSupport {
159        name: "IfcSurfaceWeightsPositive",
160        owner: "rules::bspline",
161        status: IMPLEMENTED,
162    },
163    FunctionSupport {
164        name: "IfcVectorDifference",
165        owner: "axiolid_core::Vec3::sub",
166        status: NATIVE,
167    },
168    FunctionSupport {
169        name: "IfcVectorSum",
170        owner: "axiolid_core::Vec3::add",
171        status: NATIVE,
172    },
173    FunctionSupport {
174        name: "IfcPointListDim",
175        owner: "resource::functions",
176        status: IMPLEMENTED,
177    },
178    FunctionSupport {
179        name: "IfcTaperedSweptAreaProfiles",
180        owner: "rules::surface",
181        status: IMPLEMENTED,
182    },
183    FunctionSupport {
184        name: "IfcCorrectLocalPlacement",
185        owner: "rules::placement",
186        status: IMPLEMENTED,
187    },
188];
189
190/// The schema's default comparison tolerance, from `IfcSameValue`.
191const DEFAULT_EPSILON: f64 = 0.000_001;
192
193/// `IfcSameValue`: are two reals equal within `epsilon`?
194///
195/// The schema writes this as `(v1 + eps > v2) AND (v1 < v2 + eps)`, which is
196/// an *exclusive* band: values exactly `epsilon` apart are NOT equal. A
197/// symmetric `(a - b).abs() <= eps` would accept that boundary, so the
198/// strict comparison is kept deliberately.
199///
200/// `None` selects the schema default of 1e-6, matching EXPRESS `NVL`.
201#[must_use]
202pub fn same_value(a: f64, b: f64, epsilon: Option<f64>) -> bool {
203    let eps = epsilon.unwrap_or(DEFAULT_EPSILON);
204    a + eps > b && a < b + eps
205}
206
207/// `IfcSameCartesianPoint` / `IfcSameDirection` over raw component lists.
208///
209/// Both schema functions have identical bodies apart from the attribute they
210/// read, so they share one implementation. A missing third component reads
211/// as zero: the schema initialises `cp1z`/`dir1z` to 0 and only overwrites
212/// when `SIZEOF > 2`, which makes a 2D point equal to its 3D lift.
213#[must_use]
214pub fn same_components(a: &[f64], b: &[f64], epsilon: Option<f64>) -> bool {
215    let at = |v: &[f64], i: usize| v.get(i).copied().unwrap_or(0.0);
216    (0..3).all(|i| same_value(at(a, i), at(b, i), epsilon))
217}
218
219/// `IfcPointListDim`: the dimensionality a point list declares by its type.
220///
221/// `None` for anything that is not one of the two concrete list types, which
222/// is the schema's `?` return rather than a guess.
223#[must_use]
224pub fn point_list_dim(type_name: &str) -> Option<usize> {
225    match type_name.to_ascii_uppercase().as_str() {
226        "IFCCARTESIANPOINTLIST2D" => Some(2),
227        "IFCCARTESIANPOINTLIST3D" => Some(3),
228        _ => None,
229    }
230}
231
232/// `IfcOrthogonalComplement`: a 2D direction turned a quarter turn CCW.
233///
234/// The schema returns `?` for anything not 2D; that is the `None` here.
235#[must_use]
236pub fn orthogonal_complement(v: &[f64]) -> Option<[f64; 2]> {
237    match v {
238        [x, y] => Some([-*y, *x]),
239        _ => None,
240    }
241}
242
243/// `IfcBuild2Axes`: a 2D frame from an optional reference direction.
244///
245/// Defaults to +X when the direction is absent or degenerate, per the
246/// schema's `NVL(IfcNormalise(RefDirection), IfcDirection([1.0, 0.0]))`.
247#[must_use]
248pub fn build_2_axes(ref_direction: Option<[f64; 2]>) -> ([f64; 2], [f64; 2]) {
249    let x = ref_direction
250        .and_then(|d| {
251            let len = d[0].hypot(d[1]);
252            (len.is_finite() && len > 0.0).then(|| [d[0] / len, d[1] / len])
253        })
254        .unwrap_or([1.0, 0.0]);
255    (x, [-x[1], x[0]])
256}
257
258/// `IfcSameAxis2Placement`: do two placements describe the same frame?
259///
260/// Compares the two derived axes `P[1]`/`P[2]` and the location, each with
261/// [`same_components`].
262///
263/// # The schema compares `ap1.Location` with itself
264///
265/// The published function reads
266/// `IfcSameCartesianPoint(ap1.Location, ap1.Location, Epsilon)` -- `ap1`
267/// twice, so the location is never actually tested and two placements that
268/// differ only in origin compare equal. That is a defect, not an intent:
269/// the surrounding function exists to decide frame identity, and the other
270/// two terms both compare `ap1` against `ap2`. This implementation compares
271/// `ap2.Location`, and the test below pins that difference so it stays a
272/// deliberate divergence rather than drifting back.
273#[must_use]
274pub fn same_axis2_placement(
275    a_axes: (&[f64], &[f64]),
276    a_location: &[f64],
277    b_axes: (&[f64], &[f64]),
278    b_location: &[f64],
279    epsilon: Option<f64>,
280) -> bool {
281    same_components(a_axes.0, b_axes.0, epsilon)
282        && same_components(a_axes.1, b_axes.1, epsilon)
283        && same_components(a_location, b_location, epsilon)
284}
285
286/// Look up a function case-insensitively.
287pub fn function_support(name: &str) -> Option<&'static FunctionSupport> {
288    FUNCTIONS
289        .iter()
290        .find(|item| item.name.eq_ignore_ascii_case(name))
291}
292
293#[cfg(test)]
294mod function_tests {
295    use super::*;
296
297    /// The schema's band is exclusive: `(v1 + eps > v2) AND (v1 < v2 + eps)`.
298    /// Values exactly `epsilon` apart are NOT equal, which a symmetric
299    /// `abs() <= eps` would wrongly accept.
300    #[test]
301    fn same_value_boundary_is_exclusive() {
302        assert!(same_value(1.0, 1.0 + 0.5e-6, None));
303        assert!(!same_value(1.0, 1.0 + 1e-6, None), "exactly eps apart");
304        assert!(!same_value(1.0, 1.0 + 2e-6, None));
305        // Symmetric in its arguments.
306        assert!(!same_value(1.0 + 1e-6, 1.0, None));
307    }
308
309    /// `None` must select the schema default, not zero tolerance.
310    #[test]
311    fn same_value_default_epsilon_is_not_exact_equality() {
312        assert!(same_value(2.0, 2.0 + 1e-9, None));
313        assert!(!same_value(2.0, 2.0 + 1e-9, Some(1e-12)));
314    }
315
316    /// A missing third component reads as zero, so a 2D point equals its
317    /// 3D lift but not a genuinely different Z.
318    #[test]
319    fn missing_third_component_is_zero() {
320        assert!(same_components(&[1.0, 2.0], &[1.0, 2.0, 0.0], None));
321        assert!(!same_components(&[1.0, 2.0], &[1.0, 2.0, 5.0], None));
322    }
323
324    #[test]
325    fn point_list_dim_refuses_unknown_types() {
326        assert_eq!(point_list_dim("IfcCartesianPointList2D"), Some(2));
327        assert_eq!(point_list_dim("IFCCARTESIANPOINTLIST3D"), Some(3));
328        assert_eq!(point_list_dim("IFCCARTESIANPOINT"), None);
329    }
330
331    /// The complement is a quarter turn counter-clockwise, and the schema
332    /// returns `?` for anything that is not 2D.
333    #[test]
334    fn orthogonal_complement_is_2d_only() {
335        assert_eq!(orthogonal_complement(&[1.0, 0.0]), Some([0.0, 1.0]));
336        assert_eq!(orthogonal_complement(&[0.0, 1.0]), Some([-1.0, 0.0]));
337        assert_eq!(orthogonal_complement(&[1.0, 0.0, 0.0]), None);
338    }
339
340    /// A degenerate or absent reference direction falls back to +X.
341    #[test]
342    fn build_2_axes_defaults_and_normalises() {
343        assert_eq!(build_2_axes(None), ([1.0, 0.0], [0.0, 1.0]));
344        assert_eq!(build_2_axes(Some([0.0, 0.0])), ([1.0, 0.0], [0.0, 1.0]));
345        let (x, y) = build_2_axes(Some([5.0, 0.0]));
346        assert!((x[0] - 1.0).abs() < 1e-12, "normalised: {x:?}");
347        assert!((y[1] - 1.0).abs() < 1e-12, "perpendicular: {y:?}");
348    }
349
350    /// The schema's own body compares `ap1.Location` with `ap1.Location`,
351    /// so locations are never tested. We compare `ap2` instead; this test
352    /// pins that divergence.
353    #[test]
354    fn same_axis2_placement_actually_compares_locations() {
355        let x = [1.0, 0.0, 0.0];
356        let y = [0.0, 1.0, 0.0];
357        assert!(same_axis2_placement(
358            (&x, &y),
359            &[0.0, 0.0, 0.0],
360            (&x, &y),
361            &[0.0, 0.0, 0.0],
362            None
363        ));
364        // Same axes, different origin: equal under the schema as written,
365        // NOT equal here.
366        assert!(!same_axis2_placement(
367            (&x, &y),
368            &[0.0, 0.0, 0.0],
369            (&x, &y),
370            &[5.0, 0.0, 0.0],
371            None
372        ));
373        // A differing axis is caught too.
374        assert!(!same_axis2_placement(
375            (&x, &y),
376            &[0.0, 0.0, 0.0],
377            (&y, &x),
378            &[0.0, 0.0, 0.0],
379            None
380        ));
381    }
382}