1#![deny(missing_docs)]
2use std::fmt;
44use std::ops::Range;
45
46use bytemuck::Pod;
47use serde::de::DeserializeOwned;
48use serde::{Deserialize, Serialize};
49
50pub const ABI_VERSION: u32 = 1;
52
53#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
55pub struct Limits {
56 pub max_vertices: u32,
58 pub max_indices: u32,
60 pub max_static_meshes: u32,
62 pub max_dynamic_meshes: u32,
64 pub max_textures: u32,
66 pub max_texture_bytes: u32,
68 pub max_texture_dim: u32,
70}
71
72impl Limits {
73 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, max_texture_dim: 512,
83 }
84 }
85}
86
87#[derive(Clone, Debug, Serialize, Deserialize)]
89#[serde(bound(
90 serialize = "V: Serialize",
91 deserialize = "V: Serialize + DeserializeOwned"
92))]
93pub struct StaticMesh<V: Pod> {
94 pub label: String,
96 pub vertices: Vec<V>,
98 pub indices: Vec<u16>,
100}
101
102#[derive(Clone, Debug, Serialize, Deserialize)]
104pub struct DynamicMesh {
105 pub label: String,
107 pub max_vertices: u32,
109 pub indices: Vec<u16>,
111}
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
115pub enum PipelineKind {
116 Opaque,
118 Transparent,
120}
121
122#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
124pub enum DrawSource {
125 Static(usize),
127 Dynamic(usize),
129}
130
131#[derive(Clone, Debug, Serialize, Deserialize)]
133pub struct DrawSpec {
134 pub label: String,
136 pub source: DrawSource,
138 pub pipeline: PipelineKind,
140 pub index_range: Range<u32>,
142}
143
144#[derive(Clone, Debug, Serialize, Deserialize)]
146pub struct TextureDesc {
147 pub label: String,
149 pub width: u32,
151 pub height: u32,
153 pub format: TextureFormat,
155 pub wrap_u: WrapMode,
157 pub wrap_v: WrapMode,
159 pub wrap_w: WrapMode,
161 pub mag_filter: FilterMode,
163 pub min_filter: FilterMode,
165 pub mip_filter: FilterMode,
167 pub data: Vec<u8>,
169}
170
171#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
173pub enum TextureFormat {
174 Rgba8Unorm,
176}
177
178#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
180pub enum WrapMode {
181 Repeat,
183 ClampToEdge,
185}
186
187#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
189pub enum FilterMode {
190 Nearest,
192 Linear,
194}
195
196#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
198pub enum SceneSpace {
199 Screen2D,
201 World3D,
203}
204
205#[derive(Clone, Debug, Serialize, Deserialize)]
207pub struct ShaderSources {
208 pub vertex_wgsl: Option<String>,
210 pub fragment_wgsl: Option<String>,
212}
213
214#[derive(Clone, Debug, Serialize, Deserialize)]
216pub struct CameraKeyframe {
217 pub time: f32,
219 pub position: [f32; 3],
221 pub target: [f32; 3],
223 pub up: [f32; 3],
225 pub fov_y_deg: f32,
227}
228
229#[derive(Clone, Debug, Serialize, Deserialize)]
231pub struct CameraPath {
232 pub looped: bool,
234 pub keyframes: Vec<CameraKeyframe>,
236}
237
238#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
240pub struct DirectionalLight {
241 pub direction: [f32; 3],
243 pub color: [f32; 3],
245 pub intensity: f32,
247}
248
249impl DirectionalLight {
250 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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
262pub struct WorldLighting {
263 pub ambient_color: [f32; 3],
265 pub ambient_intensity: f32,
267 pub directional_light: Option<DirectionalLight>,
269}
270
271impl WorldLighting {
272 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#[derive(Clone, Debug, Serialize, Deserialize)]
306pub struct RuntimeOverlay<V: Pod> {
307 pub vertices: Vec<V>,
309 pub indices: Vec<u16>,
311}
312
313#[derive(Clone, Debug, Serialize, Deserialize)]
315pub struct RuntimeMesh<V: Pod> {
316 pub mesh_index: u32,
318 pub vertices: Vec<V>,
320 pub index_count: u32,
322}
323
324#[derive(Clone, Debug, Serialize, Deserialize)]
326pub struct RuntimeMeshSet<V: Pod> {
327 pub meshes: Vec<RuntimeMesh<V>>,
329}
330
331#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
333pub struct MeshAssetVertex {
334 pub position: [f32; 3],
336 pub normal: [f32; 3],
338 pub tex_coords: [f32; 2],
340 pub color: [f32; 4],
342}
343
344#[derive(Clone, Debug, Serialize, Deserialize)]
346pub struct MeshAsset {
347 pub vertices: Vec<MeshAssetVertex>,
349 pub indices: Vec<u16>,
351}
352
353#[derive(Clone, Debug, Serialize, Deserialize)]
355pub struct FontAtlas {
356 pub width: u32,
358 pub height: u32,
360 pub pixels: Vec<u8>, pub glyphs: Vec<GlyphInfo>,
364}
365
366#[derive(Clone, Debug, Serialize, Deserialize)]
368pub struct GlyphInfo {
369 pub codepoint: u32,
371 pub u0: f32,
373 pub v0: f32,
375 pub u1: f32,
377 pub v1: f32,
379}
380
381#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
383pub struct SceneAnchor {
384 pub id: String,
386 pub label: String,
388 pub node_name: Option<String>,
390 pub tag: Option<String>,
392 pub world_transform: [[f32; 4]; 4],
394}
395
396impl SceneAnchor {
397 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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
409pub struct SceneAnchorSet {
410 pub scene_id: String,
412 pub scene_label: Option<String>,
414 pub scene_name: Option<String>,
416 pub anchors: Vec<SceneAnchor>,
418}
419
420impl SceneAnchorSet {
421 pub fn anchor(&self, key: &str) -> Option<&SceneAnchor> {
423 self.anchors.iter().find(|anchor| anchor.id == key)
424 }
425
426 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#[derive(Clone, Debug, PartialEq, Eq)]
443pub enum SceneAnchorLookupError {
444 NotFound {
446 scene_id: String,
448 key: String,
450 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#[derive(Clone, Debug, Serialize, Deserialize)]
484#[serde(bound(
485 serialize = "V: Serialize",
486 deserialize = "V: Serialize + DeserializeOwned"
487))]
488pub struct SlideSpec<V: Pod> {
489 pub name: String,
491 pub limits: Limits,
493 pub scene_space: SceneSpace,
495 pub camera_path: Option<CameraPath>,
497 pub shaders: Option<ShaderSources>,
499 pub overlay: Option<RuntimeOverlay<V>>,
501 pub font: Option<FontAtlas>,
503 pub textures_used: u32,
505 pub textures: Vec<TextureDesc>,
507 pub static_meshes: Vec<StaticMesh<V>>,
509 pub dynamic_meshes: Vec<DynamicMesh>,
511 pub draws: Vec<DrawSpec>,
513 #[serde(default = "default_slide_lighting")]
515 pub lighting: Option<WorldLighting>,
516}
517
518#[derive(Debug)]
520pub enum SpecError {
521 StaticMeshesExceeded {
523 count: usize,
525 max: u32,
527 },
528 DynamicMeshesExceeded {
530 count: usize,
532 max: u32,
534 },
535 VertexBudget {
537 total: u32,
539 max: u32,
541 },
542 IndexBudget {
544 total: u32,
546 max: u32,
548 },
549 TextureBudget {
551 used: u32,
553 max: u32,
555 },
556 TextureBytes {
558 total: u32,
560 max: u32,
562 },
563 TextureDimension {
565 dim: u32,
567 max: u32,
569 },
570 TextureCountMismatch {
572 declared: u32,
574 actual: u32,
576 },
577 DrawMissingMesh {
579 label: String,
581 },
582 DrawRange {
584 label: String,
586 available: u32,
588 requested: u32,
590 },
591 InvalidRange {
593 label: String,
595 },
596 CameraPathEmpty,
598 CameraKeyframeOrder,
600 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 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 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 pub fn validate(&self) -> Result<(), SpecError> {
693 let _ = self.name; 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#[repr(C)]
838#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable, Serialize, Deserialize)]
839pub struct WorldVertex {
840 pub position: [f32; 3],
842 pub normal: [f32; 3],
844 pub color: [f32; 4],
846 pub mode: f32,
848}
849
850#[cfg(feature = "gpu")]
851impl WorldVertex {
852 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 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#[repr(C)]
882#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable, Serialize, Deserialize)]
883pub struct ScreenVertex {
884 pub position: [f32; 3],
886 pub tex_coords: [f32; 2],
888 pub color: [f32; 4],
890 pub mode: f32,
892}
893
894#[cfg(feature = "gpu")]
895impl ScreenVertex {
896 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 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#[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 #[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 #[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
970pub const FONT_CHAR_ORDER: &[u8] = b" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-:";
977
978pub 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}