indicatrix 0.8.1

A physically-based spectral gemstone renderer that turns GemCAD-style cutting instructions into rendered output.
Documentation
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
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
//! Material encoding: [`DispersionParams`] (one dispersion curve) and [`GpuGemMaterial`]
//! (a full `optics::materials::GemMaterial`), plus their `encode` constructors and the
//! discriminant modules ([`dispersion_model_type`]/[`band_shape`]/[`crystal_system`]/
//! [`optical_character`]) both sides must agree on.

use core::mem::offset_of;

use crate::optics::{
    absorption::{AbsorptionBand, BandShape},
    dispersion::DispersionModel,
    materials::{CrystalSystem, GemMaterial, OpticalCharacter},
};

/// One dispersion curve (`optics::dispersion::DispersionModel`), GPU-encoded.
///
/// `model_type` selects the interpretation of `param_a`/`param_b`:
/// - `0` (Sellmeier1 `{b1, c1}`): `param_a[0] = b1`, `param_b[0] = c1`.
/// - `1` (Sellmeier3 `{b: [f32;3], c: [f32;3]}`): `param_a[0..3] = b`, `param_b[0..3] = c`.
/// - `2` (Cauchy `{a, b, c}`): `param_a[0..3] = [a, b, c]`.
///
/// `c_axis_and_birefringence.xyz` is `GemMaterial::c_axis`; `.w` is
/// `GemMaterial::birefringence_delta`. `biaxial_delta_beta_alpha` /
/// `has_biaxial_delta` mirror `GemMaterial::biaxial_delta_beta_alpha: Option<f32>`
/// (`has_biaxial_delta != 0` <=> `Some`).
///
/// # Layout
///
/// The field after `model_type` needs 16-byte alignment (every `param_*`/
/// `c_axis_and_birefringence` field is a `vec4<f32>`), so `_pad_after_model_type`
/// reproduces WGSL's implicit 12-byte padding. `param_a` through
/// `c_axis_and_birefringence` pack back-to-back at 16 bytes each; the trailing three
/// scalars pack tightly, and `_pad_tail` reproduces WGSL's final 4-byte struct-size
/// rounding.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct DispersionParams {
    /// Model type.
    pub model_type: u32,
    _pad_after_model_type: [u32; 3],
    /// Param a.
    pub param_a: [f32; 4],
    /// Param b.
    pub param_b: [f32; 4],
    /// Param c.
    pub param_c: [f32; 4],
    /// C axis and birefringence.
    pub c_axis_and_birefringence: [f32; 4],
    /// Is anisotropic.
    pub is_anisotropic: u32,
    /// Biaxial delta beta alpha.
    pub biaxial_delta_beta_alpha: f32,
    /// Has biaxial delta.
    pub has_biaxial_delta: u32,
    _pad_tail: f32,
}

const _: () = {
    assert!(offset_of!(DispersionParams, model_type) == 0);
    assert!(offset_of!(DispersionParams, param_a) == 16);
    assert!(offset_of!(DispersionParams, param_b) == 32);
    assert!(offset_of!(DispersionParams, param_c) == 48);
    assert!(offset_of!(DispersionParams, c_axis_and_birefringence) == 64);
    assert!(offset_of!(DispersionParams, is_anisotropic) == 80);
    assert!(offset_of!(DispersionParams, biaxial_delta_beta_alpha) == 84);
    assert!(offset_of!(DispersionParams, has_biaxial_delta) == 88);
    assert!(offset_of!(DispersionParams, _pad_tail) == 92);
    assert!(size_of::<DispersionParams>() == 96);
};

/// `model_type` discriminants for [`DispersionParams`] -- must match
/// `renderer/shaders/layout_echo.wgsl` and (eventually) any real dispersion-evaluating
/// kernel.
pub mod dispersion_model_type {
    /// Identifier for sellmeier1.
    pub const SELLMEIER1: u32 = 0;
    /// Identifier for sellmeier3.
    pub const SELLMEIER3: u32 = 1;
    /// Identifier for cauchy.
    pub const CAUCHY: u32 = 2;
}

