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
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
use std::collections::HashMap;
use std::sync::Arc;
/// Integer option bag used to configure Draco encoding.
///
/// Options mirror the C++ Draco encoder style: global options apply to the
/// whole geometry, while attribute options override a value for one attribute
/// id and fall back to the global value when unset.
///
/// Common keys include `quantization_bits` (per-attribute precision),
/// `encoding_speed`/`decoding_speed`, `encoding_method`, and
/// `prediction_scheme`. Keys without an explicit setter are read and written
/// with [`get_global_int`](EncoderOptions::get_global_int) /
/// [`set_global_int`](EncoderOptions::set_global_int).
///
/// # Examples
///
/// ```
/// use draco_core::EncoderOptions;
///
/// let mut options = EncoderOptions::new();
/// options.set_global_int("quantization_bits", 14); // default for all attributes
/// options.set_attribute_int(0, "quantization_bits", 10); // override attribute 0
///
/// assert_eq!(options.get_attribute_int(0, "quantization_bits", 0), 10);
/// // Attribute 1 has no override, so it falls back to the global value.
/// assert_eq!(options.get_attribute_int(1, "quantization_bits", 0), 14);
/// ```
#[derive(Debug, Clone, Default)]
pub struct EncoderOptions {
inner: Arc<Inner>,
}
/// The option maps, shared until a setter needs its own copy.
///
/// The encoders clone the whole bag on every `encode` call and keep the copy
/// for the duration. A deep clone paid one allocation per map and per `String`
/// key each time -- pure bookkeeping, and on a tiny mesh a visible share of
/// the per-call floor. Behind an `Arc` the clone is a reference count, and a
/// setter on a bag that is shared at that moment copies the maps once for
/// itself (`Arc::make_mut`); a bag nobody else holds is mutated in place.
#[derive(Debug, Clone, Default)]
struct Inner {
global_options: HashMap<String, i32>,
attribute_options: HashMap<i32, HashMap<String, i32>>,
}
impl EncoderOptions {
/// Creates options with Draco-compatible defaults.
pub fn new() -> Self {
Self::default()
}
/// Returns the configured encoding speed, defaulting to 5.
pub fn get_encoding_speed(&self) -> i32 {
self.get_global_int("encoding_speed", 5)
}
/// Returns the configured decoding speed target, defaulting to 5.
pub fn get_decoding_speed(&self) -> i32 {
self.get_global_int("decoding_speed", 5)
}
/// Returns the maximum speed for both encoding/decoding.
/// Matches C++ ExpertEncoder::GetSpeed() behavior.
pub fn get_speed(&self) -> i32 {
let encoding_speed = self
.inner
.global_options
.get("encoding_speed")
.copied()
.unwrap_or(-1);
let decoding_speed = self
.inner
.global_options
.get("decoding_speed")
.copied()
.unwrap_or(-1);
let max_speed = encoding_speed.max(decoding_speed);
if max_speed == -1 {
5 // Default value
} else {
max_speed
}
}
/// Sets both speeds from a `draco_encoder`-style compression level.
///
/// The CLI's `-cl` runs 0 (least compression) to 10 (most), the opposite
/// sense of `encoding_speed`/`decoding_speed`, and converts with
/// `speed = 10 - compression_level` before calling the same
/// `SetSpeedOptions` this crate mirrors; this method does the same
/// conversion and nothing else. `level` is not range-checked, matching
/// the CLI, which passes an out-of-range `-cl` straight through the same
/// subtraction rather than rejecting it.
pub fn set_compression_level(&mut self, level: i32) {
let speed = 10 - level;
self.set_global_int("encoding_speed", speed);
self.set_global_int("decoding_speed", speed);
}
/// Returns `10 - `[`get_speed`](Self::get_speed)`()`, the CLI's compression
/// level for the speed this instance currently carries.
///
/// A fresh `EncoderOptions` reports 5 here, not the CLI's own default of
/// 7 — the two tools default to different speeds (5 here, 3 there), and
/// this getter reads what is actually set rather than what the CLI would
/// have chosen.
pub fn get_compression_level(&self) -> i32 {
10 - self.get_speed()
}
/// Sets `quantization_bits` for one attribute id.
///
/// Equivalent to `ExpertEncoder::SetAttributeQuantization` and to what the
/// CLI's `-qp`/`-qt`/`-qn`/`-qg` resolve to once they have picked an
/// attribute id for POSITION/TEX_COORD/NORMAL/GENERIC; this method takes
/// the id directly rather than a geometry attribute type.
pub fn set_attribute_quantization(&mut self, att_id: i32, quantization_bits: i32) {
self.set_attribute_int(att_id, "quantization_bits", quantization_bits);
}
/// Returns the `quantization_bits` set for one attribute id, or -1 if
/// none was set for it or globally.
pub fn get_attribute_quantization(&self, att_id: i32) -> i32 {
self.get_attribute_int(att_id, "quantization_bits", -1)
}
/// Returns the forced prediction scheme, or -1 for the encoder default.
pub fn get_prediction_scheme(&self) -> i32 {
self.get_global_int("prediction_scheme", -1)
}
/// Returns the prediction scheme forced for one attribute, falling back to
/// the global setting and then to -1 for the encoder default.
///
/// Upstream reads this per attribute (`GetPredictionMethodFromOptions`), and
/// `get_attribute_int` itself falls back to the global option, so a value
/// set either way is honoured. Crate-internal: callers outside can already
/// express both halves with `set_attribute_int` and `set_prediction_scheme`,
/// and a getter with no matching per-attribute setter would be a half API.
pub(crate) fn get_attribute_prediction_scheme(&self, att_id: i32) -> i32 {
self.get_attribute_int(att_id, "prediction_scheme", -1)
}
/// Forces a prediction scheme by numeric Draco method id.
pub fn set_prediction_scheme(&mut self, value: i32) {
self.set_global_int("prediction_scheme", value);
}
/// Whether the encoder may choose a point-cloud attribute's prediction
/// scheme by estimating the cost of each candidate.
pub fn prediction_search(&self) -> bool {
self.get_global_int("prediction_scheme_search", 0) != 0
}
/// Lets the encoder choose each attribute's prediction scheme by the
/// estimated cost of the candidates rather than by upstream's fixed rule.
///
/// Off by default, and deliberately: the automatic choice is upstream's,
/// and this crate's output is byte-identical to C++ Draco's for the same
/// input. Searching produces a different — smaller — stream, so it is a
/// thing a caller asks for rather than a thing that happens to them.
///
/// What it buys, and why it exists at all: the automatic choice for a
/// point-cloud attribute is always `Difference`, and differencing costs
/// more than it saves whenever consecutive values do not correlate.
/// Spherical-harmonic coefficients in a Gaussian splat are the case that
/// prompted this — predicted they cost 6.35 bits per 8-bit value, coded
/// directly 5.72, against an order-0 entropy of 5.685. The opposite case is
/// just as real: on smoothly varying data, turning prediction off has made
/// a file 2.5x larger. Neither is knowable from the attribute's type, so
/// this looks at its values.
///
/// **It is worth turning on only for data of that shape**, and the honest
/// version of "that shape" is narrow. On a photogrammetry capture — eight
/// million points carrying position and colour — the search finds nothing
/// at all: both attributes are well served by differencing, and coding
/// either directly is 27% worse. Attributes whose values do not follow
/// their neighbours are what this is for, and a scanned surface is the
/// opposite of that.
///
/// The cost is encode time and nothing else. The candidates are ranked by
/// the same bit estimate the symbol coder uses to choose its own scheme,
/// which is an entropy pass over each candidate's symbols, not a second
/// encode, and the winner's estimate is what the coder is then handed
/// rather than working it out again: on a splat of a million points and 58
/// attributes the option adds about a third to the encode. Decoding is
/// unaffected, and every stream this can produce is one an ordinary decoder
/// reads: the scheme is a byte the bitstream has always carried,
/// `PREDICTION_NONE` included.
///
/// An attribute with an explicit `prediction_scheme` is left alone; a
/// caller who named a scheme has already made this choice.
pub fn set_prediction_search(&mut self, enabled: bool) {
self.set_global_int("prediction_scheme_search", i32::from(enabled));
}
/// Whether a point cloud's points may be reordered spatially before being
/// encoded.
pub fn spatial_point_order(&self) -> bool {
self.get_global_int("spatial_point_order", 0) != 0
}
/// Lets the encoder emit a point cloud's points in a spatial order rather
/// than in the order they were handed in.
///
/// Which spatial order is the encoder's choice and not part of this
/// option's contract: today it is a Morton curve, and a later version may
/// use a better curve, or spend more encode time on the order at slower
/// `encoding_speed` settings. Every such stream decodes the same way; only
/// the order of the decoded points and the size differ.
///
/// A point cloud's point order carries no meaning: no connectivity refers
/// to it, every attribute is read through the same point index, and a
/// decoder reconstructs whatever order the stream has. So an encoder may
/// choose it, and choosing it spatially is what makes the difference
/// predictor predict from a neighbour instead of from whatever the
/// exporter happened to write next.
///
/// **This is the general one of the two.** It was written for Gaussian
/// splats, where it takes a scene from 53.02 bytes per point to 45.47, and
/// it does more on ordinary captured geometry: a 223 MB photogrammetry
/// point cloud of eight million coloured points goes from 6.26 bytes per
/// point to 4.23, which is 32% and more than twice the splat's share. Any
/// cloud whose attributes vary through space rather than along its file
/// order should expect something in that range.
///
/// The Morton curve is laid over a grid as fine as the positions' own
/// `quantization_bits`, up to 21 bits an axis. Both halves of that are
/// measured: a coarser grid puts points the stream will distinguish into
/// one cell, where their order is whatever the sort left them in, and a
/// finer one sorts by differences the quantization discards.
///
/// Off by default for the same reason the prediction search is: the output
/// differs, byte for byte, from what upstream C++ Draco writes for the same
/// input, and this crate's default is to match it.
///
/// **This reorders the decoded points.** Anything outside the file that
/// indexes into it by point number — a sidecar array, an index written by
/// another tool — will be pointing at different points afterwards. Nothing
/// inside a `.drc` does, which is why this is expressible at all, but a
/// caller who has such a thing is the one who knows.
///
/// **It can also make a file bigger**, and unlike the prediction search it
/// does not check. The gain comes from attributes that vary through space;
/// an attribute that varies along the order it was handed in — an index, a
/// timestamp, anything written in sequence — is scrambled by the reorder
/// and costs more afterwards. A cloud of positions plus a running integer
/// tag grows by 14% here. It is not checked because the option is a
/// statement about the order, not about the size: a caller who wants
/// spatial locality in the decoded cloud wants it whether or not it also
/// happens to compress better. Whoever wants only the smaller file can
/// encode both ways and keep the smaller, which is what this would
/// otherwise be doing on their behalf and at twice the encode time.
///
/// Applies to the sequential coder. The kd-tree coder chooses its own point
/// order and this leaves it alone. A point cloud with no position attribute
/// has nothing to sort by and is also left alone.
pub fn set_spatial_point_order(&mut self, enabled: bool) {
self.set_global_int("spatial_point_order", i32::from(enabled));
}
/// Returns the forced encoding method, if one was set.
pub fn get_encoding_method(&self) -> Option<i32> {
self.inner.global_options.get("encoding_method").cloned()
}
/// Forces an encoding method by numeric Draco method id.
pub fn set_encoding_method(&mut self, value: i32) {
self.set_global_int("encoding_method", value);
}
/// Sets the target Draco bitstream version.
pub fn set_version(&mut self, major: u8, minor: u8) {
self.set_global_int("version_major", major as i32);
self.set_global_int("version_minor", minor as i32);
}
/// Returns the target Draco bitstream version, or `(0, 0)` for default.
pub fn get_version(&self) -> (u8, u8) {
let major = self.get_global_int("version_major", -1);
let minor = self.get_global_int("version_minor", -1);
if major == -1 || minor == -1 {
// Default version depends on the encoder type and method,
// but we'll return (0, 0) to indicate "use default".
(0, 0)
} else {
(major as u8, minor as u8)
}
}
/// Sets a global integer option.
pub fn set_global_int(&mut self, key: &str, value: i32) {
Arc::make_mut(&mut self.inner)
.global_options
.insert(key.to_string(), value);
}
/// Returns a global integer option or the supplied default.
pub fn get_global_int(&self, key: &str, default_val: i32) -> i32 {
*self.inner.global_options.get(key).unwrap_or(&default_val)
}
/// Sets an integer option for one attribute id.
pub fn set_attribute_int(&mut self, att_id: i32, key: &str, value: i32) {
Arc::make_mut(&mut self.inner)
.attribute_options
.entry(att_id)
.or_default()
.insert(key.to_string(), value);
}
/// Returns an attribute integer option, falling back to the global value.
pub fn get_attribute_int(&self, att_id: i32, key: &str, default_val: i32) -> i32 {
if let Some(opts) = self.inner.attribute_options.get(&att_id) {
if let Some(val) = opts.get(key) {
return *val;
}
}
// Falls back to the global value, as upstream does.
self.get_global_int(key, default_val)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `draco_encoder`'s own default is `-cl 7`; this crate's is speed 5.
/// Verified against the CLI: `EncoderOptions::new()` and
/// `draco_encoder -cl 5 -qp 0` produce byte-identical output.
#[test]
fn compression_level_defaults_to_5_not_the_cli_default_of_7() {
let options = EncoderOptions::new();
assert_eq!(options.get_compression_level(), 5);
}
/// `speed = 10 - compression_level`, matching `draco_encoder.cc`'s own
/// conversion before it calls `SetSpeedOptions`.
#[test]
fn set_compression_level_matches_the_cli_conversion() {
let mut options = EncoderOptions::new();
options.set_compression_level(7);
assert_eq!(options.get_speed(), 3);
assert_eq!(options.get_encoding_speed(), 3);
assert_eq!(options.get_decoding_speed(), 3);
assert_eq!(options.get_compression_level(), 7);
}
#[test]
fn set_compression_level_round_trips_the_full_cli_range() {
for level in 0..=10 {
let mut options = EncoderOptions::new();
options.set_compression_level(level);
assert_eq!(options.get_compression_level(), level);
}
}
/// A clone shares the maps, and a setter on either side afterwards
/// changes only the side it was called on.
#[test]
fn a_clone_shares_the_maps_until_one_side_writes() {
let mut options = EncoderOptions::new();
options.set_global_int("quantization_bits", 14);
options.set_attribute_int(1, "quantization_bits", 10);
let mut cloned = options.clone();
assert!(Arc::ptr_eq(&options.inner, &cloned.inner));
cloned.set_attribute_int(1, "quantization_bits", 8);
options.set_global_int("quantization_bits", 12);
assert!(!Arc::ptr_eq(&options.inner, &cloned.inner));
assert_eq!(options.get_attribute_int(1, "quantization_bits", 0), 10);
assert_eq!(options.get_global_int("quantization_bits", 0), 12);
assert_eq!(cloned.get_attribute_int(1, "quantization_bits", 0), 8);
assert_eq!(cloned.get_global_int("quantization_bits", 0), 14);
}
#[test]
fn attribute_quantization_defaults_to_unset() {
let options = EncoderOptions::new();
assert_eq!(options.get_attribute_quantization(0), -1);
}
#[test]
fn set_attribute_quantization_round_trips() {
let mut options = EncoderOptions::new();
options.set_attribute_quantization(0, 14);
options.set_attribute_quantization(1, 10);
assert_eq!(options.get_attribute_quantization(0), 14);
assert_eq!(options.get_attribute_quantization(1), 10);
// Unset attributes fall back to the global value, same as
// `get_attribute_int` -- there is none here, so -1.
assert_eq!(options.get_attribute_quantization(2), -1);
}
}