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}