/// Hard cap on how many [`GpuAbsorptionBand`]s either eigenmode of a [`GpuGemMaterial`]
/// can carry.
///
/// `GemMaterial::absorption`'s `Vec<AbsorptionBand>` is unbounded on the CPU side, but a
/// GPU encoding needs a fixed-capacity array. Enforced on scene ingest by
/// `apps/indicatrix-worker/src/validate.rs`'s `validate_scene`, so a scene that would
/// silently truncate on the GPU is rejected before it ever gets there.
///
/// 8 is comfortably above every built-in material's real band count (the widest is 3,
/// `legacy_rgb_bands`), while staying small enough that the fixed array costs nothing
/// worth measuring for materials that use far fewer.
pub const MAX_ABSORPTION_BANDS: usize = 8;

/// One Gaussian absorption band (`optics::absorption::AbsorptionBand`), GPU-encoded.
///
/// # Layout
///
/// All four fields are 4-byte-aligned scalars (`shape` a `u32`), so this struct's WGSL
/// alignment is 4 (no vec3/vec4 field to trigger the usual pitfall) and its size is
/// exactly 16 bytes with no padding, matching Rust's natural layout. This is why
/// [`GpuGemMaterial`]'s band arrays are safe as plain `[GpuAbsorptionBand;
/// MAX_ABSORPTION_BANDS]` with no per-element padding: a storage buffer's array-stride
/// rule only requires a multiple of the element's own alignment (4), unlike a *uniform*
/// buffer's array, which would require a multiple of 16 -- [`GpuGemMaterial`] is bound
/// as storage specifically because of this.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GpuAbsorptionBand {
    /// Center nm.
    pub center_nm: f32,
    /// Width nm.
    pub width_nm: f32,
    /// Peak.
    pub peak: f32,
    /// Which domain this band is Gaussian in -- see [`band_shape`] for the
    /// discriminants, mirroring `optics::absorption::BandShape`.
    pub shape: u32,
}

const _: () = {
    assert!(offset_of!(GpuAbsorptionBand, center_nm) == 0);
    assert!(offset_of!(GpuAbsorptionBand, width_nm) == 4);
    assert!(offset_of!(GpuAbsorptionBand, peak) == 8);
    assert!(offset_of!(GpuAbsorptionBand, shape) == 12);
    assert!(size_of::<GpuAbsorptionBand>() == 16);
};

/// `optics::absorption::BandShape` discriminants for [`GpuAbsorptionBand::shape`]. Must
/// match `renderer/shaders/transport_physics.wgsl`'s `spectral_absorption`'s own
/// `band.shape == 1u` branch.
pub mod band_shape {
    /// Identifier for gaussian wavelength.
    pub const GAUSSIAN_WAVELENGTH: u32 = 0;
    /// Identifier for gaussian energy.
    pub const GAUSSIAN_ENERGY: u32 = 1;
}

/// `crystal_system` discriminants for [`GpuGemMaterial`].
///
/// Must match `renderer/shaders/layout_echo.wgsl`'s and (eventually) any real
/// material-evaluating kernel's own numbering. Order matches
/// `optics::materials::CrystalSystem`'s own declaration order.
pub mod crystal_system {
    /// Identifier for cubic.
    pub const CUBIC: u32 = 0;
    /// Identifier for tetragonal.
    pub const TETRAGONAL: u32 = 1;
    /// Identifier for hexagonal.
    pub const HEXAGONAL: u32 = 2;
    /// Identifier for trigonal.
    pub const TRIGONAL: u32 = 3;
    /// Identifier for orthorhombic.
    pub const ORTHORHOMBIC: u32 = 4;
    /// Identifier for monoclinic.
    pub const MONOCLINIC: u32 = 5;
    /// Identifier for triclinic.
    pub const TRICLINIC: u32 = 6;
}
/// `optical_character` discriminants for [`GpuGemMaterial`].
///
/// Must match `renderer/shaders/layout_echo.wgsl`'s and (eventually) any real
/// material-evaluating kernel's own numbering. Order matches
/// `optics::materials::OpticalCharacter`'s own declaration order.
pub mod optical_character {
    /// Identifier for isotropic.
    pub const ISOTROPIC: u32 = 0;
    /// Identifier for uniaxial positive.
    pub const UNIAXIAL_POSITIVE: u32 = 1;
    /// Identifier for uniaxial negative.
    pub const UNIAXIAL_NEGATIVE: u32 = 2;
    /// Identifier for biaxial positive.
    pub const BIAXIAL_POSITIVE: u32 = 3;
    /// Identifier for biaxial negative.
    pub const BIAXIAL_NEGATIVE: u32 = 4;
}

