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