ifc_lite_core/
limits.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//! Bounds on walks over file-supplied entity references, plus the
6//! large-coordinate threshold every crate gates the RTC re-base on
7//! ([`LARGE_COORD_THRESHOLD_METERS`]).
8//!
9//! A cap bounds one path's LENGTH only. A walk that can revisit also needs a
10//! cycle guard, and one that fans out over a DAG also needs a work budget; the
11//! three are not interchangeable and choosing wrongly fails silently in both
12//! directions. The rule for picking, and the scope rule for the visited set,
13//! are in AGENTS.md under "Bounding walks over file-supplied references" —
14//! there rather than here, because whoever needs it is adding a NEW walk
15//! somewhere else and will not open this file.
16
17/// Maximum `IfcMappedItem` → `IfcRepresentationMap` → `MappedRepresentation.Items`
18/// nesting any walk in this workspace will follow.
19///
20/// Shared because three crates walk the SAME chain and their bounds must agree:
21/// `ifc_lite_processing::element`, `ifc_lite_geometry::router::processing` and
22/// `ifc-lite-wasm`'s styling colour resolver.
23///
24/// Disagreement fails SILENTLY, which is why it is worth making structural. A
25/// mid-review revision of #2864 held 16 against the router's 32: an element
26/// whose chain was 17 to 32 links long would have rendered its geometry and
27/// quietly lost its authored colour. That was caught before merge and never
28/// shipped — the point is that nothing except a reviewer's attention was
29/// stopping it.
30///
31/// `ifc_lite_processing::symbolic::item_walk::MAX_ITEM_DEPTH` also walks this
32/// chain and asserts equality with this constant, but deliberately keeps its
33/// own name: it charges `depth + 1` for `IfcGeometricSet` elements and
34/// `IfcCompositeCurve` segments as well, so an equal number does not mean equal
35/// reach. It is never more permissive than this one.
36///
37/// A walk over this chain also needs a cycle guard; the cap alone is not
38/// sufficient (see the module docs above for which kind).
39pub const MAX_MAPPED_ITEM_DEPTH: u32 = 32;
40
41/// Maximum `IfcLocalPlacement.PlacementRelTo` chain any walk in this workspace
42/// will follow. A chain longer than this composes only its first
43/// `MAX_PLACEMENT_DEPTH + 1` placements and the rest is dropped — silently, on
44/// every site that uses it.
45///
46/// Shared because two walks follow the SAME attribute of the SAME entity and
47/// their bounds must agree: `ifc_lite_geometry::router::transforms` (the mesh
48/// path) and `ifc_lite_geometry::profile_extractor` (the 2D drawing path).
49/// They disagreed — 32 against 100 — and because both exceed-branches return
50/// the IDENTITY rather than an error, an element on a 33-to-101-link chain was
51/// drawn in two different places by the two paths with nothing reported. #2873
52///
53/// ## Why 100 and not 32
54///
55/// The 32 carried the rationale "keep low for WASM — each frame uses ~2KB+ of
56/// stack". The wasm bundles are linked with `-zstack-size=8388608`
57/// (`.cargo/config.toml` and both extra bundles in `scripts/build-wasm.sh`), so
58/// the budget is 8 MiB and the cap's own worst case is ~0.1% of it; native
59/// hosts give the geometry pool 256 MiB (`rust/ffi`, `rust/python`). Measured
60/// rather than assumed: see the frame figures in #2873. `PlacementRelTo` is a
61/// single reference, so this walk has fan-out 1 and costs O(depth) — the
62/// breadth blow-up that makes a depth cap the wrong instrument elsewhere (see
63/// AGENTS.md) does not apply here.
64///
65/// The two candidates are not symmetric. Equalising UP lets the mesh path
66/// compose chains it currently flattens; equalising DOWN would make the 2D path
67/// start flattening chains it composes correctly today, silently. Across the
68/// 111 fixtures in `tests/models` that contain placements the deepest chain is
69/// 7 links, so neither value binds on any file in the corpus and the choice is
70/// decided entirely by which failure is worse on a file that does exceed it.
71///
72/// This bounds one path's LENGTH only. A walk over a chain that can revisit
73/// also needs a cycle guard; see the module docs above.
74pub const MAX_PLACEMENT_DEPTH: usize = 100;
75
76/// The large-coordinate threshold (metres): a world coordinate whose magnitude
77/// exceeds this needs RTC re-basing before it is cast to f32, or the model
78/// renders with vertex jitter.
79///
80/// Shared because every crate that decides "is this model far from the
81/// origin" must draw the line in the same place: a copy that drifts fails
82/// silently (one side re-bases, the other does not, and the two frames differ
83/// by the whole offset). It lives in `ifc_lite_core` because core's own
84/// placement-bounds scan needs it and cannot import from above;
85/// `ifc_lite_geometry` re-exports it under the same name.
86///
87/// Prefer [`coord_is_large`] to comparing against this directly, so the
88/// comparison (strictly greater, any axis, absolute value) also has one home.
89///
90/// ## Why 1 km and not 10 km (#4934)
91///
92/// The line is precision-derived, not distance-derived: it is the point past
93/// which the f32 store the mesh path casts into loses more than a BIM
94/// tolerance. f32 has a 24-bit mantissa, so its ULP near `x` is
95/// `2^(exponent(x) - 23)`; at 1 km that is ~0.061 mm, and at the old 10 km
96/// line it is ~0.5-1 mm — comparable to the flush-face tolerances authoring
97/// tools actually hit, so two faces meant to be coincident round to
98/// *different* f32 lattice nodes and z-fight (#4934's report: 6.3 km survey
99/// coordinates, ~0.26 mm ULP, visible speckle over every join). 1 km keeps
100/// worst-case quantization at a fraction of a millimetre while matching the
101/// existing `NEAR_ORIGIN` band in `rust/geometry/src/router/rtc_offset.rs`,
102/// so a model just inside the line and the detector's own "already near the
103/// origin" judgment agree.
104pub const LARGE_COORD_THRESHOLD_METERS: f64 = 1000.0;
105
106/// True when any component of `point` (metres) lies strictly beyond
107/// [`LARGE_COORD_THRESHOLD_METERS`] from the origin.
108#[inline]
109pub fn coord_is_large(point: (f64, f64, f64)) -> bool {
110    point.0.abs() > LARGE_COORD_THRESHOLD_METERS
111        || point.1.abs() > LARGE_COORD_THRESHOLD_METERS
112        || point.2.abs() > LARGE_COORD_THRESHOLD_METERS
113}
114
115/// What an RTC detector concluded about a model's coordinates. The anchor and
116/// the decision travel together because the decision is not a property of the
117/// anchor: the placement-bounds scan decides on the bbox CORNERS and answers
118/// with the bbox CENTRE, which can be inside the threshold (8.5 km centre, 15 km
119/// corner) or even the origin, while the coordinates still need re-basing.
120#[derive(Debug, Clone, Copy, PartialEq)]
121pub enum RtcVerdict {
122    /// Every coordinate judged is inside the threshold: nothing to subtract.
123    Small,
124    /// Some coordinate is past the threshold: subtract `anchor` before the f32
125    /// cast, whatever the anchor's own magnitude.
126    Large { anchor: (f64, f64, f64) },
127}
128
129impl RtcVerdict {
130    /// For a detector whose anchor IS its evidence (the job sampler's median).
131    #[inline]
132    pub fn of_anchor(anchor: (f64, f64, f64)) -> Self {
133        if coord_is_large(anchor) {
134            Self::Large { anchor }
135        } else {
136            Self::Small
137        }
138    }
139
140    /// The translation to subtract: the anchor when large, zero when small.
141    #[inline]
142    pub fn offset(self) -> (f64, f64, f64) {
143        match self {
144            Self::Small => (0.0, 0.0, 0.0),
145            Self::Large { anchor } => anchor,
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    /// #4611, lowered by #4934. The documented contract: 1 km, strictly
155    /// greater, any axis, sign ignored. Pinned here because every former copy
156    /// compared the same way and a predicate that flipped to `>=` or dropped
157    /// an axis would move the needs-shift decision for every consumer at
158    /// once.
159    #[test]
160    fn large_coord_threshold_is_the_documented_1_km() {
161        assert_eq!(LARGE_COORD_THRESHOLD_METERS, 1_000.0);
162        assert!(!coord_is_large((1_000.0, 0.0, 0.0)));
163        assert!(!coord_is_large((-1_000.0, -1_000.0, -1_000.0)));
164        assert!(coord_is_large((1_000.5, 0.0, 0.0)));
165        assert!(coord_is_large((0.0, -1_000.5, 0.0)));
166        assert!(coord_is_large((0.0, 0.0, 1_000.5)));
167    }
168
169    /// The documented contract. This pins the VALUE; the constant being shared
170    /// is what pins agreement between the three walks, and that is now
171    /// structural rather than asserted.
172    #[test]
173    fn mapped_item_depth_is_the_documented_32() {
174        assert_eq!(MAX_MAPPED_ITEM_DEPTH, 32);
175    }
176
177    /// The documented contract, pinned separately from the two sites that
178    /// import it — those assert they ARE this constant, which cannot also pin
179    /// its value.
180    #[test]
181    fn placement_depth_is_the_documented_100() {
182        assert_eq!(MAX_PLACEMENT_DEPTH, 100);
183    }
184}