/// A full `optics::materials::GemMaterial`, GPU-encoded.
///
/// [`DispersionParams`] plus crystal/optical-character discriminants and both
/// eigenmodes' absorption band sets (flattened to [`MAX_ABSORPTION_BANDS`]-capacity
/// arrays with an explicit count, per this crate's Phase-0 plan).
///
/// # Layout
///
/// `dispersion` is 96 bytes (a multiple of its own 16-byte WGSL alignment), occupying
/// 0..96 with no leading padding. `crystal_system` through `e_ray_band_count` are five
/// 4-byte-aligned `u32`s packing at 96..116. `o_ray_bands`/`e_ray_bands` (alignment 4)
/// need only a 4-byte offset, so 116 already qualifies; each is now
/// `MAX_ABSORPTION_BANDS` (8) * 16 bytes = 128 bytes (see [`GpuAbsorptionBand`]'s own
/// Layout doc comment for why its size grew from 12 to 16), so `o_ray_bands` occupies
/// 116..244 and `e_ray_bands` 244..372. `scattering_sigma_s` through
/// `edge_rounding_radius` pack at 372..384.
///
/// Every field from `has_beta_ray` onward is APPENDED at the end rather than inserted
/// alongside its logical sibling, so every earlier field keeps its offset:
/// `has_beta_ray`/`beta_ray_band_count` (384..392), `beta_ray_bands` (392..520, the same
/// 128-byte band-array size as `o_ray_bands`/`e_ray_bands`), `absorption_path_scale`
/// (520..524), `has_extraordinary_dispersion`/`extraordinary_model_type` (524..532,
/// mirroring `GemMaterial::uniaxial_extraordinary_dispersion: Option<DispersionModel>`).
/// `extraordinary_param_a`/`extraordinary_param_b` encode that model like
/// [`DispersionParams::encode`]'s own `param_a`/`param_b`, and being `vec4<f32>` need
/// 16-byte alignment: `_pad_before_extraordinary_params` (532..544) reproduces WGSL's
/// implicit padding to get there; they then occupy 544..576, already 16-byte aligned,
/// with no trailing pad.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GpuGemMaterial {
    /// Dispersion.
    pub dispersion: DispersionParams,
    /// Crystal system.
    pub crystal_system: u32,
    /// Optical character.
    pub optical_character: u32,
    /// Is pleochroic.
    pub is_pleochroic: u32,
    /// O ray band count.
    pub o_ray_band_count: u32,
    /// E ray band count.
    pub e_ray_band_count: u32,
    /// O ray bands.
    pub o_ray_bands: [GpuAbsorptionBand; MAX_ABSORPTION_BANDS],
    /// E ray bands.
    pub e_ray_bands: [GpuAbsorptionBand; MAX_ABSORPTION_BANDS],
    /// Inclusion/subsurface scattering: mirrors
    /// `optics::materials::GemMaterial::scattering_sigma_s`/`scattering_g` exactly.
    pub scattering_sigma_s: f32,
    /// Scattering g.
    pub scattering_g: f32,
    /// Facet edge rounding: mirrors
    /// `optics::materials::GemMaterial::edge_rounding_radius` exactly.
    pub edge_rounding_radius: f32,
    /// Mirrors `AbsorptionTensor::beta_ray`'s presence (`beta_ray.is_some()`).
    /// `beta_ray_band_count`/`beta_ray_bands` are meaningless when this is 0;
    /// distinguishes "no third band set" from "a third band set with zero bands", same
    /// as `Option<Vec<AbsorptionBand>>` on the CPU side.
    pub has_beta_ray: u32,
    /// The third, `n_beta`, principal direction's absorption bands' count -- see
    /// `has_beta_ray`.
    pub beta_ray_band_count: u32,
    /// The third, `n_beta`, principal direction's absorption bands -- see
    /// `has_beta_ray`. Mirrors `o_ray_bands`/`e_ray_bands`'s fixed-capacity encoding.
    pub beta_ray_bands: [GpuAbsorptionBand; MAX_ABSORPTION_BANDS],
    /// Mirrors `GemMaterial::absorption_path_scale` -- every model-unit length entering
    /// Beer-Lambert absorption or the scattering estimator is multiplied by this before
    /// use, both in the CPU tracer and in `spectral_transport.wgsl`'s mirrored blocks.
    pub absorption_path_scale: f32,
    /// Whether `extraordinary_param_a`/`extraordinary_param_b` carry a genuine
    /// independent extraordinary-ray dispersion curve -- mirrors
    /// `uniaxial_extraordinary_dispersion.is_some()`. Zero means the shader falls back to
    /// the constant-offset approximation `n_o + birefringence_delta` -- see
    /// `spectral_transport.wgsl`'s `extraordinary_index_at`.
    pub has_extraordinary_dispersion: u32,
    /// The extraordinary-ray curve's [`DispersionModel`] variant, using the SAME
    /// [`dispersion_model_type`] discriminants as `dispersion.model_type`. Meaningless
    /// when `has_extraordinary_dispersion == 0`.
    pub extraordinary_model_type: u32,
    /// Explicit padding reproducing the implicit bytes WGSL inserts before
    /// `extraordinary_param_a` (a `vec4<f32>`) -- see this struct's Layout doc comment.
    _pad_before_extraordinary_params: [u32; 3],
    /// The extraordinary-ray curve's `param_a`, encoded like [`DispersionParams::param_a`].
    /// Meaningless when `has_extraordinary_dispersion == 0`.
    pub extraordinary_param_a: [f32; 4],
    /// The extraordinary-ray curve's `param_b`, encoded like [`DispersionParams::param_b`].
    /// Meaningless when `has_extraordinary_dispersion == 0`.
    pub extraordinary_param_b: [f32; 4],
    /// The zone table, appended at offset 576 so every earlier
    /// field keeps its offset. See `renderer::buffers::zoning`. All zero means unzoned.
    pub zones: super::zoning::GpuZoneTable,
}

