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