Skip to main content

vzglyd_slide/
lib.rs

1#![deny(missing_docs)]
2//! # `vzglyd_slide`
3//!
4//! ABI contract and shared data types for [VZGLYD](https://github.com/vzglyd/vzglyd) slides.
5//!
6//! A VZGLYD slide is a `wasm32-wasip1` module that exports `vzglyd_abi_version()` and
7//! `vzglyd_update(dt: f32) -> i32`. The engine loads the slide, deserializes its
8//! [`SlideSpec`], validates it against device limits, and renders it with the engine's
9//! fixed pipeline contract.
10//!
11//! ## Quick Start
12//!
13//! Add the crate to your slide:
14//!
15//! ```toml
16//! [dependencies]
17//! vzglyd_slide = "0.1"
18//! ```
19//!
20//! Export the required ABI surface:
21//!
22//! ```no_run
23//! use vzglyd_slide::ABI_VERSION;
24//!
25//! #[unsafe(no_mangle)]
26//! pub extern "C" fn vzglyd_abi_version() -> u32 {
27//!     ABI_VERSION
28//! }
29//!
30//! #[unsafe(no_mangle)]
31//! pub extern "C" fn vzglyd_update(_dt: f32) -> i32 {
32//!     0
33//! }
34//! ```
35//!
36//! `dt` is the elapsed time since the previous frame, expressed in seconds. Returning `0`
37//! tells the engine that geometry is unchanged and can be reused. Returning `1` tells the
38//! engine to fetch updated geometry and upload fresh buffers for the next frame.
39//!
40//! See the crate README for a packaging overview, and [`ABI_POLICY.md`](../ABI_POLICY.md) for
41//! the versioning and compatibility contract.
42
43use std::fmt;
44use std::ops::Range;
45
46use bytemuck::Pod;
47use serde::de::DeserializeOwned;
48use serde::{Deserialize, Serialize};
49
50/// Current slide ABI version understood by this crate and the engine.
51pub const ABI_VERSION: u32 = 1;
52
53/// Resource and geometry limits a slide is allowed to consume.
54#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
55pub struct Limits {
56    /// Maximum total vertex count across static and dynamic meshes.
57    pub max_vertices: u32,
58    /// Maximum total index count across static and dynamic meshes.
59    pub max_indices: u32,
60    /// Maximum number of static meshes.
61    pub max_static_meshes: u32,
62    /// Maximum number of dynamic meshes.
63    pub max_dynamic_meshes: u32,
64    /// Maximum number of texture slots a slide may occupy.
65    pub max_textures: u32,
66    /// Maximum aggregate number of texture bytes stored in the package.
67    pub max_texture_bytes: u32,
68    /// Maximum width or height of any single texture.
69    pub max_texture_dim: u32,
70}
71
72impl Limits {
73    /// Conservative limits chosen to stay within Raspberry Pi 4 budgets.
74    pub const fn pi4() -> Self {
75        Self {
76            max_vertices: 25_600,
77            max_indices: 26_624,
78            max_static_meshes: 4,
79            max_dynamic_meshes: 4,
80            max_textures: 4,
81            max_texture_bytes: 512 * 512 * 4 * 4, // up to four 512² RGBA8 textures
82            max_texture_dim: 512,
83        }
84    }
85}
86
87/// Static mesh payload fully provided by the slide.
88#[derive(Clone, Debug, Serialize, Deserialize)]
89#[serde(bound(
90    serialize = "V: Serialize",
91    deserialize = "V: Serialize + DeserializeOwned"
92))]
93pub struct StaticMesh<V: Pod> {
94    /// Human-readable mesh label used in diagnostics.
95    pub label: String,
96    /// Vertex payload uploaded once when the slide is loaded.
97    pub vertices: Vec<V>,
98    /// Triangle index data for the mesh.
99    pub indices: Vec<u16>,
100}
101
102/// Dynamic mesh where vertices are rewritten every frame but index order is fixed.
103#[derive(Clone, Debug, Serialize, Deserialize)]
104pub struct DynamicMesh {
105    /// Human-readable mesh label used in diagnostics.
106    pub label: String,
107    /// Maximum number of vertices the slide may upload for this mesh.
108    pub max_vertices: u32,
109    /// Static index order used for every frame update.
110    pub indices: Vec<u16>,
111}
112
113/// Fixed render pipeline selection for a draw call.
114#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
115pub enum PipelineKind {
116    /// Opaque geometry written without alpha blending.
117    Opaque,
118    /// Transparent geometry rendered with blending enabled.
119    Transparent,
120}
121
122/// Mesh source referenced by a draw call.
123#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
124pub enum DrawSource {
125    /// Read indices from a static mesh.
126    Static(usize),
127    /// Read indices from a dynamic mesh.
128    Dynamic(usize),
129}
130
131/// Draw call descriptor for one mesh slice.
132#[derive(Clone, Debug, Serialize, Deserialize)]
133pub struct DrawSpec {
134    /// Human-readable draw label used in diagnostics.
135    pub label: String,
136    /// Mesh buffer to draw from.
137    pub source: DrawSource,
138    /// Pipeline variant used to render the mesh slice.
139    pub pipeline: PipelineKind,
140    /// Range of indices consumed from the referenced mesh.
141    pub index_range: Range<u32>,
142}
143
144/// Texture payload embedded in the slide package.
145#[derive(Clone, Debug, Serialize, Deserialize)]
146pub struct TextureDesc {
147    /// Human-readable texture label used in diagnostics.
148    pub label: String,
149    /// Texture width in pixels.
150    pub width: u32,
151    /// Texture height in pixels.
152    pub height: u32,
153    /// Pixel format understood by the engine.
154    pub format: TextureFormat,
155    /// Address mode for the U axis.
156    pub wrap_u: WrapMode,
157    /// Address mode for the V axis.
158    pub wrap_v: WrapMode,
159    /// Address mode for the W axis.
160    pub wrap_w: WrapMode,
161    /// Magnification filter.
162    pub mag_filter: FilterMode,
163    /// Minification filter.
164    pub min_filter: FilterMode,
165    /// Mipmap filter.
166    pub mip_filter: FilterMode,
167    /// Raw texture bytes in the declared format.
168    pub data: Vec<u8>,
169}
170
171/// Texture formats accepted by the engine.
172#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
173pub enum TextureFormat {
174    /// 8-bit normalized RGBA texture data.
175    Rgba8Unorm,
176}
177
178/// Sampler address mode.
179#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
180pub enum WrapMode {
181    /// Repeat texture coordinates outside the `[0, 1]` range.
182    Repeat,
183    /// Clamp texture coordinates to the texture edge.
184    ClampToEdge,
185}
186
187/// Sampler filtering mode.
188#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
189pub enum FilterMode {
190    /// Nearest-neighbor sampling.
191    Nearest,
192    /// Linear interpolation between neighboring texels.
193    Linear,
194}
195
196/// Coordinate system and renderer contract for the slide.
197#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
198pub enum SceneSpace {
199    /// Screen-aligned 2D content rendered in slide pixel space.
200    Screen2D,
201    /// World-space 3D content rendered with camera and lighting support.
202    World3D,
203}
204
205/// Optional custom shader source overrides supplied by the slide package.
206#[derive(Clone, Debug, Serialize, Deserialize)]
207pub struct ShaderSources {
208    /// Vertex shader WGSL source, if the slide overrides the default.
209    pub vertex_wgsl: Option<String>,
210    /// Fragment shader WGSL source, if the slide overrides the default.
211    pub fragment_wgsl: Option<String>,
212}
213
214/// Camera pose at a specific point along an animated path.
215#[derive(Clone, Debug, Serialize, Deserialize)]
216pub struct CameraKeyframe {
217    /// Time, in seconds, measured from the start of the path.
218    pub time: f32,
219    /// Camera position in world space.
220    pub position: [f32; 3],
221    /// Camera target point in world space.
222    pub target: [f32; 3],
223    /// Up vector used to construct the camera basis.
224    pub up: [f32; 3],
225    /// Vertical field of view in degrees.
226    pub fov_y_deg: f32,
227}
228
229/// Ordered keyframes that define camera motion for a world-space slide.
230#[derive(Clone, Debug, Serialize, Deserialize)]
231pub struct CameraPath {
232    /// Whether the path should wrap back to the start after the final keyframe.
233    pub looped: bool,
234    /// Keyframes in strictly increasing time order.
235    pub keyframes: Vec<CameraKeyframe>,
236}
237
238/// Directional light configuration for world-space slides.
239#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
240pub struct DirectionalLight {
241    /// Direction from the shaded point toward the light source.
242    pub direction: [f32; 3],
243    /// RGB light color.
244    pub color: [f32; 3],
245    /// Scalar light intensity multiplier.
246    pub intensity: f32,
247}
248
249impl DirectionalLight {
250    /// Construct a directional light definition.
251    pub const fn new(direction: [f32; 3], color: [f32; 3], intensity: f32) -> Self {
252        Self {
253            direction,
254            color,
255            intensity,
256        }
257    }
258}
259
260/// Lighting parameters for world-space slides.
261#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
262pub struct WorldLighting {
263    /// RGB ambient light color.
264    pub ambient_color: [f32; 3],
265    /// Scalar ambient intensity multiplier.
266    pub ambient_intensity: f32,
267    /// Primary directional light, if the slide wants one.
268    pub directional_light: Option<DirectionalLight>,
269}
270
271impl WorldLighting {
272    /// Construct a full lighting description.
273    pub const fn new(
274        ambient_color: [f32; 3],
275        ambient_intensity: f32,
276        directional_light: Option<DirectionalLight>,
277    ) -> Self {
278        Self {
279            ambient_color,
280            ambient_intensity,
281            directional_light,
282        }
283    }
284}
285
286impl Default for WorldLighting {
287    fn default() -> Self {
288        Self {
289            ambient_color: [1.0, 1.0, 1.0],
290            ambient_intensity: 0.22,
291            directional_light: Some(DirectionalLight::new(
292                [0.55, 1.0, 0.38],
293                [1.0, 1.0, 1.0],
294                1.0,
295            )),
296        }
297    }
298}
299
300fn default_slide_lighting() -> Option<WorldLighting> {
301    Some(WorldLighting::default())
302}
303
304/// Overlay geometry uploaded separately from the main mesh set.
305#[derive(Clone, Debug, Serialize, Deserialize)]
306pub struct RuntimeOverlay<V: Pod> {
307    /// Overlay vertices rendered over the main scene.
308    pub vertices: Vec<V>,
309    /// Triangle indices for the overlay.
310    pub indices: Vec<u16>,
311}
312
313/// Runtime-updated mesh payload for a specific dynamic mesh slot.
314#[derive(Clone, Debug, Serialize, Deserialize)]
315pub struct RuntimeMesh<V: Pod> {
316    /// Index of the target mesh in [`SlideSpec::dynamic_meshes`].
317    pub mesh_index: u32,
318    /// Replacement vertex payload for the current frame.
319    pub vertices: Vec<V>,
320    /// Number of indices from the static index buffer to draw.
321    pub index_count: u32,
322}
323
324/// Batch of dynamic mesh updates produced at runtime.
325#[derive(Clone, Debug, Serialize, Deserialize)]
326pub struct RuntimeMeshSet<V: Pod> {
327    /// Per-mesh updates keyed by mesh slot.
328    pub meshes: Vec<RuntimeMesh<V>>,
329}
330
331/// Vertex data extracted from an imported mesh asset.
332#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
333pub struct MeshAssetVertex {
334    /// Vertex position in model space.
335    pub position: [f32; 3],
336    /// Vertex normal in model space.
337    pub normal: [f32; 3],
338    /// Primary texture coordinates.
339    pub tex_coords: [f32; 2],
340    /// Vertex color.
341    pub color: [f32; 4],
342}
343
344/// Standalone mesh asset containing geometry buffers.
345#[derive(Clone, Debug, Serialize, Deserialize)]
346pub struct MeshAsset {
347    /// Mesh vertices.
348    pub vertices: Vec<MeshAssetVertex>,
349    /// Mesh triangle indices.
350    pub indices: Vec<u16>,
351}
352
353/// Runtime font atlas used by text-capable slides.
354#[derive(Clone, Debug, Serialize, Deserialize)]
355pub struct FontAtlas {
356    /// Atlas width in pixels.
357    pub width: u32,
358    /// Atlas height in pixels.
359    pub height: u32,
360    /// RGBA8 pixel data for the atlas.
361    pub pixels: Vec<u8>, // RGBA8
362    /// Glyph metadata packed into the atlas.
363    pub glyphs: Vec<GlyphInfo>,
364}
365
366/// UV mapping data for one glyph in a [`FontAtlas`].
367#[derive(Clone, Debug, Serialize, Deserialize)]
368pub struct GlyphInfo {
369    /// Unicode code point for the glyph.
370    pub codepoint: u32,
371    /// Minimum U coordinate.
372    pub u0: f32,
373    /// Minimum V coordinate.
374    pub v0: f32,
375    /// Maximum U coordinate.
376    pub u1: f32,
377    /// Maximum V coordinate.
378    pub v1: f32,
379}
380
381/// Named anchor extracted from an imported scene asset.
382#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
383pub struct SceneAnchor {
384    /// Stable machine-readable identifier for the anchor.
385    pub id: String,
386    /// Human-readable label.
387    pub label: String,
388    /// Source node name, if known.
389    pub node_name: Option<String>,
390    /// Optional author-defined tag.
391    pub tag: Option<String>,
392    /// World transform matrix for the anchor.
393    pub world_transform: [[f32; 4]; 4],
394}
395
396impl SceneAnchor {
397    /// Extract the translation component from [`SceneAnchor::world_transform`].
398    pub fn translation(&self) -> [f32; 3] {
399        [
400            self.world_transform[3][0],
401            self.world_transform[3][1],
402            self.world_transform[3][2],
403        ]
404    }
405}
406
407/// Set of anchors extracted from a named scene asset.
408#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
409pub struct SceneAnchorSet {
410    /// Stable identifier for the scene asset.
411    pub scene_id: String,
412    /// Human-readable scene label, if present.
413    pub scene_label: Option<String>,
414    /// Source scene name, if present.
415    pub scene_name: Option<String>,
416    /// Anchors discovered in the scene.
417    pub anchors: Vec<SceneAnchor>,
418}
419
420impl SceneAnchorSet {
421    /// Look up an anchor by machine-readable identifier.
422    pub fn anchor(&self, key: &str) -> Option<&SceneAnchor> {
423        self.anchors.iter().find(|anchor| anchor.id == key)
424    }
425
426    /// Require an anchor to exist, returning a descriptive lookup error otherwise.
427    pub fn require_anchor(&self, key: &str) -> Result<&SceneAnchor, SceneAnchorLookupError> {
428        self.anchor(key)
429            .ok_or_else(|| SceneAnchorLookupError::NotFound {
430                scene_id: self.scene_id.clone(),
431                key: key.to_string(),
432                available: self
433                    .anchors
434                    .iter()
435                    .map(|anchor| anchor.id.clone())
436                    .collect(),
437            })
438    }
439}
440
441/// Error returned when looking up a missing scene anchor.
442#[derive(Clone, Debug, PartialEq, Eq)]
443pub enum SceneAnchorLookupError {
444    /// The requested anchor key was not present in the scene.
445    NotFound {
446        /// Identifier of the scene that was searched.
447        scene_id: String,
448        /// Requested anchor key.
449        key: String,
450        /// Available anchor identifiers present in the scene.
451        available: Vec<String>,
452    },
453}
454
455impl fmt::Display for SceneAnchorLookupError {
456    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
457        match self {
458            SceneAnchorLookupError::NotFound {
459                scene_id,
460                key,
461                available,
462            } => {
463                let available = if available.is_empty() {
464                    "none".to_string()
465                } else {
466                    available.join(", ")
467                };
468                write!(
469                    f,
470                    "scene '{scene_id}' does not define anchor '{key}' (available: {available})"
471                )
472            }
473        }
474    }
475}
476
477impl std::error::Error for SceneAnchorLookupError {}
478
479/// Complete scene description returned by a slide.
480///
481/// The engine loads a `SlideSpec` when the slide starts, validates it against
482/// [`Limits`], and uses it to allocate textures, mesh buffers, and render state.
483#[derive(Clone, Debug, Serialize, Deserialize)]
484#[serde(bound(
485    serialize = "V: Serialize",
486    deserialize = "V: Serialize + DeserializeOwned"
487))]
488pub struct SlideSpec<V: Pod> {
489    /// Human-readable slide name used for logging and diagnostics.
490    pub name: String,
491    /// Resource limits the slide promises to stay within.
492    pub limits: Limits,
493    /// Rendering space used by the slide.
494    pub scene_space: SceneSpace,
495    /// Camera animation used by world-space slides.
496    pub camera_path: Option<CameraPath>,
497    /// Optional custom WGSL overrides for the engine shader contract.
498    pub shaders: Option<ShaderSources>,
499    /// Optional runtime overlay drawn on top of the main scene.
500    pub overlay: Option<RuntimeOverlay<V>>,
501    /// Optional font atlas used by text helpers.
502    pub font: Option<FontAtlas>,
503    /// Number of texture slots this slide expects to occupy.
504    pub textures_used: u32,
505    /// Texture payloads embedded in the package.
506    pub textures: Vec<TextureDesc>,
507    /// Static meshes uploaded once when the slide loads.
508    pub static_meshes: Vec<StaticMesh<V>>,
509    /// Dynamic meshes whose vertices may change at runtime.
510    pub dynamic_meshes: Vec<DynamicMesh>,
511    /// Draw plan executed by the engine every frame.
512    pub draws: Vec<DrawSpec>,
513    /// Optional lighting override for world-space slides.
514    #[serde(default = "default_slide_lighting")]
515    pub lighting: Option<WorldLighting>,
516}
517
518/// Validation error produced when a [`SlideSpec`] breaks the engine contract.
519#[derive(Debug)]
520pub enum SpecError {
521    /// Too many static meshes were declared.
522    StaticMeshesExceeded {
523        /// Number of meshes declared by the slide.
524        count: usize,
525        /// Maximum static meshes allowed by [`Limits`].
526        max: u32,
527    },
528    /// Too many dynamic meshes were declared.
529    DynamicMeshesExceeded {
530        /// Number of meshes declared by the slide.
531        count: usize,
532        /// Maximum dynamic meshes allowed by [`Limits`].
533        max: u32,
534    },
535    /// The slide exceeded the total vertex budget.
536    VertexBudget {
537        /// Total vertices requested by the slide.
538        total: u32,
539        /// Maximum vertices allowed by [`Limits`].
540        max: u32,
541    },
542    /// The slide exceeded the total index budget.
543    IndexBudget {
544        /// Total indices requested by the slide.
545        total: u32,
546        /// Maximum indices allowed by [`Limits`].
547        max: u32,
548    },
549    /// The slide exceeded the declared texture slot budget.
550    TextureBudget {
551        /// Number of texture slots used by the slide.
552        used: u32,
553        /// Maximum texture slots allowed by [`Limits`].
554        max: u32,
555    },
556    /// The slide exceeded the aggregate texture byte budget.
557    TextureBytes {
558        /// Total bytes consumed by all textures.
559        total: u32,
560        /// Maximum bytes allowed by [`Limits`].
561        max: u32,
562    },
563    /// A texture exceeded the maximum allowed dimension.
564    TextureDimension {
565        /// Largest requested width or height.
566        dim: u32,
567        /// Maximum width or height allowed by [`Limits`].
568        max: u32,
569    },
570    /// `textures_used` disagrees with the actual embedded textures.
571    TextureCountMismatch {
572        /// Declared texture count.
573        declared: u32,
574        /// Actual texture count.
575        actual: u32,
576    },
577    /// A draw call referenced a mesh slot that does not exist.
578    DrawMissingMesh {
579        /// Label of the offending draw.
580        label: String,
581    },
582    /// A draw call referenced more indices than the mesh contains.
583    DrawRange {
584        /// Label of the offending draw.
585        label: String,
586        /// Available indices in the referenced mesh.
587        available: u32,
588        /// Requested end index.
589        requested: u32,
590    },
591    /// A range or dimension was structurally invalid.
592    InvalidRange {
593        /// Label of the offending asset or draw.
594        label: String,
595    },
596    /// A camera path was present but contained no keyframes.
597    CameraPathEmpty,
598    /// Camera keyframes were not strictly increasing in time.
599    CameraKeyframeOrder,
600    /// A camera keyframe contained a negative timestamp.
601    CameraKeyframeTimeNegative,
602}
603
604impl fmt::Display for SpecError {
605    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
606        match self {
607            SpecError::StaticMeshesExceeded { count, max } => {
608                write!(f, "{count} static meshes exceeds limit {max}")
609            }
610            SpecError::DynamicMeshesExceeded { count, max } => {
611                write!(f, "{count} dynamic meshes exceeds limit {max}")
612            }
613            SpecError::VertexBudget { total, max } => {
614                write!(f, "vertex budget exceeded: {total} > {max}")
615            }
616            SpecError::IndexBudget { total, max } => {
617                write!(f, "index budget exceeded: {total} > {max}")
618            }
619            SpecError::TextureBudget { used, max } => {
620                write!(f, "texture budget exceeded: {used} > {max}")
621            }
622            SpecError::TextureBytes { total, max } => {
623                write!(f, "texture byte budget exceeded: {total} > {max}")
624            }
625            SpecError::TextureDimension { dim, max } => {
626                write!(f, "texture dimension {dim} exceeds {max}")
627            }
628            SpecError::TextureCountMismatch { declared, actual } => {
629                write!(f, "textures_used={declared} does not match actual {actual}")
630            }
631            SpecError::DrawMissingMesh { label } => {
632                write!(f, "draw '{label}' references a missing mesh")
633            }
634            SpecError::DrawRange {
635                label,
636                available,
637                requested,
638            } => write!(
639                f,
640                "draw '{label}' requests {requested} indices but only {available} exist"
641            ),
642            SpecError::InvalidRange { label } => {
643                write!(f, "draw '{label}' has an invalid index range")
644            }
645            SpecError::CameraPathEmpty => write!(f, "camera path has no keyframes"),
646            SpecError::CameraKeyframeOrder => write!(
647                f,
648                "camera keyframes must be in strictly increasing time order"
649            ),
650            SpecError::CameraKeyframeTimeNegative => {
651                write!(f, "camera keyframes must have non-negative time")
652            }
653        }
654    }
655}
656
657impl<V: Pod> SlideSpec<V> {
658    /// Total vertex budget consumed by this slide, including dynamic mesh capacity.
659    pub fn total_vertex_budget(&self) -> u32 {
660        let static_vertices: u32 = self
661            .static_meshes
662            .iter()
663            .map(|mesh| mesh.vertices.len() as u32)
664            .sum();
665        let dynamic_vertices: u32 = self
666            .dynamic_meshes
667            .iter()
668            .map(|mesh| mesh.max_vertices)
669            .sum();
670        static_vertices.saturating_add(dynamic_vertices)
671    }
672
673    /// Total index budget consumed by this slide.
674    pub fn total_index_budget(&self) -> u32 {
675        let static_indices: u32 = self
676            .static_meshes
677            .iter()
678            .map(|mesh| mesh.indices.len() as u32)
679            .sum();
680        let dynamic_indices: u32 = self
681            .dynamic_meshes
682            .iter()
683            .map(|mesh| mesh.indices.len() as u32)
684            .sum();
685        static_indices.saturating_add(dynamic_indices)
686    }
687
688    /// Validate that the slide stays within declared limits and references are sound.
689    ///
690    /// Validation checks mesh counts, vertex and index budgets, texture budgets,
691    /// draw references, texture dimensions, and camera path ordering.
692    pub fn validate(&self) -> Result<(), SpecError> {
693        let _ = self.name; // keep name observable even if unused by callers
694
695        if self.static_meshes.len() as u32 > self.limits.max_static_meshes {
696            return Err(SpecError::StaticMeshesExceeded {
697                count: self.static_meshes.len(),
698                max: self.limits.max_static_meshes,
699            });
700        }
701        if self.dynamic_meshes.len() as u32 > self.limits.max_dynamic_meshes {
702            return Err(SpecError::DynamicMeshesExceeded {
703                count: self.dynamic_meshes.len(),
704                max: self.limits.max_dynamic_meshes,
705            });
706        }
707
708        let total_vertices = self.total_vertex_budget();
709        if total_vertices > self.limits.max_vertices {
710            return Err(SpecError::VertexBudget {
711                total: total_vertices,
712                max: self.limits.max_vertices,
713            });
714        }
715
716        let total_indices = self.total_index_budget();
717        if total_indices > self.limits.max_indices {
718            return Err(SpecError::IndexBudget {
719                total: total_indices,
720                max: self.limits.max_indices,
721            });
722        }
723
724        if self.textures_used > self.limits.max_textures {
725            return Err(SpecError::TextureBudget {
726                used: self.textures_used,
727                max: self.limits.max_textures,
728            });
729        }
730        if self.textures.len() as u32 != self.textures_used {
731            return Err(SpecError::TextureCountMismatch {
732                declared: self.textures_used,
733                actual: self.textures.len() as u32,
734            });
735        }
736        if self.textures.len() as u32 > self.limits.max_textures {
737            return Err(SpecError::TextureBudget {
738                used: self.textures.len() as u32,
739                max: self.limits.max_textures,
740            });
741        }
742        let mut tex_bytes = 0u32;
743        for tex in &self.textures {
744            if tex.width == 0 || tex.height == 0 {
745                return Err(SpecError::InvalidRange {
746                    label: tex.label.clone(),
747                });
748            }
749            if tex.width > self.limits.max_texture_dim || tex.height > self.limits.max_texture_dim {
750                return Err(SpecError::TextureDimension {
751                    dim: tex.width.max(tex.height),
752                    max: self.limits.max_texture_dim,
753                });
754            }
755            tex_bytes = tex_bytes.saturating_add(tex.data.len() as u32);
756        }
757        if tex_bytes > self.limits.max_texture_bytes {
758            return Err(SpecError::TextureBytes {
759                total: tex_bytes,
760                max: self.limits.max_texture_bytes,
761            });
762        }
763
764        if let Some(cam) = &self.camera_path {
765            if cam.keyframes.is_empty() {
766                return Err(SpecError::CameraPathEmpty);
767            }
768            let mut last = -1.0_f32;
769            for k in &cam.keyframes {
770                if k.time < 0.0 {
771                    return Err(SpecError::CameraKeyframeTimeNegative);
772                }
773                if k.time <= last {
774                    return Err(SpecError::CameraKeyframeOrder);
775                }
776                last = k.time;
777            }
778        }
779
780        for draw in &self.draws {
781            if draw.index_range.start > draw.index_range.end {
782                return Err(SpecError::InvalidRange {
783                    label: draw.label.clone(),
784                });
785            }
786            match draw.source {
787                DrawSource::Static(idx) => {
788                    let Some(mesh) = self.static_meshes.get(idx) else {
789                        return Err(SpecError::DrawMissingMesh {
790                            label: draw.label.clone(),
791                        });
792                    };
793                    let available = mesh.indices.len() as u32;
794                    if draw.index_range.end > available {
795                        return Err(SpecError::DrawRange {
796                            label: draw.label.clone(),
797                            available,
798                            requested: draw.index_range.end,
799                        });
800                    }
801                }
802                DrawSource::Dynamic(idx) => {
803                    let Some(mesh) = self.dynamic_meshes.get(idx) else {
804                        return Err(SpecError::DrawMissingMesh {
805                            label: draw.label.clone(),
806                        });
807                    };
808                    let available = mesh.indices.len() as u32;
809                    if draw.index_range.end > available {
810                        return Err(SpecError::DrawRange {
811                            label: draw.label.clone(),
812                            available,
813                            requested: draw.index_range.end,
814                        });
815                    }
816                }
817            }
818        }
819
820        Ok(())
821    }
822}
823
824// ── Canonical vertex types ─────────────────────────────────────────────────────
825
826/// Canonical vertex for world-space (3-D) slides.
827///
828/// All 3-D slides must produce meshes using this layout so the engine's world
829/// shader prelude can address the attributes at the fixed locations below.
830///
831/// | Location | Field | Format |
832/// |----------|-------|--------|
833/// | 0 | `position` | `Float32x3` |
834/// | 1 | `normal` | `Float32x3` |
835/// | 2 | `color` | `Float32x4` |
836/// | 3 | `mode` | `Float32` |
837#[repr(C)]
838#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable, Serialize, Deserialize)]
839pub struct WorldVertex {
840    /// Object-space position.
841    pub position: [f32; 3],
842    /// Object-space surface normal.
843    pub normal: [f32; 3],
844    /// Per-vertex RGBA colour.
845    pub color: [f32; 4],
846    /// Shader-interpreted mode flag (0 = lit, 1 = sky/unlit, etc.).
847    pub mode: f32,
848}
849
850#[cfg(feature = "gpu")]
851impl WorldVertex {
852    /// Vertex attribute descriptors for use in a wgpu pipeline.
853    pub const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
854        0 => Float32x3,
855        1 => Float32x3,
856        2 => Float32x4,
857        3 => Float32,
858    ];
859
860    /// Returns the [`wgpu::VertexBufferLayout`] for this vertex type.
861    pub fn desc() -> wgpu::VertexBufferLayout<'static> {
862        wgpu::VertexBufferLayout {
863            array_stride: std::mem::size_of::<WorldVertex>() as wgpu::BufferAddress,
864            step_mode: wgpu::VertexStepMode::Vertex,
865            attributes: &Self::ATTRIBS,
866        }
867    }
868}
869
870/// Canonical vertex for screen-space (2-D) slides.
871///
872/// All 2-D slides must produce meshes using this layout so the engine's screen
873/// shader prelude can address the attributes at the fixed locations below.
874///
875/// | Location | Field | Format |
876/// |----------|-------|--------|
877/// | 0 | `position` | `Float32x3` |
878/// | 1 | `tex_coords` | `Float32x2` |
879/// | 2 | `color` | `Float32x4` |
880/// | 3 | `mode` | `Float32` |
881#[repr(C)]
882#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable, Serialize, Deserialize)]
883pub struct ScreenVertex {
884    /// Clip-space position (z ignored; use 0.0).
885    pub position: [f32; 3],
886    /// Normalised texture coordinates.
887    pub tex_coords: [f32; 2],
888    /// Per-vertex RGBA colour.
889    pub color: [f32; 4],
890    /// Shader-interpreted mode flag.
891    pub mode: f32,
892}
893
894#[cfg(feature = "gpu")]
895impl ScreenVertex {
896    /// Vertex attribute descriptors for use in a wgpu pipeline.
897    pub const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
898        0 => Float32x3,
899        1 => Float32x2,
900        2 => Float32x4,
901        3 => Float32,
902    ];
903
904    /// Returns the [`wgpu::VertexBufferLayout`] for this vertex type.
905    pub fn desc() -> wgpu::VertexBufferLayout<'static> {
906        wgpu::VertexBufferLayout {
907            array_stride: std::mem::size_of::<ScreenVertex>() as wgpu::BufferAddress,
908            step_mode: wgpu::VertexStepMode::Vertex,
909            attributes: &Self::ATTRIBS,
910        }
911    }
912}
913
914// ── Optional configure protocol ───────────────────────────────────────────────
915
916/// Declare a parameter buffer that the host can populate before `vzglyd_init`.
917///
918/// # Configure Protocol
919///
920/// A slide opts into parameterisation by invoking this macro and exporting a
921/// `vzglyd_configure(len: i32) -> i32` function.  The host will then:
922///
923/// 1. Call `vzglyd_params_ptr()` to locate the buffer in WASM linear memory.
924/// 2. Call `vzglyd_params_capacity()` to learn how many bytes the buffer holds.
925/// 3. Write JSON parameter bytes (truncated to capacity) into the buffer.
926/// 4. Call `vzglyd_configure(len)` with the byte count written.
927/// 5. Proceed with the normal `vzglyd_init` / `vzglyd_spec_ptr` / `vzglyd_spec_len` sequence.
928///
929/// If any of `vzglyd_params_ptr`, `vzglyd_params_capacity`, or `vzglyd_configure` is
930/// absent, the host skips the configure step entirely.
931///
932/// # Example
933///
934/// ```no_run
935/// use vzglyd_slide::params_buf;
936///
937/// params_buf!(256);
938///
939/// # #[cfg(target_arch = "wasm32")]
940/// #[unsafe(no_mangle)]
941/// pub extern "C" fn vzglyd_configure(len: i32) -> i32 {
942///     let bytes = unsafe { &VZGLYD_PARAMS_BUF[..len.max(0) as usize] };
943///     // parse `bytes` as JSON and apply to slide state
944///     let _ = bytes;
945///     0
946/// }
947/// ```
948#[macro_export]
949macro_rules! params_buf {
950    ($size:expr) => {
951        #[cfg(target_arch = "wasm32")]
952        static mut VZGLYD_PARAMS_BUF: [u8; $size] = [0u8; $size];
953
954        /// Returns a pointer into linear memory where the host writes parameter bytes.
955        #[cfg(target_arch = "wasm32")]
956        #[unsafe(no_mangle)]
957        pub extern "C" fn vzglyd_params_ptr() -> i32 {
958            unsafe { VZGLYD_PARAMS_BUF.as_mut_ptr() as i32 }
959        }
960
961        /// Returns the byte capacity of the parameter buffer.
962        #[cfg(target_arch = "wasm32")]
963        #[unsafe(no_mangle)]
964        pub extern "C" fn vzglyd_params_capacity() -> u32 {
965            $size as u32
966        }
967    };
968}
969
970// ── Shared texture generators ──────────────────────────────────────────────────
971
972/// The character order used by the 5×7 bitmap font atlas.
973///
974/// Each character occupies a 6-pixel-wide column in the atlas (5 pixels of
975/// glyph data + 1 pixel gap). The atlas is `256 × 8` pixels, RGBA8.
976pub const FONT_CHAR_ORDER: &[u8] = b" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-:";
977
978/// Generates the built-in 5×7 bitmap font atlas used by world-space slides.
979///
980/// Returns a `256 × 8` RGBA8 pixel buffer (8 192 bytes). White pixels are set
981/// for lit glyph bits; all other pixels are transparent black.
982///
983/// This atlas is also available as the engine's default font texture slot, so
984/// slides do not need to bundle it unless they want to override it.
985pub fn make_font_atlas() -> Vec<u8> {
986    fn glyph(c: u8) -> [u8; 7] {
987        match c {
988            b' ' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
989            b'A' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
990            b'B' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
991            b'C' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],
992            b'D' => [0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E],
993            b'E' => [0x1F, 0x10, 0x10, 0x1C, 0x10, 0x10, 0x1F],
994            b'F' => [0x1F, 0x10, 0x10, 0x1C, 0x10, 0x10, 0x10],
995            b'G' => [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E],
996            b'H' => [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
997            b'I' => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x1F],
998            b'J' => [0x0F, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C],
999            b'K' => [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
1000            b'L' => [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F],
1001            b'M' => [0x11, 0x1B, 0x15, 0x11, 0x11, 0x11, 0x11],
1002            b'N' => [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11],
1003            b'O' => [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
1004            b'P' => [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],
1005            b'Q' => [0x0E, 0x11, 0x11, 0x11, 0x15, 0x13, 0x0F],
1006            b'R' => [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],
1007            b'S' => [0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E],
1008            b'T' => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
1009            b'U' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
1010            b'V' => [0x11, 0x11, 0x11, 0x11, 0x0A, 0x0A, 0x04],
1011            b'W' => [0x11, 0x11, 0x11, 0x15, 0x1B, 0x11, 0x11],
1012            b'X' => [0x11, 0x0A, 0x04, 0x04, 0x04, 0x0A, 0x11],
1013            b'Y' => [0x11, 0x0A, 0x04, 0x04, 0x04, 0x04, 0x04],
1014            b'Z' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F],
1015            b'0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
1016            b'1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
1017            b'2' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
1018            b'3' => [0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E],
1019            b'4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
1020            b'5' => [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E],
1021            b'6' => [0x0E, 0x10, 0x10, 0x1E, 0x11, 0x11, 0x0E],
1022            b'7' => [0x1F, 0x01, 0x02, 0x04, 0x04, 0x04, 0x04],
1023            b'8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
1024            b'9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x11, 0x0E],
1025            b'.' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04],
1026            b'-' => [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00],
1027            b':' => [0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00],
1028            _ => [0x00; 7],
1029        }
1030    }
1031    const AW: usize = 256;
1032    const AH: usize = 8;
1033    let mut buf = vec![0u8; AW * AH * 4];
1034    for (ci, &c) in FONT_CHAR_ORDER.iter().enumerate() {
1035        let rows = glyph(c);
1036        let xb = ci * 6;
1037        for (row, &byte) in rows.iter().enumerate() {
1038            for col in 0..5usize {
1039                if (byte >> (4 - col)) & 1 == 1 {
1040                    let i = (row * AW + xb + col) * 4;
1041                    buf[i] = 255;
1042                    buf[i + 1] = 255;
1043                    buf[i + 2] = 255;
1044                    buf[i + 3] = 255;
1045                }
1046            }
1047        }
1048    }
1049    buf
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055
1056    #[derive(Clone, Copy, Pod, bytemuck::Zeroable)]
1057    #[repr(C)]
1058    struct V {
1059        pos: [f32; 3],
1060    }
1061
1062    #[test]
1063    fn limits_pi4_are_conservative() {
1064        let l = Limits::pi4();
1065        assert!(l.max_vertices >= 25_000);
1066        assert!(l.max_indices >= 26_000);
1067    }
1068
1069    #[test]
1070    fn validate_checks_vertex_budget() {
1071        let spec = SlideSpec {
1072            name: "test".to_string(),
1073            limits: Limits {
1074                max_vertices: 3,
1075                max_indices: 10,
1076                max_static_meshes: 2,
1077                max_dynamic_meshes: 1,
1078                max_textures: 1,
1079                max_texture_bytes: 64,
1080                max_texture_dim: 16,
1081            },
1082            scene_space: SceneSpace::Screen2D,
1083            camera_path: None,
1084            shaders: None,
1085            overlay: None,
1086            font: None,
1087            textures_used: 1,
1088            textures: vec![TextureDesc {
1089                label: "t".to_string(),
1090                width: 1,
1091                height: 1,
1092                format: TextureFormat::Rgba8Unorm,
1093                wrap_u: WrapMode::ClampToEdge,
1094                wrap_v: WrapMode::ClampToEdge,
1095                wrap_w: WrapMode::ClampToEdge,
1096                mag_filter: FilterMode::Nearest,
1097                min_filter: FilterMode::Nearest,
1098                mip_filter: FilterMode::Nearest,
1099                data: vec![255, 255, 255, 255],
1100            }],
1101            static_meshes: vec![StaticMesh {
1102                label: "m".to_string(),
1103                vertices: vec![V { pos: [0.0; 3] }; 4],
1104                indices: vec![0, 1, 2],
1105            }],
1106            dynamic_meshes: vec![],
1107            draws: vec![DrawSpec {
1108                label: "d".to_string(),
1109                source: DrawSource::Static(0),
1110                pipeline: PipelineKind::Opaque,
1111                index_range: 0..3,
1112            }],
1113            lighting: None,
1114        };
1115        let err = spec.validate().unwrap_err();
1116        matches!(err, SpecError::VertexBudget { .. });
1117    }
1118
1119    #[test]
1120    fn scene_anchor_translation_reads_transform_origin() {
1121        let anchor = SceneAnchor {
1122            id: "spawn".into(),
1123            label: "Spawn".into(),
1124            node_name: Some("Spawn".into()),
1125            tag: Some("spawn".into()),
1126            world_transform: [
1127                [1.0, 0.0, 0.0, 0.0],
1128                [0.0, 1.0, 0.0, 0.0],
1129                [0.0, 0.0, 1.0, 0.0],
1130                [3.0, 1.5, -2.0, 1.0],
1131            ],
1132        };
1133
1134        assert_eq!(anchor.translation(), [3.0, 1.5, -2.0]);
1135    }
1136
1137    #[test]
1138    fn scene_anchor_lookup_reports_available_ids() {
1139        let anchors = SceneAnchorSet {
1140            scene_id: "hero_world".into(),
1141            scene_label: Some("Hero World".into()),
1142            scene_name: Some("WorldScene".into()),
1143            anchors: vec![SceneAnchor {
1144                id: "spawn_marker".into(),
1145                label: "SpawnAnchor".into(),
1146                node_name: Some("SpawnAnchor".into()),
1147                tag: Some("spawn".into()),
1148                world_transform: [
1149                    [1.0, 0.0, 0.0, 0.0],
1150                    [0.0, 1.0, 0.0, 0.0],
1151                    [0.0, 0.0, 1.0, 0.0],
1152                    [3.0, 0.0, 2.0, 1.0],
1153                ],
1154            }],
1155        };
1156
1157        let error = anchors
1158            .require_anchor("missing")
1159            .expect_err("missing anchor should fail");
1160        assert_eq!(
1161            error.to_string(),
1162            "scene 'hero_world' does not define anchor 'missing' (available: spawn_marker)"
1163        );
1164    }
1165}