const _: () = {
    assert!(offset_of!(GpuGemMaterial, dispersion) == 0);
    assert!(offset_of!(GpuGemMaterial, crystal_system) == 96);
    assert!(offset_of!(GpuGemMaterial, optical_character) == 100);
    assert!(offset_of!(GpuGemMaterial, is_pleochroic) == 104);
    assert!(offset_of!(GpuGemMaterial, o_ray_band_count) == 108);
    assert!(offset_of!(GpuGemMaterial, e_ray_band_count) == 112);
    assert!(offset_of!(GpuGemMaterial, o_ray_bands) == 116);
    assert!(offset_of!(GpuGemMaterial, e_ray_bands) == 244);
    assert!(offset_of!(GpuGemMaterial, scattering_sigma_s) == 372);
    assert!(offset_of!(GpuGemMaterial, scattering_g) == 376);
    assert!(offset_of!(GpuGemMaterial, edge_rounding_radius) == 380);
    assert!(offset_of!(GpuGemMaterial, has_beta_ray) == 384);
    assert!(offset_of!(GpuGemMaterial, beta_ray_band_count) == 388);
    assert!(offset_of!(GpuGemMaterial, beta_ray_bands) == 392);
    assert!(offset_of!(GpuGemMaterial, absorption_path_scale) == 520);
    assert!(offset_of!(GpuGemMaterial, has_extraordinary_dispersion) == 524);
    assert!(offset_of!(GpuGemMaterial, extraordinary_model_type) == 528);
    assert!(offset_of!(GpuGemMaterial, extraordinary_param_a) == 544);
    assert!(offset_of!(GpuGemMaterial, extraordinary_param_b) == 560);
    // The zone table is appended: 576 + 2128 (see `renderer::buffers::zoning`).
    assert!(offset_of!(GpuGemMaterial, zones) == 576);
    assert!(size_of::<GpuGemMaterial>() == 576 + 2128);
};

