Skip to main content

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 Hilbert curve, which writes 0.35% to 4%
210    /// fewer bytes than a Morton curve for the same encode time, and a later
211    /// version may use a better one. Every such stream decodes the
212    /// same way; only 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. Those are the Morton
228    /// curve's figures; the Hilbert curve is 1% to 2% smaller again on both.
229    ///
230    /// The curve is laid over a grid as fine as the positions' own
231    /// `quantization_bits`, up to 21 bits an axis. Both halves of that are
232    /// measured: a coarser grid puts points the stream will distinguish into
233    /// one cell, where their order is whatever the sort left them in, and a
234    /// finer one sorts by differences the quantization discards.
235    ///
236    /// Off by default for the same reason the prediction search is: the output
237    /// differs, byte for byte, from what upstream C++ Draco writes for the same
238    /// input, and this crate's default is to match it.
239    ///
240    /// **This reorders the decoded points.** Anything outside the file that
241    /// indexes into it by point number — a sidecar array, an index written by
242    /// another tool — will be pointing at different points afterwards. Nothing
243    /// inside a `.drc` does, which is why this is expressible at all, but a
244    /// caller who has such a thing is the one who knows.
245    ///
246    /// **It can also make a file bigger**, and unlike the prediction search it
247    /// does not check. The gain comes from attributes that vary through space;
248    /// an attribute that varies along the order it was handed in — an index, a
249    /// timestamp, anything written in sequence — is scrambled by the reorder
250    /// and costs more afterwards. A cloud of positions plus a running integer
251    /// tag grows by 14% here. It is not checked because the option is a
252    /// statement about the order, not about the size: a caller who wants
253    /// spatial locality in the decoded cloud wants it whether or not it also
254    /// happens to compress better. Whoever wants only the smaller file wants
255    /// [`set_point_order_search`](Self::set_point_order_search) instead, which
256    /// checks, and is allowed to trade locality for size.
257    ///
258    /// Applies to the sequential coder. The kd-tree coder chooses its own point
259    /// order and this leaves it alone. A point cloud with no position attribute
260    /// has nothing to sort by and is also left alone.
261    pub fn set_spatial_point_order(&mut self, enabled: bool) {
262        self.set_global_int("spatial_point_order", i32::from(enabled));
263    }
264
265    /// Whether the encoder may search for the order of a point cloud's points
266    /// that makes the stream smallest.
267    pub fn point_order_search(&self) -> bool {
268        self.get_global_int("point_order_search", 0) != 0
269    }
270
271    /// Lets the encoder search for the order of a point cloud's points that
272    /// makes the stream smallest, and leave the order alone where it finds the
273    /// one it was handed already better.
274    ///
275    /// The sequential coder writes each attribute as the difference between a
276    /// point and the one before it, so the order decides how large those
277    /// differences are. A spatial order makes neighbours in space neighbours
278    /// in the stream; this strings the points along a Hilbert curve and then
279    /// repairs it where the other attributes disagree with the positions,
280    /// which they do in a Gaussian splat, where the position is a small part
281    /// of each point's bytes.
282    ///
283    /// Against [`set_spatial_point_order`](Self::set_spatial_point_order) at
284    /// the same encoding speed it gains the most on clouds like that, and more
285    /// the lower the speed. Photogrammetry scans and lidar captures gain less.
286    /// The stream is an ordinary one. Only the order of the points in it
287    /// differs.
288    ///
289    /// **It costs encode time**, several times the encode in file order, and
290    /// more the lower the speed. The search runs on as many threads as
291    /// [`set_threads`](Self::set_threads) allows, writing the same order on any
292    /// number. **And some decode time**: in the order it finds, neighbours
293    /// agree well enough that the encoder picks the more compact schemes,
294    /// differences over values as they are and the raw coder over the tagged
295    /// one, and those take a little longer to undo.
296    ///
297    /// **It can decline.** Each order it could write -- the one it was handed,
298    /// the curve, and the curve repaired -- is priced by the symbol coder's own
299    /// plan of what it would write for a sample of the cloud, and an order
300    /// replaces another only if it undercuts it by 1%. The repair is tried on a
301    /// sample of the path's blocks first, one in sixteen on a large cloud, and
302    /// finished only if the trial pays. A scan or a rotating-lidar capture
303    /// written along its scan lines is already in an order no curve improves
304    /// on, and comes out exactly as it would without this, where a spatial
305    /// order would have made it larger.
306    ///
307    /// **The effort follows `encoding_speed`**, the way it does elsewhere:
308    /// speed 9 and 10 write the curve and nothing more, 7 and 8 count the 8
309    /// dearest attribute columns over a window of 8 points, 5 and 6 (the
310    /// default) 16 over 16, 3 and 4 32 over 32, and 0 to 2 the 64 dearest over
311    /// 64.
312    ///
313    /// Off by default for the reason the prediction search is: it changes the
314    /// bytes, and this crate's default is to match upstream C++ Draco.
315    ///
316    /// **This reorders the decoded points**, and unlike
317    /// [`set_spatial_point_order`](Self::set_spatial_point_order) it makes no
318    /// promise that they are spatially near one another: a point may sit
319    /// beside one that looks like it rather than one that is close. If the
320    /// search keeps the order it was handed and `set_spatial_point_order` is
321    /// also on, the spatial order is what is written.
322    ///
323    /// Applies to the sequential coder, like the order option does.
324    pub fn set_point_order_search(&mut self, enabled: bool) {
325        self.set_global_int("point_order_search", i32::from(enabled));
326    }
327
328    /// The threads asked of this encode: 1, the default, is the calling thread
329    /// alone, 0 is as many as the machine has, and no count is taken past
330    /// sixteen.
331    pub fn get_threads(&self) -> i32 {
332        self.get_global_int("threads", 1)
333    }
334
335    /// Caps the threads an encode may use; `1`, the default, keeps everything
336    /// on the calling thread and `0` takes as many as the machine has. Neither
337    /// a count asked for nor the machine's is taken past sixteen.
338    ///
339    /// One by default because a library cannot see what its caller already
340    /// runs: a server encoding on a pool of its own, or a converter working
341    /// through files side by side, would have every encode multiply its
342    /// threads by up to sixteen. A caller with the cores to spare, encoding a
343    /// large cloud at a time, asks for them.
344    ///
345    /// A sequential point cloud's attributes are encoded side by side, each
346    /// into a buffer of its own that is appended in order, and an attribute of
347    /// a million values or more also cuts its own passes -- quantization, the
348    /// gather, the prediction, the symbol plan -- into pieces; only the rANS
349    /// write stays one chain. The point order search and the spatial curve run
350    /// on the same threads. A large cloud of many attributes, a Gaussian splat
351    /// for one, encodes several times faster on many threads. Below 2^17 values
352    /// in all, counted as points times attributes, no thread is started.
353    ///
354    /// Only work that splits into pieces independent of one another is run on
355    /// threads, and each piece's result is the same whichever thread works it,
356    /// so the stream does not depend on this. The calling thread is one of
357    /// them, and a thread the system refuses to start is one fewer rather than
358    /// an error: the encode finishes on those that did. On WebAssembly there
359    /// are no threads and the value is ignored.
360    pub fn set_threads(&mut self, threads: i32) {
361        self.set_global_int("threads", threads);
362    }
363
364    /// Returns the forced encoding method, if one was set.
365    pub fn get_encoding_method(&self) -> Option<i32> {
366        self.inner.global_options.get("encoding_method").cloned()
367    }
368
369    /// Forces an encoding method by numeric Draco method id.
370    pub fn set_encoding_method(&mut self, value: i32) {
371        self.set_global_int("encoding_method", value);
372    }
373
374    /// Sets the target Draco bitstream version.
375    pub fn set_version(&mut self, major: u8, minor: u8) {
376        self.set_global_int("version_major", major as i32);
377        self.set_global_int("version_minor", minor as i32);
378    }
379
380    /// Returns the target Draco bitstream version, or `(0, 0)` for default.
381    pub fn get_version(&self) -> (u8, u8) {
382        let major = self.get_global_int("version_major", -1);
383        let minor = self.get_global_int("version_minor", -1);
384        if major == -1 || minor == -1 {
385            // Default version depends on the encoder type and method,
386            // but we'll return (0, 0) to indicate "use default".
387            (0, 0)
388        } else {
389            (major as u8, minor as u8)
390        }
391    }
392
393    /// Sets a global integer option.
394    pub fn set_global_int(&mut self, key: &str, value: i32) {
395        Arc::make_mut(&mut self.inner)
396            .global_options
397            .insert(key.to_string(), value);
398    }
399
400    /// Returns a global integer option or the supplied default.
401    pub fn get_global_int(&self, key: &str, default_val: i32) -> i32 {
402        *self.inner.global_options.get(key).unwrap_or(&default_val)
403    }
404
405    /// Sets an integer option for one attribute id.
406    pub fn set_attribute_int(&mut self, att_id: i32, key: &str, value: i32) {
407        Arc::make_mut(&mut self.inner)
408            .attribute_options
409            .entry(att_id)
410            .or_default()
411            .insert(key.to_string(), value);
412    }
413
414    /// Returns an attribute integer option, falling back to the global value.
415    pub fn get_attribute_int(&self, att_id: i32, key: &str, default_val: i32) -> i32 {
416        if let Some(opts) = self.inner.attribute_options.get(&att_id) {
417            if let Some(val) = opts.get(key) {
418                return *val;
419            }
420        }
421        // Falls back to the global value, as upstream does.
422        self.get_global_int(key, default_val)
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    /// `draco_encoder`'s own default is `-cl 7`; this crate's is speed 5.
431    /// Verified against the CLI: `EncoderOptions::new()` and
432    /// `draco_encoder -cl 5 -qp 0` produce byte-identical output.
433    #[test]
434    fn compression_level_defaults_to_5_not_the_cli_default_of_7() {
435        let options = EncoderOptions::new();
436        assert_eq!(options.get_compression_level(), 5);
437    }
438
439    /// `speed = 10 - compression_level`, matching `draco_encoder.cc`'s own
440    /// conversion before it calls `SetSpeedOptions`.
441    #[test]
442    fn set_compression_level_matches_the_cli_conversion() {
443        let mut options = EncoderOptions::new();
444        options.set_compression_level(7);
445        assert_eq!(options.get_speed(), 3);
446        assert_eq!(options.get_encoding_speed(), 3);
447        assert_eq!(options.get_decoding_speed(), 3);
448        assert_eq!(options.get_compression_level(), 7);
449    }
450
451    #[test]
452    fn set_compression_level_round_trips_the_full_cli_range() {
453        for level in 0..=10 {
454            let mut options = EncoderOptions::new();
455            options.set_compression_level(level);
456            assert_eq!(options.get_compression_level(), level);
457        }
458    }
459
460    /// A clone shares the maps, and a setter on either side afterwards
461    /// changes only the side it was called on.
462    #[test]
463    fn a_clone_shares_the_maps_until_one_side_writes() {
464        let mut options = EncoderOptions::new();
465        options.set_global_int("quantization_bits", 14);
466        options.set_attribute_int(1, "quantization_bits", 10);
467        let mut cloned = options.clone();
468        assert!(Arc::ptr_eq(&options.inner, &cloned.inner));
469
470        cloned.set_attribute_int(1, "quantization_bits", 8);
471        options.set_global_int("quantization_bits", 12);
472        assert!(!Arc::ptr_eq(&options.inner, &cloned.inner));
473        assert_eq!(options.get_attribute_int(1, "quantization_bits", 0), 10);
474        assert_eq!(options.get_global_int("quantization_bits", 0), 12);
475        assert_eq!(cloned.get_attribute_int(1, "quantization_bits", 0), 8);
476        assert_eq!(cloned.get_global_int("quantization_bits", 0), 14);
477    }
478
479    #[test]
480    fn attribute_quantization_defaults_to_unset() {
481        let options = EncoderOptions::new();
482        assert_eq!(options.get_attribute_quantization(0), -1);
483    }
484
485    #[test]
486    fn set_attribute_quantization_round_trips() {
487        let mut options = EncoderOptions::new();
488        options.set_attribute_quantization(0, 14);
489        options.set_attribute_quantization(1, 10);
490
491        assert_eq!(options.get_attribute_quantization(0), 14);
492        assert_eq!(options.get_attribute_quantization(1), 10);
493        // Unset attributes fall back to the global value, same as
494        // `get_attribute_int` -- there is none here, so -1.
495        assert_eq!(options.get_attribute_quantization(2), -1);
496    }
497}