Skip to main content

ifc_lite_geometry/
tessellation.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//! Consumer-configurable tessellation quality.
6//!
7//! Geometry tessellation detail (how many segments a curve, arc, cylinder or
8//! NURBS patch is approximated with) used to be hardcoded at every call site.
9//! [`TessellationQuality`] lets a consumer ask for coarser geometry (faster,
10//! fewer triangles) or finer geometry (less faceting on large curved models),
11//! and [`scale_segments`] is the single helper every tessellator routes its
12//! segment count through.
13//!
14//! The design pivots on one invariant: **`Medium` is the identity case.** Its
15//! [`TessellationQuality::density_factor`] is exactly `1.0`, and
16//! [`scale_segments`] short-circuits to the pre-existing `base.clamp(min, max)`
17//! at `Medium` so default output is byte-for-byte identical to before the enum
18//! existed.
19
20/// Detail level for geometry tessellation, selectable by consumers.
21///
22/// Levels map to a density multiplier ("angular deflection coefficient") via
23/// [`density_factor`](TessellationQuality::density_factor). `Medium` reproduces
24/// the engine's historical hardcoded behavior exactly and is the default.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
26pub enum TessellationQuality {
27    /// Coarsest — quarter density. Throughput / preview oriented.
28    Lowest,
29    /// Half density.
30    Low,
31    /// Engine default. Byte-for-byte identical to pre-enum behavior.
32    #[default]
33    Medium,
34    /// Double density.
35    High,
36    /// Finest — quadruple density. Minimizes faceting on curved models.
37    Highest,
38}
39
40impl TessellationQuality {
41    /// Stable lowercase label — the single string surface shared by the wasm
42    /// `setTessellationQuality` setter and the server's `tessellation_quality`
43    /// query parameter, so the two consumer-facing spellings cannot drift.
44    pub fn label(self) -> &'static str {
45        match self {
46            Self::Lowest => "lowest",
47            Self::Low => "low",
48            Self::Medium => "medium",
49            Self::High => "high",
50            Self::Highest => "highest",
51        }
52    }
53
54    /// Parse a consumer-facing label (case-insensitive). Inverse of
55    /// [`label`](Self::label); `None` for unknown spellings.
56    pub fn parse_label(s: &str) -> Option<Self> {
57        match s.to_ascii_lowercase().as_str() {
58            "lowest" => Some(Self::Lowest),
59            "low" => Some(Self::Low),
60            "medium" => Some(Self::Medium),
61            "high" => Some(Self::High),
62            "highest" => Some(Self::Highest),
63            _ => None,
64        }
65    }
66
67    /// Dense 0-4 index (Lowest..Highest). Used by the wasm bindings to store
68    /// the level in an atomic; total inverse of [`from_index`](Self::from_index).
69    pub fn to_index(self) -> u8 {
70        match self {
71            Self::Lowest => 0,
72            Self::Low => 1,
73            Self::Medium => 2,
74            Self::High => 3,
75            Self::Highest => 4,
76        }
77    }
78
79    /// Inverse of [`to_index`](Self::to_index); unknown values map to `Medium`.
80    pub fn from_index(idx: u8) -> Self {
81        match idx {
82            0 => Self::Lowest,
83            1 => Self::Low,
84            3 => Self::High,
85            4 => Self::Highest,
86            _ => Self::Medium,
87        }
88    }
89
90    /// Density multiplier applied to segment counts.
91    ///
92    /// `Medium == 1.0` is load-bearing: it guarantees [`scale_segments`] is the
93    /// identity at the default level, so existing golden output never moves.
94    #[inline]
95    pub fn density_factor(self) -> f64 {
96        match self {
97            Self::Lowest => 0.25,
98            Self::Low => 0.5,
99            Self::Medium => 1.0,
100            Self::High => 2.0,
101            Self::Highest => 4.0,
102        }
103    }
104
105    /// Segment count for a **profile-plane arc / fillet** (steel-section root
106    /// fillets, rounded-rectangle corners, trimmed conics and polycurve arcs in
107    /// arbitrary profiles), where `base` is the historical (often chord-adaptive)
108    /// count and `min` is the floor.
109    ///
110    /// Like [`circle_profile_segments`](Self::circle_profile_segments) these never
111    /// get *finer* above `Medium` (denser caps only add earcut bridge slivers),
112    /// but they coarsen proportionally below `Medium` so large channel/angle
113    /// fillets stop dominating the triangle budget on preview levels (issue #976).
114    #[inline]
115    pub fn profile_arc_segments(self, base: usize, min: usize) -> usize {
116        let n = match self {
117            Self::Lowest => (base as f64 * 0.25).round() as usize,
118            Self::Low => (base as f64 * 0.5).round() as usize,
119            Self::Medium | Self::High | Self::Highest => base,
120        };
121        n.max(min)
122    }
123
124    /// Fillet / edge radius for a **parametric steel-section corner**, given the
125    /// model's declared `radius`.
126    ///
127    /// Callers today are `IfcI/L/U/TShapeProfileDef` — the profiles whose builders
128    /// actually read a radius attribute. `IfcC/ZShapeProfileDef` build plain sharp
129    /// point lists and ignore their radius attributes entirely, so they never reach
130    /// here; route them through this helper if that ever changes.
131    ///
132    /// Below `Medium` the radius collapses to `0.0`, so the corner is emitted
133    /// sharp instead of as an arc: an I-section drops from ~28 outline vertices
134    /// to its 12 sharp ones, halving the cross-section triangles on slender
135    /// members where the fillet is a sub-pixel detail anyway (issue #1809).
136    /// `Medium` and above return the radius untouched, keeping default output
137    /// byte-identical — the same identity invariant as
138    /// [`profile_arc_segments`](Self::profile_arc_segments).
139    ///
140    /// This only applies to parametric profiles swept by `IfcExtrudedAreaSolid`;
141    /// faceted / tessellated exports have their fillets baked into the mesh.
142    #[inline]
143    pub fn profile_fillet_radius(self, radius: f64) -> f64 {
144        match self {
145            Self::Lowest | Self::Low => 0.0,
146            Self::Medium | Self::High | Self::Highest => radius,
147        }
148    }
149
150    /// Segment count for a **circular profile** outline (opening cutter / cap),
151    /// where `base` is the historical fixed count (e.g. 36 for
152    /// `IfcCircleProfileDef`).
153    ///
154    /// Profile circles deliberately do **not** get *finer* above `Medium`:
155    /// denser opening circles only multiply the earcut cap-bridge slivers that
156    /// show up as scar lines on plates with bolt holes (issue #976). They do get
157    /// *coarser* below `Medium` for preview / throughput. The `.min(base)`
158    /// guards tiny circles whose `base` is already below the coarse targets.
159    #[inline]
160    pub fn circle_profile_segments(self, base: usize) -> usize {
161        match self {
162            Self::Lowest => base.min(8),
163            Self::Low => base.min(16),
164            Self::Medium | Self::High | Self::Highest => base,
165        }
166    }
167}
168
169/// Scale a tessellator's segment count by the selected quality level.
170///
171/// `base` is the segment count the call site computed by its own (possibly
172/// adaptive) rule; `min`/`max` are that site's existing clamp bounds. At
173/// [`TessellationQuality::Medium`] the result is exactly `base.clamp(min, max)`
174/// — the historical value. Away from `Medium`, both `base` and the clamp bounds
175/// are scaled by [`TessellationQuality::density_factor`], so detail genuinely
176/// rises or falls instead of saturating at the old cap. The result is monotonic
177/// non-decreasing across the five levels.
178#[inline]
179pub fn scale_segments(base: usize, min: usize, max: usize, q: TessellationQuality) -> usize {
180    if q == TessellationQuality::Medium {
181        // Identity path — provably unchanged from pre-enum behavior.
182        return base.clamp(min, max);
183    }
184    let f = q.density_factor();
185    let scaled = (base as f64 * f).round() as usize;
186    let lo = ((min as f64 * f).round() as usize).max(1);
187    let hi = (max as f64 * f).round() as usize;
188    scaled.clamp(lo, hi.max(lo))
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    const LEVELS: [TessellationQuality; 5] = [
196        TessellationQuality::Lowest,
197        TessellationQuality::Low,
198        TessellationQuality::Medium,
199        TessellationQuality::High,
200        TessellationQuality::Highest,
201    ];
202
203    #[test]
204    fn default_is_medium() {
205        assert_eq!(TessellationQuality::default(), TessellationQuality::Medium);
206    }
207
208    #[test]
209    fn medium_factor_is_one() {
210        assert_eq!(TessellationQuality::Medium.density_factor(), 1.0);
211    }
212
213    #[test]
214    fn medium_is_identity_clamp() {
215        // For a representative spread of (base, min, max) the Medium result must
216        // equal the historical base.clamp(min, max) exactly.
217        let cases = [
218            (26usize, 8usize, 32usize), // sqrt(10)*8 circle
219            (4, 8, 32),                 // below floor
220            (200, 8, 32),               // above cap
221            (24, 24, 24),               // fixed count
222            (36, 36, 36),               // fixed count
223            (12, 2, 128),               // trimmed conic
224        ];
225        for (base, min, max) in cases {
226            assert_eq!(
227                scale_segments(base, min, max, TessellationQuality::Medium),
228                base.clamp(min, max),
229                "Medium must be identity for ({base},{min},{max})"
230            );
231        }
232    }
233
234    #[test]
235    fn monotonic_non_decreasing_across_levels() {
236        // A site with headroom (base below the scaled cap) must scale up
237        // monotonically and strictly increase somewhere across the range.
238        for (base, min, max) in [(26usize, 8usize, 64usize), (24, 8, 128), (36, 8, 144)] {
239            let counts: Vec<usize> = LEVELS
240                .iter()
241                .map(|&q| scale_segments(base, min, max, q))
242                .collect();
243            for w in counts.windows(2) {
244                assert!(
245                    w[0] <= w[1],
246                    "not monotonic for base={base}: {counts:?}"
247                );
248            }
249            assert!(
250                counts.first() < counts.last(),
251                "expected strict increase across range for base={base}: {counts:?}"
252            );
253        }
254    }
255
256    #[test]
257    fn circle_profile_segments_coarsen_below_medium_cap_above() {
258        use TessellationQuality::*;
259        // base 36 → the documented 8/16/36/36/36 mapping.
260        assert_eq!(Lowest.circle_profile_segments(36), 8);
261        assert_eq!(Low.circle_profile_segments(36), 16);
262        for q in [Medium, High, Highest] {
263            assert_eq!(q.circle_profile_segments(36), 36, "{q:?} must keep base");
264        }
265        // Tiny circle whose base is already below the coarse targets: never
266        // *increase* it (monotonic, no jump above base).
267        assert_eq!(Lowest.circle_profile_segments(6), 6);
268        assert_eq!(Low.circle_profile_segments(12), 12);
269        assert_eq!(Medium.circle_profile_segments(6), 6);
270    }
271
272    #[test]
273    fn profile_arc_segments_coarsen_below_medium_cap_above() {
274        use TessellationQuality::*;
275        // base 24 (a chunky chord-adaptive arc): identity at Medium+, halved at
276        // Low, quartered at Lowest.
277        assert_eq!(Lowest.profile_arc_segments(24, 2), 6);
278        assert_eq!(Low.profile_arc_segments(24, 2), 12);
279        for q in [Medium, High, Highest] {
280            assert_eq!(q.profile_arc_segments(24, 2), 24, "{q:?} keeps base");
281        }
282        // Floor respected.
283        assert_eq!(Lowest.profile_arc_segments(6, 2), 2);
284    }
285
286    #[test]
287    fn profile_fillet_radius_drops_below_medium_identity_above() {
288        use TessellationQuality::*;
289        for q in [Lowest, Low] {
290            assert_eq!(q.profile_fillet_radius(15.0), 0.0, "{q:?} must go sharp");
291        }
292        for q in [Medium, High, Highest] {
293            assert_eq!(q.profile_fillet_radius(15.0), 15.0, "{q:?} keeps the radius");
294        }
295        // An already-sharp corner stays sharp everywhere.
296        assert_eq!(Medium.profile_fillet_radius(0.0), 0.0);
297    }
298
299    #[test]
300    fn never_below_one() {
301        // Even at Lowest with a tiny base/min the helper never returns zero.
302        assert!(scale_segments(2, 2, 8, TessellationQuality::Lowest) >= 1);
303    }
304}