/// Encodes a CPU `optics::materials::GemMaterial` into a [`GpuGemMaterial`] for upload.
///
/// Never a hand-copied duplicate of the material data: every field read here is the SAME
/// field `optics::raytracer::trace_spectral_ray` itself reads. `material.absorption.beta_ray`
/// (the optional third, trichroic band set) is encoded into
/// `has_beta_ray`/`beta_ray_band_count`/`beta_ray_bands`, consumed by the shader's
/// genuinely biaxial absorption path whenever `dispersion.has_biaxial_delta != 0`.
impl GpuGemMaterial {
    /// This is the production upload path (`gpu::frame`'s accumulate and async paths) as
    /// well as the self-test encoder.
    ///
    /// A band set longer than [`MAX_ABSORPTION_BANDS`] in any of the three eigenmode
    /// sets is truncated to its first [`MAX_ABSORPTION_BANDS`] bands and a
    /// `tracing::warn!` is emitted; the network validator already rejects such a material
    /// before it reaches the GPU, and every built-in material has at most 3 bands.
    #[must_use]
    pub fn encode(material: &GemMaterial) -> Self {
        let crystal_system_val = match material.crystal_system {
            CrystalSystem::Cubic => crystal_system::CUBIC,
            CrystalSystem::Tetragonal => crystal_system::TETRAGONAL,
            CrystalSystem::Hexagonal => crystal_system::HEXAGONAL,
            CrystalSystem::Trigonal => crystal_system::TRIGONAL,
            CrystalSystem::Orthorhombic => crystal_system::ORTHORHOMBIC,
            CrystalSystem::Monoclinic => crystal_system::MONOCLINIC,
            CrystalSystem::Triclinic => crystal_system::TRICLINIC,
        };
        let optical_character_val = match material.optical_character {
            OpticalCharacter::Isotropic => optical_character::ISOTROPIC,
            OpticalCharacter::UniaxialPositive => optical_character::UNIAXIAL_POSITIVE,
            OpticalCharacter::UniaxialNegative => optical_character::UNIAXIAL_NEGATIVE,
            OpticalCharacter::BiaxialPositive => optical_character::BIAXIAL_POSITIVE,
            OpticalCharacter::BiaxialNegative => optical_character::BIAXIAL_NEGATIVE,
        };

        // A renderable zoned material uploads its BASE zone here (the zones follow in the
        // appended table); every other material uploads its own tensor.
        let (absorption, zones) = super::zoning::material_parts(material);

        let (o_ray_bands, o_ray_band_count) = encode_bands(&absorption.o_ray);
        let (e_ray_bands, e_ray_band_count) = encode_bands(&absorption.e_ray);
        let (beta_ray_bands, beta_ray_band_count) = absorption
            .beta_ray
            .as_deref()
            .map_or_else(empty_bands, encode_bands);

        // None (every built-in except Quartz/Amethyst/Citrine/Ametrine/Rutile) encodes to
        // has_extraordinary_dispersion == 0 and an all-zero curve the shader never reads.
        let (
            has_extraordinary_dispersion,
            extraordinary_model_type,
            extraordinary_param_a,
            extraordinary_param_b,
        ) = material.uniaxial_extraordinary_dispersion.map_or(
            (0u32, 0u32, [0.0f32; 4], [0.0f32; 4]),
            |e_dispersion| {
                let (model_type, param_a, param_b) = encode_dispersion_model(&e_dispersion);
                (1u32, model_type, param_a, param_b)
            },
        );

        Self {
            dispersion: DispersionParams::encode(material),
            crystal_system: crystal_system_val,
            optical_character: optical_character_val,
            is_pleochroic: u32::from(absorption.is_pleochroic),
            o_ray_band_count,
            e_ray_band_count,
            o_ray_bands,
            e_ray_bands,
            scattering_sigma_s: material.scattering_sigma_s,
            scattering_g: material.scattering_g,
            edge_rounding_radius: material.edge_rounding_radius,
            has_beta_ray: u32::from(absorption.beta_ray.is_some()),
            beta_ray_band_count,
            beta_ray_bands,
            absorption_path_scale: material.absorption_path_scale,
            has_extraordinary_dispersion,
            extraordinary_model_type,
            _pad_before_extraordinary_params: [0; 3],
            extraordinary_param_a,
            extraordinary_param_b,
            zones,
        }
    }
}

