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    /// Segment count for a **circular profile** outline (opening cutter / cap),
125    /// where `base` is the historical fixed count (e.g. 36 for
126    /// `IfcCircleProfileDef`).
127    ///
128    /// Profile circles deliberately do **not** get *finer* above `Medium`:
129    /// denser opening circles only multiply the earcut cap-bridge slivers that
130    /// show up as scar lines on plates with bolt holes (issue #976). They do get
131    /// *coarser* below `Medium` for preview / throughput. The `.min(base)`
132    /// guards tiny circles whose `base` is already below the coarse targets.
133    #[inline]
134    pub fn circle_profile_segments(self, base: usize) -> usize {
135        match self {
136            Self::Lowest => base.min(8),
137            Self::Low => base.min(16),
138            Self::Medium | Self::High | Self::Highest => base,
139        }
140    }
141}
142
143/// Scale a tessellator's segment count by the selected quality level.
144///
145/// `base` is the segment count the call site computed by its own (possibly
146/// adaptive) rule; `min`/`max` are that site's existing clamp bounds. At
147/// [`TessellationQuality::Medium`] the result is exactly `base.clamp(min, max)`
148/// — the historical value. Away from `Medium`, both `base` and the clamp bounds
149/// are scaled by [`TessellationQuality::density_factor`], so detail genuinely
150/// rises or falls instead of saturating at the old cap. The result is monotonic
151/// non-decreasing across the five levels.
152#[inline]
153pub fn scale_segments(base: usize, min: usize, max: usize, q: TessellationQuality) -> usize {
154    if q == TessellationQuality::Medium {
155        // Identity path — provably unchanged from pre-enum behavior.
156        return base.clamp(min, max);
157    }
158    let f = q.density_factor();
159    let scaled = (base as f64 * f).round() as usize;
160    let lo = ((min as f64 * f).round() as usize).max(1);
161    let hi = (max as f64 * f).round() as usize;
162    scaled.clamp(lo, hi.max(lo))
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    const LEVELS: [TessellationQuality; 5] = [
170        TessellationQuality::Lowest,
171        TessellationQuality::Low,
172        TessellationQuality::Medium,
173        TessellationQuality::High,
174        TessellationQuality::Highest,
175    ];
176
177    #[test]
178    fn default_is_medium() {
179        assert_eq!(TessellationQuality::default(), TessellationQuality::Medium);
180    }
181
182    #[test]
183    fn medium_factor_is_one() {
184        assert_eq!(TessellationQuality::Medium.density_factor(), 1.0);
185    }
186
187    #[test]
188    fn medium_is_identity_clamp() {
189        // For a representative spread of (base, min, max) the Medium result must
190        // equal the historical base.clamp(min, max) exactly.
191        let cases = [
192            (26usize, 8usize, 32usize), // sqrt(10)*8 circle
193            (4, 8, 32),                 // below floor
194            (200, 8, 32),               // above cap
195            (24, 24, 24),               // fixed count
196            (36, 36, 36),               // fixed count
197            (12, 2, 128),               // trimmed conic
198        ];
199        for (base, min, max) in cases {
200            assert_eq!(
201                scale_segments(base, min, max, TessellationQuality::Medium),
202                base.clamp(min, max),
203                "Medium must be identity for ({base},{min},{max})"
204            );
205        }
206    }
207
208    #[test]
209    fn monotonic_non_decreasing_across_levels() {
210        // A site with headroom (base below the scaled cap) must scale up
211        // monotonically and strictly increase somewhere across the range.
212        for (base, min, max) in [(26usize, 8usize, 64usize), (24, 8, 128), (36, 8, 144)] {
213            let counts: Vec<usize> = LEVELS
214                .iter()
215                .map(|&q| scale_segments(base, min, max, q))
216                .collect();
217            for w in counts.windows(2) {
218                assert!(
219                    w[0] <= w[1],
220                    "not monotonic for base={base}: {counts:?}"
221                );
222            }
223            assert!(
224                counts.first() < counts.last(),
225                "expected strict increase across range for base={base}: {counts:?}"
226            );
227        }
228    }
229
230    #[test]
231    fn circle_profile_segments_coarsen_below_medium_cap_above() {
232        use TessellationQuality::*;
233        // base 36 → the documented 8/16/36/36/36 mapping.
234        assert_eq!(Lowest.circle_profile_segments(36), 8);
235        assert_eq!(Low.circle_profile_segments(36), 16);
236        for q in [Medium, High, Highest] {
237            assert_eq!(q.circle_profile_segments(36), 36, "{q:?} must keep base");
238        }
239        // Tiny circle whose base is already below the coarse targets: never
240        // *increase* it (monotonic, no jump above base).
241        assert_eq!(Lowest.circle_profile_segments(6), 6);
242        assert_eq!(Low.circle_profile_segments(12), 12);
243        assert_eq!(Medium.circle_profile_segments(6), 6);
244    }
245
246    #[test]
247    fn profile_arc_segments_coarsen_below_medium_cap_above() {
248        use TessellationQuality::*;
249        // base 24 (a chunky chord-adaptive arc): identity at Medium+, halved at
250        // Low, quartered at Lowest.
251        assert_eq!(Lowest.profile_arc_segments(24, 2), 6);
252        assert_eq!(Low.profile_arc_segments(24, 2), 12);
253        for q in [Medium, High, Highest] {
254            assert_eq!(q.profile_arc_segments(24, 2), 24, "{q:?} keeps base");
255        }
256        // Floor respected.
257        assert_eq!(Lowest.profile_arc_segments(6, 2), 2);
258    }
259
260    #[test]
261    fn never_below_one() {
262        // Even at Lowest with a tiny base/min the helper never returns zero.
263        assert!(scale_segments(2, 2, 8, TessellationQuality::Lowest) >= 1);
264    }
265}