#![deny(missing_docs)]
use std::fmt;
use std::ops::Range;
use bytemuck::Pod;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
pub const ABI_VERSION: u32 = 1;
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub struct Limits {
pub max_vertices: u32,
pub max_indices: u32,
pub max_static_meshes: u32,
pub max_dynamic_meshes: u32,
pub max_textures: u32,
pub max_texture_bytes: u32,
pub max_texture_dim: u32,
}
impl Limits {
pub const fn pi4() -> Self {
Self {
max_vertices: 25_600,
max_indices: 26_624,
max_static_meshes: 4,
max_dynamic_meshes: 4,
max_textures: 4,
max_texture_bytes: 512 * 512 * 4 * 4, max_texture_dim: 512,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(bound(
serialize = "V: Serialize",
deserialize = "V: Serialize + DeserializeOwned"
))]
pub struct StaticMesh<V: Pod> {
pub label: String,
pub vertices: Vec<V>,
pub indices: Vec<u16>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DynamicMesh {
pub label: String,
pub max_vertices: u32,
pub indices: Vec<u16>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PipelineKind {
Opaque,
Transparent,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DrawSource {
Static(usize),
Dynamic(usize),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DrawSpec {
pub label: String,
pub source: DrawSource,
pub pipeline: PipelineKind,
pub index_range: Range<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TextureDesc {
pub label: String,
pub width: u32,
pub height: u32,
pub format: TextureFormat,
pub wrap_u: WrapMode,
pub wrap_v: WrapMode,
pub wrap_w: WrapMode,
pub mag_filter: FilterMode,
pub min_filter: FilterMode,
pub mip_filter: FilterMode,
pub data: Vec<u8>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum TextureFormat {
Rgba8Unorm,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum WrapMode {
Repeat,
ClampToEdge,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum FilterMode {
Nearest,
Linear,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum SceneSpace {
Screen2D,
World3D,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ShaderSources {
pub vertex_wgsl: Option<String>,
pub fragment_wgsl: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CameraKeyframe {
pub time: f32,
pub position: [f32; 3],
pub target: [f32; 3],
pub up: [f32; 3],
pub fov_y_deg: f32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CameraPath {
pub looped: bool,
pub keyframes: Vec<CameraKeyframe>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
pub struct DirectionalLight {
pub direction: [f32; 3],
pub color: [f32; 3],
pub intensity: f32,
}
impl DirectionalLight {
pub const fn new(direction: [f32; 3], color: [f32; 3], intensity: f32) -> Self {
Self {
direction,
color,
intensity,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct WorldLighting {
pub ambient_color: [f32; 3],
pub ambient_intensity: f32,
pub directional_light: Option<DirectionalLight>,
}
impl WorldLighting {
pub const fn new(
ambient_color: [f32; 3],
ambient_intensity: f32,
directional_light: Option<DirectionalLight>,
) -> Self {
Self {
ambient_color,
ambient_intensity,
directional_light,
}
}
}
impl Default for WorldLighting {
fn default() -> Self {
Self {
ambient_color: [1.0, 1.0, 1.0],
ambient_intensity: 0.22,
directional_light: Some(DirectionalLight::new(
[0.55, 1.0, 0.38],
[1.0, 1.0, 1.0],
1.0,
)),
}
}
}
fn default_slide_lighting() -> Option<WorldLighting> {
Some(WorldLighting::default())
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RuntimeOverlay<V: Pod> {
pub vertices: Vec<V>,
pub indices: Vec<u16>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RuntimeMesh<V: Pod> {
pub mesh_index: u32,
pub vertices: Vec<V>,
pub index_count: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RuntimeMeshSet<V: Pod> {
pub meshes: Vec<RuntimeMesh<V>>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub struct MeshAssetVertex {
pub position: [f32; 3],
pub normal: [f32; 3],
pub tex_coords: [f32; 2],
pub color: [f32; 4],
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MeshAsset {
pub vertices: Vec<MeshAssetVertex>,
pub indices: Vec<u16>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FontAtlas {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>, pub glyphs: Vec<GlyphInfo>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GlyphInfo {
pub codepoint: u32,
pub u0: f32,
pub v0: f32,
pub u1: f32,
pub v1: f32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct SceneAnchor {
pub id: String,
pub label: String,
pub node_name: Option<String>,
pub tag: Option<String>,
pub world_transform: [[f32; 4]; 4],
}
impl SceneAnchor {
pub fn translation(&self) -> [f32; 3] {
[
self.world_transform[3][0],
self.world_transform[3][1],
self.world_transform[3][2],
]
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct SceneAnchorSet {
pub scene_id: String,
pub scene_label: Option<String>,
pub scene_name: Option<String>,
pub anchors: Vec<SceneAnchor>,
}
impl SceneAnchorSet {
pub fn anchor(&self, key: &str) -> Option<&SceneAnchor> {
self.anchors.iter().find(|anchor| anchor.id == key)
}
pub fn require_anchor(&self, key: &str) -> Result<&SceneAnchor, SceneAnchorLookupError> {
self.anchor(key)
.ok_or_else(|| SceneAnchorLookupError::NotFound {
scene_id: self.scene_id.clone(),
key: key.to_string(),
available: self
.anchors
.iter()
.map(|anchor| anchor.id.clone())
.collect(),
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SceneAnchorLookupError {
NotFound {
scene_id: String,
key: String,
available: Vec<String>,
},
}
impl fmt::Display for SceneAnchorLookupError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SceneAnchorLookupError::NotFound {
scene_id,
key,
available,
} => {
let available = if available.is_empty() {
"none".to_string()
} else {
available.join(", ")
};
write!(
f,
"scene '{scene_id}' does not define anchor '{key}' (available: {available})"
)
}
}
}
}
impl std::error::Error for SceneAnchorLookupError {}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(bound(
serialize = "V: Serialize",
deserialize = "V: Serialize + DeserializeOwned"
))]
pub struct SlideSpec<V: Pod> {
pub name: String,
pub limits: Limits,
pub scene_space: SceneSpace,
pub camera_path: Option<CameraPath>,
pub shaders: Option<ShaderSources>,
pub overlay: Option<RuntimeOverlay<V>>,
pub font: Option<FontAtlas>,
pub textures_used: u32,
pub textures: Vec<TextureDesc>,
pub static_meshes: Vec<StaticMesh<V>>,
pub dynamic_meshes: Vec<DynamicMesh>,
pub draws: Vec<DrawSpec>,
#[serde(default = "default_slide_lighting")]
pub lighting: Option<WorldLighting>,
}
#[derive(Debug)]
pub enum SpecError {
StaticMeshesExceeded {
count: usize,
max: u32,
},
DynamicMeshesExceeded {
count: usize,
max: u32,
},
VertexBudget {
total: u32,
max: u32,
},
IndexBudget {
total: u32,
max: u32,
},
TextureBudget {
used: u32,
max: u32,
},
TextureBytes {
total: u32,
max: u32,
},
TextureDimension {
dim: u32,
max: u32,
},
TextureCountMismatch {
declared: u32,
actual: u32,
},
DrawMissingMesh {
label: String,
},
DrawRange {
label: String,
available: u32,
requested: u32,
},
InvalidRange {
label: String,
},
CameraPathEmpty,
CameraKeyframeOrder,
CameraKeyframeTimeNegative,
}
impl fmt::Display for SpecError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SpecError::StaticMeshesExceeded { count, max } => {
write!(f, "{count} static meshes exceeds limit {max}")
}
SpecError::DynamicMeshesExceeded { count, max } => {
write!(f, "{count} dynamic meshes exceeds limit {max}")
}
SpecError::VertexBudget { total, max } => {
write!(f, "vertex budget exceeded: {total} > {max}")
}
SpecError::IndexBudget { total, max } => {
write!(f, "index budget exceeded: {total} > {max}")
}
SpecError::TextureBudget { used, max } => {
write!(f, "texture budget exceeded: {used} > {max}")
}
SpecError::TextureBytes { total, max } => {
write!(f, "texture byte budget exceeded: {total} > {max}")
}
SpecError::TextureDimension { dim, max } => {
write!(f, "texture dimension {dim} exceeds {max}")
}
SpecError::TextureCountMismatch { declared, actual } => {
write!(f, "textures_used={declared} does not match actual {actual}")
}
SpecError::DrawMissingMesh { label } => {
write!(f, "draw '{label}' references a missing mesh")
}
SpecError::DrawRange {
label,
available,
requested,
} => write!(
f,
"draw '{label}' requests {requested} indices but only {available} exist"
),
SpecError::InvalidRange { label } => {
write!(f, "draw '{label}' has an invalid index range")
}
SpecError::CameraPathEmpty => write!(f, "camera path has no keyframes"),
SpecError::CameraKeyframeOrder => write!(
f,
"camera keyframes must be in strictly increasing time order"
),
SpecError::CameraKeyframeTimeNegative => {
write!(f, "camera keyframes must have non-negative time")
}
}
}
}
impl<V: Pod> SlideSpec<V> {
pub fn total_vertex_budget(&self) -> u32 {
let static_vertices: u32 = self
.static_meshes
.iter()
.map(|mesh| mesh.vertices.len() as u32)
.sum();
let dynamic_vertices: u32 = self
.dynamic_meshes
.iter()
.map(|mesh| mesh.max_vertices)
.sum();
static_vertices.saturating_add(dynamic_vertices)
}
pub fn total_index_budget(&self) -> u32 {
let static_indices: u32 = self
.static_meshes
.iter()
.map(|mesh| mesh.indices.len() as u32)
.sum();
let dynamic_indices: u32 = self
.dynamic_meshes
.iter()
.map(|mesh| mesh.indices.len() as u32)
.sum();
static_indices.saturating_add(dynamic_indices)
}
pub fn validate(&self) -> Result<(), SpecError> {
let _ = self.name;
if self.static_meshes.len() as u32 > self.limits.max_static_meshes {
return Err(SpecError::StaticMeshesExceeded {
count: self.static_meshes.len(),
max: self.limits.max_static_meshes,
});
}
if self.dynamic_meshes.len() as u32 > self.limits.max_dynamic_meshes {
return Err(SpecError::DynamicMeshesExceeded {
count: self.dynamic_meshes.len(),
max: self.limits.max_dynamic_meshes,
});
}
let total_vertices = self.total_vertex_budget();
if total_vertices > self.limits.max_vertices {
return Err(SpecError::VertexBudget {
total: total_vertices,
max: self.limits.max_vertices,
});
}
let total_indices = self.total_index_budget();
if total_indices > self.limits.max_indices {
return Err(SpecError::IndexBudget {
total: total_indices,
max: self.limits.max_indices,
});
}
if self.textures_used > self.limits.max_textures {
return Err(SpecError::TextureBudget {
used: self.textures_used,
max: self.limits.max_textures,
});
}
if self.textures.len() as u32 != self.textures_used {
return Err(SpecError::TextureCountMismatch {
declared: self.textures_used,
actual: self.textures.len() as u32,
});
}
if self.textures.len() as u32 > self.limits.max_textures {
return Err(SpecError::TextureBudget {
used: self.textures.len() as u32,
max: self.limits.max_textures,
});
}
let mut tex_bytes = 0u32;
for tex in &self.textures {
if tex.width == 0 || tex.height == 0 {
return Err(SpecError::InvalidRange {
label: tex.label.clone(),
});
}
if tex.width > self.limits.max_texture_dim || tex.height > self.limits.max_texture_dim {
return Err(SpecError::TextureDimension {
dim: tex.width.max(tex.height),
max: self.limits.max_texture_dim,
});
}
tex_bytes = tex_bytes.saturating_add(tex.data.len() as u32);
}
if tex_bytes > self.limits.max_texture_bytes {
return Err(SpecError::TextureBytes {
total: tex_bytes,
max: self.limits.max_texture_bytes,
});
}
if let Some(cam) = &self.camera_path {
if cam.keyframes.is_empty() {
return Err(SpecError::CameraPathEmpty);
}
let mut last = -1.0_f32;
for k in &cam.keyframes {
if k.time < 0.0 {
return Err(SpecError::CameraKeyframeTimeNegative);
}
if k.time <= last {
return Err(SpecError::CameraKeyframeOrder);
}
last = k.time;
}
}
for draw in &self.draws {
if draw.index_range.start > draw.index_range.end {
return Err(SpecError::InvalidRange {
label: draw.label.clone(),
});
}
match draw.source {
DrawSource::Static(idx) => {
let Some(mesh) = self.static_meshes.get(idx) else {
return Err(SpecError::DrawMissingMesh {
label: draw.label.clone(),
});
};
let available = mesh.indices.len() as u32;
if draw.index_range.end > available {
return Err(SpecError::DrawRange {
label: draw.label.clone(),
available,
requested: draw.index_range.end,
});
}
}
DrawSource::Dynamic(idx) => {
let Some(mesh) = self.dynamic_meshes.get(idx) else {
return Err(SpecError::DrawMissingMesh {
label: draw.label.clone(),
});
};
let available = mesh.indices.len() as u32;
if draw.index_range.end > available {
return Err(SpecError::DrawRange {
label: draw.label.clone(),
available,
requested: draw.index_range.end,
});
}
}
}
}
Ok(())
}
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable, Serialize, Deserialize)]
pub struct WorldVertex {
pub position: [f32; 3],
pub normal: [f32; 3],
pub color: [f32; 4],
pub mode: f32,
}
#[cfg(feature = "gpu")]
impl WorldVertex {
pub const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
0 => Float32x3,
1 => Float32x3,
2 => Float32x4,
3 => Float32,
];
pub fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<WorldVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &Self::ATTRIBS,
}
}
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable, Serialize, Deserialize)]
pub struct ScreenVertex {
pub position: [f32; 3],
pub tex_coords: [f32; 2],
pub color: [f32; 4],
pub mode: f32,
}
#[cfg(feature = "gpu")]
impl ScreenVertex {
pub const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
0 => Float32x3,
1 => Float32x2,
2 => Float32x4,
3 => Float32,
];
pub fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<ScreenVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &Self::ATTRIBS,
}
}
}
#[macro_export]
macro_rules! params_buf {
($size:expr) => {
#[cfg(target_arch = "wasm32")]
static mut VZGLYD_PARAMS_BUF: [u8; $size] = [0u8; $size];
#[cfg(target_arch = "wasm32")]
#[unsafe(no_mangle)]
pub extern "C" fn vzglyd_params_ptr() -> i32 {
unsafe { VZGLYD_PARAMS_BUF.as_mut_ptr() as i32 }
}
#[cfg(target_arch = "wasm32")]
#[unsafe(no_mangle)]
pub extern "C" fn vzglyd_params_capacity() -> u32 {
$size as u32
}
};
}
pub const FONT_CHAR_ORDER: &[u8] = b" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-:";
pub fn make_font_atlas() -> Vec<u8> {
fn glyph(c: u8) -> [u8; 7] {
match c {
b' ' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
b'A' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
b'B' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
b'C' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],
b'D' => [0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E],
b'E' => [0x1F, 0x10, 0x10, 0x1C, 0x10, 0x10, 0x1F],
b'F' => [0x1F, 0x10, 0x10, 0x1C, 0x10, 0x10, 0x10],
b'G' => [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E],
b'H' => [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
b'I' => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x1F],
b'J' => [0x0F, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C],
b'K' => [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
b'L' => [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F],
b'M' => [0x11, 0x1B, 0x15, 0x11, 0x11, 0x11, 0x11],
b'N' => [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11],
b'O' => [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
b'P' => [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],
b'Q' => [0x0E, 0x11, 0x11, 0x11, 0x15, 0x13, 0x0F],
b'R' => [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],
b'S' => [0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E],
b'T' => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
b'U' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
b'V' => [0x11, 0x11, 0x11, 0x11, 0x0A, 0x0A, 0x04],
b'W' => [0x11, 0x11, 0x11, 0x15, 0x1B, 0x11, 0x11],
b'X' => [0x11, 0x0A, 0x04, 0x04, 0x04, 0x0A, 0x11],
b'Y' => [0x11, 0x0A, 0x04, 0x04, 0x04, 0x04, 0x04],
b'Z' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F],
b'0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
b'1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
b'2' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
b'3' => [0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E],
b'4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
b'5' => [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E],
b'6' => [0x0E, 0x10, 0x10, 0x1E, 0x11, 0x11, 0x0E],
b'7' => [0x1F, 0x01, 0x02, 0x04, 0x04, 0x04, 0x04],
b'8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
b'9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x11, 0x0E],
b'.' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04],
b'-' => [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00],
b':' => [0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00],
_ => [0x00; 7],
}
}
const AW: usize = 256;
const AH: usize = 8;
let mut buf = vec![0u8; AW * AH * 4];
for (ci, &c) in FONT_CHAR_ORDER.iter().enumerate() {
let rows = glyph(c);
let xb = ci * 6;
for (row, &byte) in rows.iter().enumerate() {
for col in 0..5usize {
if (byte >> (4 - col)) & 1 == 1 {
let i = (row * AW + xb + col) * 4;
buf[i] = 255;
buf[i + 1] = 255;
buf[i + 2] = 255;
buf[i + 3] = 255;
}
}
}
}
buf
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Copy, Pod, bytemuck::Zeroable)]
#[repr(C)]
struct V {
pos: [f32; 3],
}
#[test]
fn limits_pi4_are_conservative() {
let l = Limits::pi4();
assert!(l.max_vertices >= 25_000);
assert!(l.max_indices >= 26_000);
}
#[test]
fn validate_checks_vertex_budget() {
let spec = SlideSpec {
name: "test".to_string(),
limits: Limits {
max_vertices: 3,
max_indices: 10,
max_static_meshes: 2,
max_dynamic_meshes: 1,
max_textures: 1,
max_texture_bytes: 64,
max_texture_dim: 16,
},
scene_space: SceneSpace::Screen2D,
camera_path: None,
shaders: None,
overlay: None,
font: None,
textures_used: 1,
textures: vec![TextureDesc {
label: "t".to_string(),
width: 1,
height: 1,
format: TextureFormat::Rgba8Unorm,
wrap_u: WrapMode::ClampToEdge,
wrap_v: WrapMode::ClampToEdge,
wrap_w: WrapMode::ClampToEdge,
mag_filter: FilterMode::Nearest,
min_filter: FilterMode::Nearest,
mip_filter: FilterMode::Nearest,
data: vec![255, 255, 255, 255],
}],
static_meshes: vec![StaticMesh {
label: "m".to_string(),
vertices: vec![V { pos: [0.0; 3] }; 4],
indices: vec![0, 1, 2],
}],
dynamic_meshes: vec![],
draws: vec![DrawSpec {
label: "d".to_string(),
source: DrawSource::Static(0),
pipeline: PipelineKind::Opaque,
index_range: 0..3,
}],
lighting: None,
};
let err = spec.validate().unwrap_err();
matches!(err, SpecError::VertexBudget { .. });
}
#[test]
fn scene_anchor_translation_reads_transform_origin() {
let anchor = SceneAnchor {
id: "spawn".into(),
label: "Spawn".into(),
node_name: Some("Spawn".into()),
tag: Some("spawn".into()),
world_transform: [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[3.0, 1.5, -2.0, 1.0],
],
};
assert_eq!(anchor.translation(), [3.0, 1.5, -2.0]);
}
#[test]
fn scene_anchor_lookup_reports_available_ids() {
let anchors = SceneAnchorSet {
scene_id: "hero_world".into(),
scene_label: Some("Hero World".into()),
scene_name: Some("WorldScene".into()),
anchors: vec![SceneAnchor {
id: "spawn_marker".into(),
label: "SpawnAnchor".into(),
node_name: Some("SpawnAnchor".into()),
tag: Some("spawn".into()),
world_transform: [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[3.0, 0.0, 2.0, 1.0],
],
}],
};
let error = anchors
.require_anchor("missing")
.expect_err("missing anchor should fail");
assert_eq!(
error.to_string(),
"scene 'hero_world' does not define anchor 'missing' (available: spawn_marker)"
);
}
}