/// Encodes one `optics::dispersion::DispersionModel` into its `(model_type, param_a,
/// param_b)` GPU representation -- shared by [`DispersionParams::encode`] (the
/// material's primary curve) and [`GpuGemMaterial::encode`] (the optional extraordinary
/// curve), so the two never independently drift on the mapping.
#[must_use]
const fn encode_dispersion_model(model: &DispersionModel) -> (u32, [f32; 4], [f32; 4]) {
    match *model {
        DispersionModel::Sellmeier1 { b1, c1 } => (
            dispersion_model_type::SELLMEIER1,
            [b1, 0.0, 0.0, 0.0],
            [c1, 0.0, 0.0, 0.0],
        ),
        DispersionModel::Sellmeier3 { b, c } => (
            dispersion_model_type::SELLMEIER3,
            [b[0], b[1], b[2], 0.0],
            [c[0], c[1], c[2], 0.0],
        ),
        DispersionModel::Cauchy { a, b, c } => {
            (dispersion_model_type::CAUCHY, [a, b, c, 0.0], [0.0; 4])
        }
    }
}

impl DispersionParams {
    #[must_use]
    fn encode(material: &GemMaterial) -> Self {
        let (model_type, param_a, param_b) = encode_dispersion_model(&material.dispersion);

        let c_axis = material.c_axis;
        Self {
            model_type,
            _pad_after_model_type: [0; 3],
            param_a,
            param_b,
            param_c: [0.0; 4],
            c_axis_and_birefringence: [c_axis.x, c_axis.y, c_axis.z, material.birefringence_delta],
            is_anisotropic: u32::from(
                material.crystal_system != CrystalSystem::Cubic
                    && material.birefringence_delta.abs() > 1e-4,
            ),
            biaxial_delta_beta_alpha: material.biaxial_delta_beta_alpha.unwrap_or(0.0),
            has_biaxial_delta: u32::from(material.biaxial_delta_beta_alpha.is_some()),
            _pad_tail: 0.0,
        }
    }
}

/// The all-zero-bands, zero-count encoding used for `beta_ray_bands` when
/// `material.absorption.beta_ray` is `None` -- mirrors [`encode_bands`]'s default slot
/// values (`width_nm: 1.0`, else `0.0`) so an unused slot is never a degenerate
/// zero-width Gaussian, even though `peak` is always `0.0` regardless.
pub(super) const fn empty_bands() -> ([GpuAbsorptionBand; MAX_ABSORPTION_BANDS], u32) {
    (
        [GpuAbsorptionBand {
            center_nm: 0.0,
            width_nm: 1.0,
            peak: 0.0,
            shape: band_shape::GAUSSIAN_WAVELENGTH,
        }; MAX_ABSORPTION_BANDS],
        0,
    )
}

/// Encodes a `Vec<AbsorptionBand>` into a fixed [`MAX_ABSORPTION_BANDS`]-capacity array
/// plus its stored length. A source with more bands than the GPU encoding can hold is
/// truncated to the first [`MAX_ABSORPTION_BANDS`] bands (see
/// [`GpuGemMaterial::encode`]'s doc comment), with a warning, so the returned count never
/// exceeds the array capacity.
pub(super) fn encode_bands(
    bands: &[AbsorptionBand],
) -> ([GpuAbsorptionBand; MAX_ABSORPTION_BANDS], u32) {
    if bands.len() > MAX_ABSORPTION_BANDS {
        tracing::warn!(
            band_count = bands.len(),
            max = MAX_ABSORPTION_BANDS,
            "material has more absorption bands than the GPU encoding holds; truncating"
        );
    }
    let bands = &bands[..bands.len().min(MAX_ABSORPTION_BANDS)];
    let mut out = [GpuAbsorptionBand {
        center_nm: 0.0,
        width_nm: 1.0,
        peak: 0.0,
        shape: band_shape::GAUSSIAN_WAVELENGTH,
    }; MAX_ABSORPTION_BANDS];
    for (slot, band) in out.iter_mut().zip(bands.iter()) {
        *slot = GpuAbsorptionBand {
            center_nm: band.center_nm,
            width_nm: band.width_nm,
            peak: band.peak,
            shape: match band.shape {
                BandShape::GaussianWavelength => band_shape::GAUSSIAN_WAVELENGTH,
                BandShape::GaussianEnergy => band_shape::GAUSSIAN_ENERGY,
            },
        };
    }
    (
        out,
        u32::try_from(bands.len()).expect("the band count is truncated to MAX_ABSORPTION_BANDS"),
    )
}