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}