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}