draco_core/encoder_options.rs
1use std::collections::HashMap;
2use std::sync::Arc;
3
4/// Integer option bag used to configure Draco encoding.
5///
6/// Options mirror the C++ Draco encoder style: global options apply to the
7/// whole geometry, while attribute options override a value for one attribute
8/// id and fall back to the global value when unset.
9///
10/// Common keys include `quantization_bits` (per-attribute precision),
11/// `encoding_speed`/`decoding_speed`, `encoding_method`, and
12/// `prediction_scheme`. Keys without an explicit setter are read and written
13/// with [`get_global_int`](EncoderOptions::get_global_int) /
14/// [`set_global_int`](EncoderOptions::set_global_int).
15///
16/// # Examples
17///
18/// ```
19/// use draco_core::EncoderOptions;
20///
21/// let mut options = EncoderOptions::new();
22/// options.set_global_int("quantization_bits", 14); // default for all attributes
23/// options.set_attribute_int(0, "quantization_bits", 10); // override attribute 0
24///
25/// assert_eq!(options.get_attribute_int(0, "quantization_bits", 0), 10);
26/// // Attribute 1 has no override, so it falls back to the global value.
27/// assert_eq!(options.get_attribute_int(1, "quantization_bits", 0), 14);
28/// ```
29#[derive(Debug, Clone, Default)]
30pub struct EncoderOptions {
31 inner: Arc<Inner>,
32}
33
34/// The option maps, shared until a setter needs its own copy.
35///
36/// The encoders clone the whole bag on every `encode` call and keep the copy
37/// for the duration. A deep clone paid one allocation per map and per `String`
38/// key each time -- pure bookkeeping, and on a tiny mesh a visible share of
39/// the per-call floor. Behind an `Arc` the clone is a reference count, and a
40/// setter on a bag that is shared at that moment copies the maps once for
41/// itself (`Arc::make_mut`); a bag nobody else holds is mutated in place.
42#[derive(Debug, Clone, Default)]
43struct Inner {
44 global_options: HashMap<String, i32>,
45 attribute_options: HashMap<i32, HashMap<String, i32>>,
46}
47
48impl EncoderOptions {
49 /// Creates options with Draco-compatible defaults.
50 pub fn new() -> Self {
51 Self::default()
52 }
53
54 /// Returns the configured encoding speed, defaulting to 5.
55 pub fn get_encoding_speed(&self) -> i32 {
56 self.get_global_int("encoding_speed", 5)
57 }
58
59 /// Returns the configured decoding speed target, defaulting to 5.
60 pub fn get_decoding_speed(&self) -> i32 {
61 self.get_global_int("decoding_speed", 5)
62 }
63
64 /// Returns the maximum speed for both encoding/decoding.
65 /// Matches C++ ExpertEncoder::GetSpeed() behavior.
66 pub fn get_speed(&self) -> i32 {
67 let encoding_speed = self
68 .inner
69 .global_options
70 .get("encoding_speed")
71 .copied()
72 .unwrap_or(-1);
73 let decoding_speed = self
74 .inner
75 .global_options
76 .get("decoding_speed")
77 .copied()
78 .unwrap_or(-1);
79 let max_speed = encoding_speed.max(decoding_speed);
80 if max_speed == -1 {
81 5 // Default value
82 } else {
83 max_speed
84 }
85 }
86
87 /// Sets both speeds from a `draco_encoder`-style compression level.
88 ///
89 /// The CLI's `-cl` runs 0 (least compression) to 10 (most), the opposite
90 /// sense of `encoding_speed`/`decoding_speed`, and converts with
91 /// `speed = 10 - compression_level` before calling the same
92 /// `SetSpeedOptions` this crate mirrors; this method does the same
93 /// conversion and nothing else. `level` is not range-checked, matching
94 /// the CLI, which passes an out-of-range `-cl` straight through the same
95 /// subtraction rather than rejecting it.
96 pub fn set_compression_level(&mut self, level: i32) {
97 let speed = 10 - level;
98 self.set_global_int("encoding_speed", speed);
99 self.set_global_int("decoding_speed", speed);
100 }
101
102 /// Returns `10 - `[`get_speed`](Self::get_speed)`()`, the CLI's compression
103 /// level for the speed this instance currently carries.
104 ///
105 /// A fresh `EncoderOptions` reports 5 here, not the CLI's own default of
106 /// 7 — the two tools default to different speeds (5 here, 3 there), and
107 /// this getter reads what is actually set rather than what the CLI would
108 /// have chosen.
109 pub fn get_compression_level(&self) -> i32 {
110 10 - self.get_speed()
111 }
112
113 /// Sets `quantization_bits` for one attribute id.
114 ///
115 /// Equivalent to `ExpertEncoder::SetAttributeQuantization` and to what the
116 /// CLI's `-qp`/`-qt`/`-qn`/`-qg` resolve to once they have picked an
117 /// attribute id for POSITION/TEX_COORD/NORMAL/GENERIC; this method takes
118 /// the id directly rather than a geometry attribute type.
119 pub fn set_attribute_quantization(&mut self, att_id: i32, quantization_bits: i32) {
120 self.set_attribute_int(att_id, "quantization_bits", quantization_bits);
121 }
122
123 /// Returns the `quantization_bits` set for one attribute id, or -1 if
124 /// none was set for it or globally.
125 pub fn get_attribute_quantization(&self, att_id: i32) -> i32 {
126 self.get_attribute_int(att_id, "quantization_bits", -1)
127 }
128
129 /// Returns the forced prediction scheme, or -1 for the encoder default.
130 pub fn get_prediction_scheme(&self) -> i32 {
131 self.get_global_int("prediction_scheme", -1)
132 }
133
134 /// Returns the prediction scheme forced for one attribute, falling back to
135 /// the global setting and then to -1 for the encoder default.
136 ///
137 /// Upstream reads this per attribute (`GetPredictionMethodFromOptions`), and
138 /// `get_attribute_int` itself falls back to the global option, so a value
139 /// set either way is honoured. Crate-internal: callers outside can already
140 /// express both halves with `set_attribute_int` and `set_prediction_scheme`,
141 /// and a getter with no matching per-attribute setter would be a half API.
142 pub(crate) fn get_attribute_prediction_scheme(&self, att_id: i32) -> i32 {
143 self.get_attribute_int(att_id, "prediction_scheme", -1)
144 }
145
146 /// Forces a prediction scheme by numeric Draco method id.
147 pub fn set_prediction_scheme(&mut self, value: i32) {
148 self.set_global_int("prediction_scheme", value);
149 }
150
151 /// Whether the encoder may choose a point-cloud attribute's prediction
152 /// scheme by estimating the cost of each candidate.
153 pub fn prediction_search(&self) -> bool {
154 self.get_global_int("prediction_scheme_search", 0) != 0
155 }
156
157 /// Lets the encoder choose each attribute's prediction scheme by the
158 /// estimated cost of the candidates rather than by upstream's fixed rule.
159 ///
160 /// Off by default, and deliberately: the automatic choice is upstream's,
161 /// and this crate's output is byte-identical to C++ Draco's for the same
162 /// input. Searching produces a different — smaller — stream, so it is a
163 /// thing a caller asks for rather than a thing that happens to them.
164 ///
165 /// What it buys, and why it exists at all: the automatic choice for a
166 /// point-cloud attribute is always `Difference`, and differencing costs
167 /// more than it saves whenever consecutive values do not correlate.
168 /// Spherical-harmonic coefficients in a Gaussian splat are the case that
169 /// prompted this — predicted they cost 6.35 bits per 8-bit value, coded
170 /// directly 5.72, against an order-0 entropy of 5.685. The opposite case is
171 /// just as real: on smoothly varying data, turning prediction off has made
172 /// a file 2.5x larger. Neither is knowable from the attribute's type, so
173 /// this looks at its values.
174 ///
175 /// **It is worth turning on only for data of that shape**, and the honest
176 /// version of "that shape" is narrow. On a photogrammetry capture — eight
177 /// million points carrying position and colour — the search finds nothing
178 /// at all: both attributes are well served by differencing, and coding
179 /// either directly is 27% worse. Attributes whose values do not follow
180 /// their neighbours are what this is for, and a scanned surface is the
181 /// opposite of that.
182 ///
183 /// The cost is encode time and nothing else. The candidates are ranked by
184 /// the same bit estimate the symbol coder uses to choose its own scheme,
185 /// which is an entropy pass over each candidate's symbols, not a second
186 /// encode, and the winner's estimate is what the coder is then handed
187 /// rather than working it out again: on a splat of a million points and 58
188 /// attributes the option adds about a third to the encode. Decoding is
189 /// unaffected, and every stream this can produce is one an ordinary decoder
190 /// reads: the scheme is a byte the bitstream has always carried,
191 /// `PREDICTION_NONE` included.
192 ///
193 /// An attribute with an explicit `prediction_scheme` is left alone; a
194 /// caller who named a scheme has already made this choice.
195 pub fn set_prediction_search(&mut self, enabled: bool) {
196 self.set_global_int("prediction_scheme_search", i32::from(enabled));
197 }
198
199 /// Whether a point cloud's points may be reordered spatially before being
200 /// encoded.
201 pub fn spatial_point_order(&self) -> bool {
202 self.get_global_int("spatial_point_order", 0) != 0
203 }
204
205 /// Lets the encoder emit a point cloud's points in a spatial order rather
206 /// than in the order they were handed in.
207 ///
208 /// Which spatial order is the encoder's choice and not part of this
209 /// option's contract: today it is a Morton curve, and a later version may
210 /// use a better curve, or spend more encode time on the order at slower
211 /// `encoding_speed` settings. Every such stream decodes the same way; only
212 /// the order of the decoded points and the size differ.
213 ///
214 /// A point cloud's point order carries no meaning: no connectivity refers
215 /// to it, every attribute is read through the same point index, and a
216 /// decoder reconstructs whatever order the stream has. So an encoder may
217 /// choose it, and choosing it spatially is what makes the difference
218 /// predictor predict from a neighbour instead of from whatever the
219 /// exporter happened to write next.
220 ///
221 /// **This is the general one of the two.** It was written for Gaussian
222 /// splats, where it takes a scene from 53.02 bytes per point to 45.47, and
223 /// it does more on ordinary captured geometry: a 223 MB photogrammetry
224 /// point cloud of eight million coloured points goes from 6.26 bytes per
225 /// point to 4.23, which is 32% and more than twice the splat's share. Any
226 /// cloud whose attributes vary through space rather than along its file
227 /// order should expect something in that range.
228 ///
229 /// The Morton curve is laid over a grid as fine as the positions' own
230 /// `quantization_bits`, up to 21 bits an axis. Both halves of that are
231 /// measured: a coarser grid puts points the stream will distinguish into
232 /// one cell, where their order is whatever the sort left them in, and a
233 /// finer one sorts by differences the quantization discards.
234 ///
235 /// Off by default for the same reason the prediction search is: the output
236 /// differs, byte for byte, from what upstream C++ Draco writes for the same
237 /// input, and this crate's default is to match it.
238 ///
239 /// **This reorders the decoded points.** Anything outside the file that
240 /// indexes into it by point number — a sidecar array, an index written by
241 /// another tool — will be pointing at different points afterwards. Nothing
242 /// inside a `.drc` does, which is why this is expressible at all, but a
243 /// caller who has such a thing is the one who knows.
244 ///
245 /// **It can also make a file bigger**, and unlike the prediction search it
246 /// does not check. The gain comes from attributes that vary through space;
247 /// an attribute that varies along the order it was handed in — an index, a
248 /// timestamp, anything written in sequence — is scrambled by the reorder
249 /// and costs more afterwards. A cloud of positions plus a running integer
250 /// tag grows by 14% here. It is not checked because the option is a
251 /// statement about the order, not about the size: a caller who wants
252 /// spatial locality in the decoded cloud wants it whether or not it also
253 /// happens to compress better. Whoever wants only the smaller file can
254 /// encode both ways and keep the smaller, which is what this would
255 /// otherwise be doing on their behalf and at twice the encode time.
256 ///
257 /// Applies to the sequential coder. The kd-tree coder chooses its own point
258 /// order and this leaves it alone. A point cloud with no position attribute
259 /// has nothing to sort by and is also left alone.
260 pub fn set_spatial_point_order(&mut self, enabled: bool) {
261 self.set_global_int("spatial_point_order", i32::from(enabled));
262 }
263
264 /// Returns the forced encoding method, if one was set.
265 pub fn get_encoding_method(&self) -> Option<i32> {
266 self.inner.global_options.get("encoding_method").cloned()
267 }
268
269 /// Forces an encoding method by numeric Draco method id.
270 pub fn set_encoding_method(&mut self, value: i32) {
271 self.set_global_int("encoding_method", value);
272 }
273
274 /// Sets the target Draco bitstream version.
275 pub fn set_version(&mut self, major: u8, minor: u8) {
276 self.set_global_int("version_major", major as i32);
277 self.set_global_int("version_minor", minor as i32);
278 }
279
280 /// Returns the target Draco bitstream version, or `(0, 0)` for default.
281 pub fn get_version(&self) -> (u8, u8) {
282 let major = self.get_global_int("version_major", -1);
283 let minor = self.get_global_int("version_minor", -1);
284 if major == -1 || minor == -1 {
285 // Default version depends on the encoder type and method,
286 // but we'll return (0, 0) to indicate "use default".
287 (0, 0)
288 } else {
289 (major as u8, minor as u8)
290 }
291 }
292
293 /// Sets a global integer option.
294 pub fn set_global_int(&mut self, key: &str, value: i32) {
295 Arc::make_mut(&mut self.inner)
296 .global_options
297 .insert(key.to_string(), value);
298 }
299
300 /// Returns a global integer option or the supplied default.
301 pub fn get_global_int(&self, key: &str, default_val: i32) -> i32 {
302 *self.inner.global_options.get(key).unwrap_or(&default_val)
303 }
304
305 /// Sets an integer option for one attribute id.
306 pub fn set_attribute_int(&mut self, att_id: i32, key: &str, value: i32) {
307 Arc::make_mut(&mut self.inner)
308 .attribute_options
309 .entry(att_id)
310 .or_default()
311 .insert(key.to_string(), value);
312 }
313
314 /// Returns an attribute integer option, falling back to the global value.
315 pub fn get_attribute_int(&self, att_id: i32, key: &str, default_val: i32) -> i32 {
316 if let Some(opts) = self.inner.attribute_options.get(&att_id) {
317 if let Some(val) = opts.get(key) {
318 return *val;
319 }
320 }
321 // Falls back to the global value, as upstream does.
322 self.get_global_int(key, default_val)
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 /// `draco_encoder`'s own default is `-cl 7`; this crate's is speed 5.
331 /// Verified against the CLI: `EncoderOptions::new()` and
332 /// `draco_encoder -cl 5 -qp 0` produce byte-identical output.
333 #[test]
334 fn compression_level_defaults_to_5_not_the_cli_default_of_7() {
335 let options = EncoderOptions::new();
336 assert_eq!(options.get_compression_level(), 5);
337 }
338
339 /// `speed = 10 - compression_level`, matching `draco_encoder.cc`'s own
340 /// conversion before it calls `SetSpeedOptions`.
341 #[test]
342 fn set_compression_level_matches_the_cli_conversion() {
343 let mut options = EncoderOptions::new();
344 options.set_compression_level(7);
345 assert_eq!(options.get_speed(), 3);
346 assert_eq!(options.get_encoding_speed(), 3);
347 assert_eq!(options.get_decoding_speed(), 3);
348 assert_eq!(options.get_compression_level(), 7);
349 }
350
351 #[test]
352 fn set_compression_level_round_trips_the_full_cli_range() {
353 for level in 0..=10 {
354 let mut options = EncoderOptions::new();
355 options.set_compression_level(level);
356 assert_eq!(options.get_compression_level(), level);
357 }
358 }
359
360 /// A clone shares the maps, and a setter on either side afterwards
361 /// changes only the side it was called on.
362 #[test]
363 fn a_clone_shares_the_maps_until_one_side_writes() {
364 let mut options = EncoderOptions::new();
365 options.set_global_int("quantization_bits", 14);
366 options.set_attribute_int(1, "quantization_bits", 10);
367 let mut cloned = options.clone();
368 assert!(Arc::ptr_eq(&options.inner, &cloned.inner));
369
370 cloned.set_attribute_int(1, "quantization_bits", 8);
371 options.set_global_int("quantization_bits", 12);
372 assert!(!Arc::ptr_eq(&options.inner, &cloned.inner));
373 assert_eq!(options.get_attribute_int(1, "quantization_bits", 0), 10);
374 assert_eq!(options.get_global_int("quantization_bits", 0), 12);
375 assert_eq!(cloned.get_attribute_int(1, "quantization_bits", 0), 8);
376 assert_eq!(cloned.get_global_int("quantization_bits", 0), 14);
377 }
378
379 #[test]
380 fn attribute_quantization_defaults_to_unset() {
381 let options = EncoderOptions::new();
382 assert_eq!(options.get_attribute_quantization(0), -1);
383 }
384
385 #[test]
386 fn set_attribute_quantization_round_trips() {
387 let mut options = EncoderOptions::new();
388 options.set_attribute_quantization(0, 14);
389 options.set_attribute_quantization(1, 10);
390
391 assert_eq!(options.get_attribute_quantization(0), 14);
392 assert_eq!(options.get_attribute_quantization(1), 10);
393 // Unset attributes fall back to the global value, same as
394 // `get_attribute_int` -- there is none here, so -1.
395 assert_eq!(options.get_attribute_quantization(2), -1);
396 }
397}