Skip to main content

qualia_core_db/render/
lod_chain.rs

1//! P5.8 — LOD chain: author mesh → decimate N LODs → serialize to `.10d`
2//! → renderer parses each level → `plan_view` selects the expected LOD.
3//!
4//! This module is the serial integrator spine connecting P5.7 (`decimate_3`)
5//! to the `.10d` container format and the authoring planner (`authoring.rs`).
6//!
7//! ## Pipeline
8//!
9//! 1. **Author**: a source `Mesh` (the highest-LOD asset).
10//! 2. **Decimate**: produce N LOD levels by successive QEM decimation, each
11//!    targeting a fraction of the previous level's triangle count.
12//! 3. **Serialize**: encode each LOD as a `.10d` QuantizedMesh section
13//!    (`mesh_section.rs`), concatenated into a single buffer.
14//! 4. **Select**: at render time, `select_lod` picks the appropriate LOD index
15//!    based on `OperationalMode` (Full → LOD 0, Eco → LOD 1, Reserve → LOD 2).
16//! 5. **Parse**: the renderer decodes the selected section back to a `Mesh`.
17//!
18//! ## Determinism
19//!
20//! Decimation is deterministic (canonical vertex remap, sorted collapses).
21//! `.10d` encoding is deterministic (quantization is pure, section order is
22//! ascending). Two encodes of the same LOD chain produce byte-identical output.
23//!
24//! ## Budget rail
25//!
26//! The `plan_view` integration adds a `LodDisposition` that combines the
27//! existing governance/attestation gates with LOD selection. On a constrained
28//! tier (`Eco`/`Reserve`), a `Scene3D` view selects a coarser LOD rather than
29//! collapsing to 2D — *if* a coarser LOD is available. If no coarser LOD
30//! exists, the existing `Collapsed2D` fallback applies.
31
32use crate::container_10d::mesh_section::{
33    decode_mesh_section, encode_mesh_section, encoded_len, MeshSectionError,
34};
35use crate::gpu_context::OperationalMode;
36use crate::render::assets::Mesh;
37use crate::specialized_libs::computational_geometry::{
38    decimate_qem, DecimateError, DecimateOptions, DecimateReport, Point3,
39};
40
41// ───────────────────────────────────────────────────────────────────────────
42//  Errors
43// ───────────────────────────────────────────────────────────────────────────
44
45/// Failure modes for the LOD chain.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum LodChainError {
48    /// Decimation failed at the given LOD level.
49    DecimateFailed { level: usize, cause: DecimateError },
50    /// `.10d` encoding failed at the given LOD level.
51    EncodeFailed {
52        level: usize,
53        cause: MeshSectionError,
54    },
55    /// `.10d` decoding failed at the given LOD level.
56    DecodeFailed {
57        level: usize,
58        cause: MeshSectionError,
59    },
60    /// Output buffer too small for the serialized LOD chain.
61    BufferTooSmall { needed: usize, have: usize },
62    /// No LOD levels were requested.
63    NoLodLevels,
64    /// The source mesh is empty (no vertices or triangles).
65    EmptySourceMesh,
66}
67
68impl core::fmt::Display for LodChainError {
69    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
70        match self {
71            Self::DecimateFailed { level, cause } => {
72                write!(
73                    f,
74                    "lod_chain: decimation failed at level {level}: {cause:?}"
75                )
76            }
77            Self::EncodeFailed { level, cause } => {
78                write!(f, "lod_chain: encode failed at level {level}: {cause}")
79            }
80            Self::DecodeFailed { level, cause } => {
81                write!(f, "lod_chain: decode failed at level {level}: {cause}")
82            }
83            Self::BufferTooSmall { needed, have } => {
84                write!(f, "lod_chain: buffer too small, need {needed}, have {have}")
85            }
86            Self::NoLodLevels => write!(f, "lod_chain: no LOD levels requested"),
87            Self::EmptySourceMesh => write!(f, "lod_chain: source mesh is empty"),
88        }
89    }
90}
91
92impl std::error::Error for LodChainError {}
93
94// ───────────────────────────────────────────────────────────────────────────
95//  LOD chain configuration
96// ───────────────────────────────────────────────────────────────────────────
97
98/// Maximum number of LOD levels supported (including LOD 0 = full resolution).
99pub const MAX_LOD_LEVELS: usize = 8;
100
101/// LOD chain configuration: how many levels and what fraction to decimate to
102/// at each level.
103#[derive(Debug, Clone, Copy)]
104pub struct LodChainOptions {
105    /// Number of LOD levels (including LOD 0 = the source mesh). Must be ≥ 1.
106    pub level_count: usize,
107    /// Triangle-count fraction at each successive LOD level. E.g. 0.5 means
108    /// each level has half the triangles of the previous. LOD 0 is always the
109    /// full mesh; LOD 1 targets `fraction * LOD0_faces`, etc.
110    pub fraction: f64,
111}
112
113impl Default for LodChainOptions {
114    fn default() -> Self {
115        Self {
116            level_count: 3,
117            fraction: 0.5,
118        }
119    }
120}
121
122impl LodChainOptions {
123    /// Create a configuration with `level_count` levels and the given fraction.
124    pub fn new(level_count: usize, fraction: f64) -> Self {
125        Self {
126            level_count: level_count.clamp(1, MAX_LOD_LEVELS),
127            fraction: fraction.clamp(0.01, 1.0),
128        }
129    }
130
131    /// 3 levels (Full / Eco / Reserve) at 50% per level.
132    pub fn default_3_tier() -> Self {
133        Self {
134            level_count: 3,
135            fraction: 0.5,
136        }
137    }
138}
139
140/// Report for one LOD level after building the chain.
141#[derive(Debug, Clone, Copy)]
142pub struct LodLevelReport {
143    /// LOD index (0 = full resolution).
144    pub level: usize,
145    /// Vertex count at this level.
146    pub vertices: usize,
147    /// Triangle count at this level.
148    pub triangles: usize,
149    /// Encoded `.10d` section byte length.
150    pub encoded_bytes: usize,
151    /// Decimation report (None for LOD 0).
152    pub decimate_report: Option<DecimateReport>,
153}
154
155/// Overall LOD chain build report.
156#[derive(Debug, Clone)]
157pub struct LodChainReport {
158    pub levels: Vec<LodLevelReport>,
159    /// Total serialized byte length (all sections concatenated).
160    pub total_bytes: usize,
161}
162
163// ───────────────────────────────────────────────────────────────────────────
164//  LOD chain builder
165// ───────────────────────────────────────────────────────────────────────────
166
167/// Build a LOD chain from a source mesh and serialize all levels as `.10d`
168/// QuantizedMesh sections into `out_buffer`.
169///
170/// Returns a report with per-level stats and the total bytes written.
171///
172/// This is a cold builder (uses `Vec` scratch for decimation and encoding).
173/// The output is caller-owned and deterministic.
174pub fn build_lod_chain(
175    source: &Mesh,
176    options: LodChainOptions,
177    out_buffer: &mut [u8],
178) -> Result<LodChainReport, LodChainError> {
179    if source.positions.is_empty() || source.triangles.is_empty() {
180        return Err(LodChainError::EmptySourceMesh);
181    }
182    if options.level_count == 0 {
183        return Err(LodChainError::NoLodLevels);
184    }
185
186    let mut levels: Vec<LodLevelReport> = Vec::with_capacity(options.level_count);
187    let mut offset = 0usize;
188
189    // LOD 0: encode the source mesh directly (no decimation).
190    let lod0_bytes = encoded_len(source.positions.len(), source.triangles.len());
191    if offset + lod0_bytes > out_buffer.len() {
192        return Err(LodChainError::BufferTooSmall {
193            needed: offset + lod0_bytes,
194            have: out_buffer.len(),
195        });
196    }
197    let written = encode_mesh_section(source, &mut out_buffer[offset..])
198        .map_err(|e| LodChainError::EncodeFailed { level: 0, cause: e })?;
199    levels.push(LodLevelReport {
200        level: 0,
201        vertices: source.positions.len(),
202        triangles: source.triangles.len(),
203        encoded_bytes: written,
204        decimate_report: None,
205    });
206    offset += written;
207
208    // Successive LOD levels: decimate the previous level.
209    let mut current_verts_f64: Vec<Point3> = source
210        .positions
211        .iter()
212        .map(|p| Point3::new(p[0] as f64, p[1] as f64, p[2] as f64))
213        .collect();
214    let mut current_tris: Vec<[u32; 3]> = source.triangles.clone();
215
216    for level in 1..options.level_count {
217        let target_faces = (current_tris.len() as f64 * options.fraction) as usize;
218        if target_faces < 4 {
219            // Can't decimate below 4 triangles; stop early.
220            break;
221        }
222
223        let decim_opts = DecimateOptions::to_faces(target_faces);
224        let mut out_v = vec![Point3::default(); current_verts_f64.len()];
225        let mut out_t = vec![[0u32; 3]; current_tris.len()];
226
227        let report = decimate_qem(
228            &current_verts_f64,
229            &current_tris,
230            decim_opts,
231            &mut out_v,
232            &mut out_t,
233        )
234        .map_err(|e| LodChainError::DecimateFailed { level, cause: e })?;
235
236        // Compact to actual counts.
237        let live_v = report.vertices;
238        let live_t = report.faces;
239        current_verts_f64 = out_v[..live_v].to_vec();
240        current_tris = out_t[..live_t].to_vec();
241
242        // Convert back to f32 for encoding.
243        let f32_positions: Vec<[f32; 3]> = current_verts_f64
244            .iter()
245            .map(|p| [p.x as f32, p.y as f32, p.z as f32])
246            .collect();
247
248        // Encode this LOD level.
249        let lod_mesh = Mesh {
250            positions: f32_positions.clone(),
251            triangles: current_tris.clone(),
252            min: [
253                f32_positions
254                    .iter()
255                    .map(|p| p[0])
256                    .fold(f32::INFINITY, f32::min),
257                f32_positions
258                    .iter()
259                    .map(|p| p[1])
260                    .fold(f32::INFINITY, f32::min),
261                f32_positions
262                    .iter()
263                    .map(|p| p[2])
264                    .fold(f32::INFINITY, f32::min),
265            ],
266            max: [
267                f32_positions
268                    .iter()
269                    .map(|p| p[0])
270                    .fold(f32::NEG_INFINITY, f32::max),
271                f32_positions
272                    .iter()
273                    .map(|p| p[1])
274                    .fold(f32::NEG_INFINITY, f32::max),
275                f32_positions
276                    .iter()
277                    .map(|p| p[2])
278                    .fold(f32::NEG_INFINITY, f32::max),
279            ],
280        };
281
282        let lod_bytes = encoded_len(live_v, live_t);
283        if offset + lod_bytes > out_buffer.len() {
284            return Err(LodChainError::BufferTooSmall {
285                needed: offset + lod_bytes,
286                have: out_buffer.len(),
287            });
288        }
289        let written = encode_mesh_section(&lod_mesh, &mut out_buffer[offset..])
290            .map_err(|e| LodChainError::EncodeFailed { level, cause: e })?;
291        levels.push(LodLevelReport {
292            level,
293            vertices: live_v,
294            triangles: live_t,
295            encoded_bytes: written,
296            decimate_report: Some(report),
297        });
298        offset += written;
299    }
300
301    Ok(LodChainReport {
302        levels,
303        total_bytes: offset,
304    })
305}
306
307/// Compute the total buffer size needed for a LOD chain with the given options
308/// and source mesh size. This is an upper bound (actual may be smaller if
309/// decimation stops early).
310pub fn required_lod_buffer_size(
311    source_vertices: usize,
312    source_triangles: usize,
313    options: LodChainOptions,
314) -> usize {
315    let mut total = 0usize;
316    let mut verts = source_vertices;
317    let mut tris = source_triangles;
318
319    for level in 0..options.level_count {
320        total += encoded_len(verts, tris);
321        if level > 0 {
322            let target = (tris as f64 * options.fraction) as usize;
323            if target < 4 {
324                break;
325            }
326            // Estimate vertex count reduction proportionally.
327            verts = (verts as f64 * options.fraction) as usize;
328            tris = target;
329        }
330    }
331    total
332}
333
334// ───────────────────────────────────────────────────────────────────────────
335//  LOD selection
336// ───────────────────────────────────────────────────────────────────────────
337
338/// Select the appropriate LOD index for a given `OperationalMode`.
339///
340/// - `Full` → LOD 0 (full resolution)
341/// - `Eco` → LOD 1 (half resolution, or the coarsest available if < 2 levels)
342/// - `Reserve` → LOD 2 (quarter resolution, or the coarsest available if < 3 levels)
343///
344/// Returns the LOD index clamped to `[0, level_count-1]`.
345#[inline]
346pub fn select_lod(mode: OperationalMode, level_count: usize) -> usize {
347    if level_count == 0 {
348        return 0;
349    }
350    let preferred = match mode {
351        OperationalMode::Full => 0,
352        OperationalMode::Eco => 1,
353        OperationalMode::Reserve => 2,
354    };
355    preferred.min(level_count - 1)
356}
357
358// ───────────────────────────────────────────────────────────────────────────
359//  LOD section parsing
360// ───────────────────────────────────────────────────────────────────────────
361
362/// Parse a specific LOD level from a serialized LOD chain buffer.
363///
364/// The `level_offsets` are the byte offsets of each LOD section within the
365/// buffer (as reported by `LodChainReport`). Returns the decoded `Mesh`.
366pub fn parse_lod_level(
367    buffer: &[u8],
368    level_offsets: &[usize],
369    level: usize,
370) -> Result<Mesh, LodChainError> {
371    if level >= level_offsets.len() {
372        return Err(LodChainError::DecodeFailed {
373            level,
374            cause: MeshSectionError::PayloadTooShort { got: 0, need: 40 },
375        });
376    }
377    let offset = level_offsets[level];
378    let end = if level + 1 < level_offsets.len() {
379        level_offsets[level + 1]
380    } else {
381        buffer.len()
382    };
383    let section_bytes = &buffer[offset..end];
384    decode_mesh_section(section_bytes).map_err(|e| LodChainError::DecodeFailed { level, cause: e })
385}
386
387/// Extract the per-level byte offsets from a `LodChainReport`.
388pub fn level_offsets(report: &LodChainReport) -> Vec<usize> {
389    let mut offsets = Vec::with_capacity(report.levels.len());
390    let mut acc = 0;
391    for lvl in &report.levels {
392        offsets.push(acc);
393        acc += lvl.encoded_bytes;
394    }
395    offsets
396}
397
398// ───────────────────────────────────────────────────────────────────────────
399//  plan_view integration
400// ───────────────────────────────────────────────────────────────────────────
401
402/// The LOD-aware disposition for a view, extending `ViewDisposition` with
403/// LOD selection for 3D scenes that have a LOD chain available.
404#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub enum LodViewDisposition {
406    /// Render the 3D scene at the given LOD level.
407    Render3dWithLod { lod: usize },
408    /// Render the 2D pane (no LOD needed).
409    Render2d,
410    /// A `Scene3D` view degraded to 2D under a constrained device budget with
411    /// no coarser LOD available.
412    Collapsed2D,
413    /// Attestation-gated and not yet attested — withheld.
414    WithheldUnattested,
415    /// Sensitive content in a shared/civic standpoint without consent — refused.
416    RefusedRightsBounded,
417}
418
419/// Plan a view with LOD awareness. Applies the same gate order as
420/// `authoring::plan_view` (attestation → rights → budget), but instead of
421/// collapsing 3D to 2D on constrained tiers, selects a coarser LOD if
422/// available.
423#[allow(clippy::too_many_arguments)]
424pub fn plan_view_with_lod(
425    view: &crate::render::authoring::QappView,
426    standpoint: &crate::render::authoring::RenderStandpoint,
427    mode: OperationalMode,
428    attestations: &[crate::NQuin],
429    gov_norms: &[crate::NQuin],
430    now_unix: u32,
431    lod_level_count: usize,
432) -> LodViewDisposition {
433    use crate::render::authoring::{has_attestation, Sensitivity, ViewKind};
434
435    // 1) Attestation gate.
436    if view.requires_attestation && !has_attestation(view, attestations) {
437        return LodViewDisposition::WithheldUnattested;
438    }
439
440    // 2) Rights-bounded context.
441    if matches!(view.sensitivity, Sensitivity::RightsBounded)
442        && !crate::render::authoring::rights_render_permitted(
443            standpoint,
444            view.manifold,
445            gov_norms,
446            now_unix,
447        )
448    {
449        return LodViewDisposition::RefusedRightsBounded;
450    }
451
452    // 3) Budget + LOD selection.
453    match view.kind {
454        ViewKind::Pane2D => LodViewDisposition::Render2d,
455        ViewKind::Scene3D => {
456            if mode.supports_3d() {
457                LodViewDisposition::Render3dWithLod {
458                    lod: select_lod(mode, lod_level_count),
459                }
460            } else if lod_level_count > 1 {
461                // Constrained tier but coarser LODs available → use the coarsest.
462                LodViewDisposition::Render3dWithLod {
463                    lod: select_lod(mode, lod_level_count),
464                }
465            } else {
466                // No coarser LOD → collapse to 2D.
467                LodViewDisposition::Collapsed2D
468            }
469        }
470    }
471}
472
473// ───────────────────────────────────────────────────────────────────────────
474//  Hash stability
475// ───────────────────────────────────────────────────────────────────────────
476
477/// Compute a simple FNV-1a hash over a byte slice (for determinism verification).
478pub fn lod_chain_hash(bytes: &[u8]) -> u64 {
479    let mut hash: u64 = 0xcbf29ce484222325;
480    for &b in bytes {
481        hash ^= b as u64;
482        hash = hash.wrapping_mul(0x100000001b3);
483    }
484    hash
485}
486
487// ───────────────────────────────────────────────────────────────────────────
488//  Tests
489// ───────────────────────────────────────────────────────────────────────────
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::q_hash;
495    use crate::render::authoring::{
496        attestation_quin, plan_view, QappView, RenderStandpoint, Sensitivity, ViewDisposition,
497        ViewKind,
498    };
499
500    fn unit_cube_mesh() -> Mesh {
501        let positions = vec![
502            [0.0f32, 0.0, 0.0],
503            [1.0, 0.0, 0.0],
504            [1.0, 1.0, 0.0],
505            [0.0, 1.0, 0.0],
506            [0.0, 0.0, 1.0],
507            [1.0, 0.0, 1.0],
508            [1.0, 1.0, 1.0],
509            [0.0, 1.0, 1.0],
510        ];
511        let triangles = vec![
512            [0, 3, 2],
513            [0, 2, 1],
514            [4, 5, 6],
515            [4, 6, 7],
516            [0, 1, 5],
517            [0, 5, 4],
518            [3, 7, 6],
519            [3, 6, 2],
520            [0, 4, 7],
521            [0, 7, 3],
522            [1, 2, 6],
523            [1, 6, 5],
524        ];
525        Mesh {
526            positions,
527            triangles,
528            min: [0.0, 0.0, 0.0],
529            max: [1.0, 1.0, 1.0],
530        }
531    }
532
533    fn subdivided_cube_mesh(subdivs: usize) -> Mesh {
534        let s = subdivs as f32;
535        let mut positions = Vec::new();
536        for i in 0..=subdivs {
537            for j in 0..=subdivs {
538                for k in 0..=subdivs {
539                    positions.push([i as f32 / s, j as f32 / s, k as f32 / s]);
540                }
541            }
542        }
543        // Build triangles for each face of the cube.
544        let mut triangles = Vec::new();
545        let idx = |i: usize, j: usize, k: usize| -> u32 {
546            (i * (subdivs + 1) * (subdivs + 1) + j * (subdivs + 1) + k) as u32
547        };
548        // Bottom (z=0, facing down)
549        for i in 0..subdivs {
550            for j in 0..subdivs {
551                triangles.push([idx(i, j, 0), idx(i, j + 1, 0), idx(i + 1, j + 1, 0)]);
552                triangles.push([idx(i, j, 0), idx(i + 1, j + 1, 0), idx(i + 1, j, 0)]);
553            }
554        }
555        // Top (z=1, facing up)
556        for i in 0..subdivs {
557            for j in 0..subdivs {
558                triangles.push([
559                    idx(i, j, subdivs),
560                    idx(i + 1, j + 1, subdivs),
561                    idx(i, j + 1, subdivs),
562                ]);
563                triangles.push([
564                    idx(i, j, subdivs),
565                    idx(i + 1, j, subdivs),
566                    idx(i + 1, j + 1, subdivs),
567                ]);
568            }
569        }
570        // Front (y=0, facing -y)
571        for i in 0..subdivs {
572            for k in 0..subdivs {
573                triangles.push([idx(i, 0, k), idx(i + 1, 0, k + 1), idx(i, 0, k + 1)]);
574                triangles.push([idx(i, 0, k), idx(i + 1, 0, k), idx(i + 1, 0, k + 1)]);
575            }
576        }
577        // Back (y=1, facing +y)
578        for i in 0..subdivs {
579            for k in 0..subdivs {
580                triangles.push([
581                    idx(i, subdivs, k),
582                    idx(i, subdivs, k + 1),
583                    idx(i + 1, subdivs, k + 1),
584                ]);
585                triangles.push([
586                    idx(i, subdivs, k),
587                    idx(i + 1, subdivs, k + 1),
588                    idx(i + 1, subdivs, k),
589                ]);
590            }
591        }
592        // Left (x=0, facing -x)
593        for j in 0..subdivs {
594            for k in 0..subdivs {
595                triangles.push([idx(0, j, k), idx(0, j, k + 1), idx(0, j + 1, k + 1)]);
596                triangles.push([idx(0, j, k), idx(0, j + 1, k + 1), idx(0, j + 1, k)]);
597            }
598        }
599        // Right (x=1, facing +x)
600        for j in 0..subdivs {
601            for k in 0..subdivs {
602                triangles.push([
603                    idx(subdivs, j, k),
604                    idx(subdivs, j + 1, k + 1),
605                    idx(subdivs, j, k + 1),
606                ]);
607                triangles.push([
608                    idx(subdivs, j, k),
609                    idx(subdivs, j + 1, k),
610                    idx(subdivs, j + 1, k + 1),
611                ]);
612            }
613        }
614        let min = [0.0f32; 3];
615        let max = [1.0f32; 3];
616        Mesh {
617            positions,
618            triangles,
619            min,
620            max,
621        }
622    }
623
624    #[test]
625    fn build_lod_chain_3_levels() {
626        let mesh = subdivided_cube_mesh(4); // 125 verts, 96 tris
627        let options = LodChainOptions::default_3_tier();
628        let buf_size =
629            required_lod_buffer_size(mesh.positions.len(), mesh.triangles.len(), options);
630        let mut buf = vec![0u8; buf_size];
631        let report = build_lod_chain(&mesh, options, &mut buf).unwrap();
632
633        assert!(
634            report.levels.len() >= 2,
635            "should produce at least 2 LOD levels"
636        );
637        assert_eq!(report.levels[0].level, 0);
638        assert_eq!(report.levels[0].vertices, mesh.positions.len());
639        assert_eq!(report.levels[0].triangles, mesh.triangles.len());
640        assert_eq!(
641            report.total_bytes,
642            report.levels.iter().map(|l| l.encoded_bytes).sum::<usize>()
643        );
644    }
645
646    #[test]
647    fn lod_chain_hash_stable() {
648        let mesh = subdivided_cube_mesh(3);
649        let options = LodChainOptions::default_3_tier();
650        let buf_size =
651            required_lod_buffer_size(mesh.positions.len(), mesh.triangles.len(), options);
652
653        let mut buf1 = vec![0u8; buf_size];
654        let mut buf2 = vec![0u8; buf_size];
655        let report1 = build_lod_chain(&mesh, options, &mut buf1).unwrap();
656        let report2 = build_lod_chain(&mesh, options, &mut buf2).unwrap();
657
658        assert_eq!(report1.total_bytes, report2.total_bytes);
659        let h1 = lod_chain_hash(&buf1[..report1.total_bytes]);
660        let h2 = lod_chain_hash(&buf2[..report2.total_bytes]);
661        assert_eq!(
662            h1, h2,
663            "LOD chain bytes must be hash-stable across two encodes"
664        );
665    }
666
667    #[test]
668    fn lod_chain_round_trip() {
669        let mesh = subdivided_cube_mesh(3);
670        let options = LodChainOptions::default_3_tier();
671        let buf_size =
672            required_lod_buffer_size(mesh.positions.len(), mesh.triangles.len(), options);
673        let mut buf = vec![0u8; buf_size];
674        let report = build_lod_chain(&mesh, options, &mut buf).unwrap();
675
676        let offsets = level_offsets(&report);
677
678        // Parse LOD 0 and verify it matches the source.
679        let lod0 = parse_lod_level(&buf, &offsets, 0).unwrap();
680        assert_eq!(lod0.positions.len(), mesh.positions.len());
681        assert_eq!(lod0.triangles.len(), mesh.triangles.len());
682
683        // Parse any additional LODs.
684        for (i, lvl) in report.levels.iter().enumerate() {
685            let decoded = parse_lod_level(&buf, &offsets, i).unwrap();
686            assert_eq!(
687                decoded.positions.len(),
688                lvl.vertices,
689                "LOD {i} vertex count mismatch"
690            );
691            assert_eq!(
692                decoded.triangles.len(),
693                lvl.triangles,
694                "LOD {i} triangle count mismatch"
695            );
696        }
697    }
698
699    #[test]
700    fn select_lod_by_mode() {
701        assert_eq!(select_lod(OperationalMode::Full, 3), 0);
702        assert_eq!(select_lod(OperationalMode::Eco, 3), 1);
703        assert_eq!(select_lod(OperationalMode::Reserve, 3), 2);
704        // Clamping: if only 2 levels, Reserve → LOD 1.
705        assert_eq!(select_lod(OperationalMode::Reserve, 2), 1);
706        // If only 1 level, all modes → LOD 0.
707        assert_eq!(select_lod(OperationalMode::Reserve, 1), 0);
708    }
709
710    #[test]
711    fn lod_chain_decreasing_triangle_counts() {
712        let mesh = subdivided_cube_mesh(5);
713        let options = LodChainOptions::default_3_tier();
714        let buf_size =
715            required_lod_buffer_size(mesh.positions.len(), mesh.triangles.len(), options);
716        let mut buf = vec![0u8; buf_size];
717        let report = build_lod_chain(&mesh, options, &mut buf).unwrap();
718
719        for i in 1..report.levels.len() {
720            assert!(
721                report.levels[i].triangles <= report.levels[i - 1].triangles,
722                "LOD {i} should have ≤ triangles than LOD {}",
723                i - 1
724            );
725        }
726    }
727
728    #[test]
729    fn plan_view_with_lod_selects_coarser_on_eco() {
730        let m = q_hash("urn:qualia:manifold:demo");
731        let view = QappView::public(m, ViewKind::Scene3D);
732        let standpoint = RenderStandpoint {
733            id: q_hash("urn:qualia:standpoint:owner"),
734            shared_civic: false,
735        };
736
737        // Full → LOD 0.
738        assert_eq!(
739            plan_view_with_lod(&view, &standpoint, OperationalMode::Full, &[], &[], 100, 3),
740            LodViewDisposition::Render3dWithLod { lod: 0 }
741        );
742        // Eco → LOD 1.
743        assert_eq!(
744            plan_view_with_lod(&view, &standpoint, OperationalMode::Eco, &[], &[], 100, 3),
745            LodViewDisposition::Render3dWithLod { lod: 1 }
746        );
747        // Reserve → LOD 2.
748        assert_eq!(
749            plan_view_with_lod(
750                &view,
751                &standpoint,
752                OperationalMode::Reserve,
753                &[],
754                &[],
755                100,
756                3
757            ),
758            LodViewDisposition::Render3dWithLod { lod: 2 }
759        );
760    }
761
762    #[test]
763    fn plan_view_with_lod_collapses_when_no_coarser_lod() {
764        let m = q_hash("urn:qualia:manifold:demo");
765        let view = QappView::public(m, ViewKind::Scene3D);
766        let standpoint = RenderStandpoint {
767            id: q_hash("urn:qualia:standpoint:owner"),
768            shared_civic: false,
769        };
770
771        // Only 1 LOD level → Eco collapses to 2D.
772        assert_eq!(
773            plan_view_with_lod(&view, &standpoint, OperationalMode::Eco, &[], &[], 100, 1),
774            LodViewDisposition::Collapsed2D
775        );
776    }
777
778    #[test]
779    fn plan_view_with_lod_2d_pane_ignores_lod() {
780        let m = q_hash("urn:qualia:manifold:demo");
781        let view = QappView::public(m, ViewKind::Pane2D);
782        let standpoint = RenderStandpoint {
783            id: q_hash("urn:qualia:standpoint:owner"),
784            shared_civic: false,
785        };
786
787        assert_eq!(
788            plan_view_with_lod(&view, &standpoint, OperationalMode::Full, &[], &[], 100, 3),
789            LodViewDisposition::Render2d
790        );
791        assert_eq!(
792            plan_view_with_lod(&view, &standpoint, OperationalMode::Eco, &[], &[], 100, 3),
793            LodViewDisposition::Render2d
794        );
795    }
796
797    #[test]
798    fn plan_view_with_lod_attestation_gate() {
799        let m = q_hash("urn:qualia:manifold:demo");
800        let view = QappView {
801            manifold: m,
802            kind: ViewKind::Scene3D,
803            sensitivity: Sensitivity::Public,
804            requires_attestation: true,
805        };
806        let standpoint = RenderStandpoint {
807            id: q_hash("urn:qualia:standpoint:owner"),
808            shared_civic: false,
809        };
810
811        // No attestation → withheld.
812        assert_eq!(
813            plan_view_with_lod(&view, &standpoint, OperationalMode::Full, &[], &[], 100, 3),
814            LodViewDisposition::WithheldUnattested
815        );
816
817        // With attestation → rendered at LOD 0.
818        let att = attestation_quin(
819            q_hash("did:example:auditor"),
820            m,
821            q_hash("urn:qualia:frame:app"),
822        );
823        assert_eq!(
824            plan_view_with_lod(
825                &view,
826                &standpoint,
827                OperationalMode::Full,
828                &[att],
829                &[],
830                100,
831                3
832            ),
833            LodViewDisposition::Render3dWithLod { lod: 0 }
834        );
835    }
836
837    #[test]
838    fn plan_view_with_lod_rights_bounded() {
839        let m = q_hash("urn:qualia:manifold:demo");
840        let view = QappView {
841            manifold: m,
842            kind: ViewKind::Scene3D,
843            sensitivity: Sensitivity::RightsBounded,
844            requires_attestation: false,
845        };
846        let civic = RenderStandpoint {
847            id: q_hash("urn:qualia:standpoint:civic"),
848            shared_civic: true,
849        };
850
851        // Civic, no consent → refused.
852        assert_eq!(
853            plan_view_with_lod(&view, &civic, OperationalMode::Full, &[], &[], 100, 3),
854            LodViewDisposition::RefusedRightsBounded
855        );
856    }
857
858    #[test]
859    fn existing_authoring_tests_stay_green() {
860        // Verify that the existing plan_view still works (no regression).
861        let m = q_hash("urn:qualia:manifold:demo");
862        let view = QappView::public(m, ViewKind::Scene3D);
863        let standpoint = RenderStandpoint {
864            id: q_hash("urn:qualia:standpoint:owner"),
865            shared_civic: false,
866        };
867
868        // Full tier → 3D scene rendered.
869        assert_eq!(
870            plan_view(&view, &standpoint, OperationalMode::Full, &[], &[], 100),
871            ViewDisposition::Render(ViewKind::Scene3D)
872        );
873        // Eco tier → collapsed to 2D (existing behavior, no LOD).
874        assert_eq!(
875            plan_view(&view, &standpoint, OperationalMode::Eco, &[], &[], 100),
876            ViewDisposition::Collapsed2D
877        );
878    }
879
880    #[test]
881    fn empty_source_mesh_errors() {
882        let mesh = Mesh {
883            positions: vec![],
884            triangles: vec![],
885            min: [0.0; 3],
886            max: [0.0; 3],
887        };
888        let options = LodChainOptions::default_3_tier();
889        let mut buf = vec![0u8; 1024];
890        assert!(matches!(
891            build_lod_chain(&mesh, options, &mut buf),
892            Err(LodChainError::EmptySourceMesh)
893        ));
894    }
895
896    #[test]
897    fn buffer_too_small_errors() {
898        let mesh = unit_cube_mesh();
899        let options = LodChainOptions::default_3_tier();
900        let mut buf = vec![0u8; 10]; // way too small
901        assert!(matches!(
902            build_lod_chain(&mesh, options, &mut buf),
903            Err(LodChainError::BufferTooSmall { .. })
904        ));
905    }
906
907    #[test]
908    fn single_level_lod_chain() {
909        let mesh = unit_cube_mesh();
910        let options = LodChainOptions::new(1, 0.5);
911        let buf_size =
912            required_lod_buffer_size(mesh.positions.len(), mesh.triangles.len(), options);
913        let mut buf = vec![0u8; buf_size];
914        let report = build_lod_chain(&mesh, options, &mut buf).unwrap();
915        assert_eq!(report.levels.len(), 1);
916        assert_eq!(report.levels[0].vertices, 8);
917        assert_eq!(report.levels[0].triangles, 12);
918    }
919}