mirror of
https://github.com/johndoe6345789/SDL3CPlusPlus.git
synced 2026-05-07 03:49:37 +00:00
feat(scene_framework): Add reusable Lua framework for 3D scene construction with mesh generation and object builders
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
# Scene Framework
|
||||
|
||||
A reusable Lua framework for 3D scene construction in SDL3CPlusPlus.
|
||||
|
||||
## Overview
|
||||
|
||||
`scene_framework.lua` provides commonly-used utilities for building 3D scenes:
|
||||
- **Mesh Generation**: Planes, cubes, and primitives
|
||||
- **Object Builders**: Standardized scene object creation with metadata
|
||||
- **Config Utilities**: Type-safe configuration resolution
|
||||
- **Material Registry**: Centralized material/shader management
|
||||
|
||||
## Usage
|
||||
|
||||
```lua
|
||||
local scene_framework = require("scene_framework")
|
||||
|
||||
-- Generate a tessellated floor
|
||||
local vertices, indices = scene_framework.generate_plane_mesh(20, 20, 20, {1.0, 1.0, 1.0})
|
||||
|
||||
-- Create a static cube object
|
||||
local cube = scene_framework.create_static_cube(
|
||||
{0, 5, 0}, -- position
|
||||
{2, 2, 2}, -- scale
|
||||
{1.0, 0.5, 0.2}, -- color
|
||||
"solid", -- shader_key
|
||||
"my_cube" -- object_type
|
||||
)
|
||||
|
||||
-- Config resolution with fallbacks
|
||||
local speed = scene_framework.resolve_number(config.move_speed, 5.0)
|
||||
local position = scene_framework.resolve_vec3(config.spawn_point, {0, 0, 0})
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Mesh Generation
|
||||
|
||||
#### `generate_plane_mesh(width, depth, subdivisions, color)`
|
||||
Generates a tessellated plane with normals pointing up (+Y).
|
||||
- Returns: `vertices, indices` (0-based indices)
|
||||
- Example: 20x20 subdivisions = 441 vertices, 800 triangles
|
||||
|
||||
#### `generate_cube_mesh(double_sided)`
|
||||
Generates a unit cube (-0.5 to 0.5) with proper normals per face.
|
||||
- Single-sided: 24 vertices, 36 indices
|
||||
- Double-sided: 24 vertices, 72 indices
|
||||
|
||||
#### `apply_color_to_vertices(vertices, color)`
|
||||
Copies vertex array and applies new color to all vertices.
|
||||
|
||||
#### `flip_normals(vertices)`
|
||||
Inverts normals in-place (useful for ceilings, inside-out geometry).
|
||||
|
||||
### Object Builders
|
||||
|
||||
#### `create_static_object(vertices, indices, position, scale, shader_key, object_type)`
|
||||
Creates a static scene object with no animation.
|
||||
|
||||
#### `create_static_cube(position, scale, color, shader_key, object_type, cube_mesh)`
|
||||
Convenience builder for colored cube objects.
|
||||
|
||||
#### `create_dynamic_object(vertices, indices, compute_fn, shader_key, object_type)`
|
||||
Creates animated object with `compute_fn(time) -> matrix` callback.
|
||||
|
||||
### Config Utilities
|
||||
|
||||
- `resolve_number(value, fallback)` - Safe number extraction
|
||||
- `resolve_boolean(value, fallback)` - Safe boolean extraction
|
||||
- `resolve_string(value, fallback)` - Safe string extraction
|
||||
- `resolve_table(value, fallback)` - Safe table extraction
|
||||
- `resolve_vec3(value, fallback)` - Safe {x,y,z} extraction
|
||||
|
||||
### Transform Utilities
|
||||
|
||||
#### `build_static_model_matrix(position, scale)`
|
||||
Builds transformation matrix from position {x,y,z} and scale {sx,sy,sz}.
|
||||
|
||||
### Material Registry
|
||||
|
||||
```lua
|
||||
local registry = scene_framework.MaterialRegistry.new(config)
|
||||
|
||||
if registry:has("floor") then
|
||||
local material = registry:get("floor")
|
||||
local shader_key = registry:get_key("floor") -- Returns key or default
|
||||
end
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite:
|
||||
```bash
|
||||
cd scripts && lua test_scene_framework.lua
|
||||
```
|
||||
|
||||
65 tests covering:
|
||||
- Config resolution edge cases
|
||||
- Mesh generation correctness
|
||||
- Object builder metadata
|
||||
- Material registry lookups
|
||||
|
||||
## Integration
|
||||
|
||||
See `cube_logic.lua` for real-world usage:
|
||||
```lua
|
||||
local scene_framework = require("scene_framework")
|
||||
|
||||
-- Delegate to framework
|
||||
local function generate_plane_mesh(width, depth, subdivisions, color)
|
||||
local vertices, indices_zero = scene_framework.generate_plane_mesh(width, depth, subdivisions, color)
|
||||
-- Convert 0-based to 1-based indices for Lua
|
||||
local indices = {}
|
||||
for i = 1, #indices_zero do
|
||||
indices[i] = indices_zero[i] + 1
|
||||
end
|
||||
return vertices, indices
|
||||
end
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
- **DRY**: Fix mesh generation bugs once, benefit everywhere
|
||||
- **Consistency**: All scenes use same object structure with `object_type`
|
||||
- **Testability**: Framework tested independently in Lua
|
||||
- **Discoverability**: New scenes see available primitives via documentation
|
||||
- **Type Safety**: Config resolution prevents nil dereferences
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential additions:
|
||||
- `generate_sphere_mesh()` for skyboxes, particles
|
||||
- `generate_cylinder_mesh()` for columns, barrels
|
||||
- Physics object helpers with mass/friction
|
||||
- Texture coordinate generation utilities
|
||||
+12
-55
@@ -1,3 +1,6 @@
|
||||
local scene_framework = require("scene_framework")
|
||||
local math3d = require("math3d")
|
||||
|
||||
local cube_mesh_info = {
|
||||
path = "models/cube.stl",
|
||||
loaded = false,
|
||||
@@ -30,50 +33,14 @@ local function build_double_sided_indices(indices)
|
||||
return doubled
|
||||
end
|
||||
|
||||
-- Generate a tessellated plane for floor/ceiling with proper vertex count
|
||||
-- Delegate to framework for plane mesh generation
|
||||
local function generate_plane_mesh(width, depth, subdivisions, color)
|
||||
local vertices = {}
|
||||
-- Framework returns 0-based indices, convert to 1-based for Lua
|
||||
local vertices, indices_zero = scene_framework.generate_plane_mesh(width, depth, subdivisions, color)
|
||||
local indices = {}
|
||||
|
||||
local step_x = width / subdivisions
|
||||
local step_z = depth / subdivisions
|
||||
local half_width = width * 0.5
|
||||
local half_depth = depth * 0.5
|
||||
|
||||
-- Generate vertices (Lua is 1-indexed)
|
||||
for z = 0, subdivisions do
|
||||
for x = 0, subdivisions do
|
||||
local px = -half_width + x * step_x
|
||||
local pz = -half_depth + z * step_z
|
||||
vertices[#vertices + 1] = {
|
||||
position = {px, 0.0, pz},
|
||||
normal = {0.0, 1.0, 0.0}, -- Up normal
|
||||
color = color or {1.0, 1.0, 1.0},
|
||||
texcoord = {x / subdivisions, z / subdivisions},
|
||||
}
|
||||
end
|
||||
for i = 1, #indices_zero do
|
||||
indices[i] = indices_zero[i] + 1
|
||||
end
|
||||
|
||||
-- Generate indices (two triangles per quad, convert to 1-based for Lua)
|
||||
for z = 0, subdivisions - 1 do
|
||||
for x = 0, subdivisions - 1 do
|
||||
-- Calculate 0-based indices first
|
||||
local i0 = z * (subdivisions + 1) + x
|
||||
local i1 = i0 + 1
|
||||
local i2 = i0 + (subdivisions + 1)
|
||||
local i3 = i2 + 1
|
||||
|
||||
-- Convert to 1-based indices for Lua
|
||||
indices[#indices + 1] = i0 + 1
|
||||
indices[#indices + 1] = i2 + 1
|
||||
indices[#indices + 1] = i1 + 1
|
||||
|
||||
indices[#indices + 1] = i1 + 1
|
||||
indices[#indices + 1] = i2 + 1
|
||||
indices[#indices + 1] = i3 + 1
|
||||
end
|
||||
end
|
||||
|
||||
return vertices, indices
|
||||
end
|
||||
|
||||
@@ -758,24 +725,14 @@ local function resolve_material_shader()
|
||||
error("MaterialX enabled but no materialx_materials shader_key found")
|
||||
end
|
||||
|
||||
-- Delegate to framework
|
||||
local function build_static_model_matrix(position, scale)
|
||||
local translation = math3d.translation(position[1], position[2], position[3])
|
||||
local scaling = scale_matrix(scale[1], scale[2], scale[3])
|
||||
return math3d.multiply(translation, scaling)
|
||||
return scene_framework.build_static_model_matrix(position, scale)
|
||||
end
|
||||
|
||||
-- Apply color using current cube_vertices
|
||||
local function apply_color_to_vertices(color)
|
||||
local colored_vertices = {}
|
||||
for i = 1, #cube_vertices do
|
||||
local v = cube_vertices[i]
|
||||
colored_vertices[i] = {
|
||||
position = v.position,
|
||||
normal = v.normal,
|
||||
color = color,
|
||||
texcoord = v.texcoord,
|
||||
}
|
||||
end
|
||||
return colored_vertices
|
||||
return scene_framework.apply_color_to_vertices(cube_vertices, color)
|
||||
end
|
||||
|
||||
local function create_static_cube(position, scale, color, shader_key, object_type)
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
-- Scene Framework: Reusable components for 3D scene construction
|
||||
-- Provides mesh generation, object builders, and utility functions
|
||||
|
||||
local math3d = require("math3d")
|
||||
|
||||
local framework = {}
|
||||
|
||||
-- ============================================================================
|
||||
-- Config Resolution Utilities
|
||||
-- ============================================================================
|
||||
|
||||
function framework.resolve_number(value, fallback)
|
||||
if type(value) == "number" then
|
||||
return value
|
||||
end
|
||||
return fallback
|
||||
end
|
||||
|
||||
function framework.resolve_boolean(value, fallback)
|
||||
if type(value) == "boolean" then
|
||||
return value
|
||||
end
|
||||
return fallback
|
||||
end
|
||||
|
||||
function framework.resolve_string(value, fallback)
|
||||
if type(value) == "string" then
|
||||
return value
|
||||
end
|
||||
return fallback
|
||||
end
|
||||
|
||||
function framework.resolve_table(value, fallback)
|
||||
if type(value) == "table" then
|
||||
return value
|
||||
end
|
||||
return fallback or {}
|
||||
end
|
||||
|
||||
function framework.resolve_vec3(value, fallback)
|
||||
if type(value) == "table"
|
||||
and type(value[1]) == "number"
|
||||
and type(value[2]) == "number"
|
||||
and type(value[3]) == "number" then
|
||||
return {value[1], value[2], value[3]}
|
||||
end
|
||||
return {fallback[1], fallback[2], fallback[3]}
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Mesh Generation
|
||||
-- ============================================================================
|
||||
|
||||
--- Generate a tessellated plane mesh with normals pointing up (+Y)
|
||||
--- @param width number Width along X axis
|
||||
--- @param depth number Depth along Z axis
|
||||
--- @param subdivisions number Number of divisions per axis (e.g., 20 = 400 quads = 441 vertices)
|
||||
--- @param color table RGB color {r, g, b} with values 0-1, defaults to white
|
||||
--- @return table vertices Array of vertex tables with position, normal, color, texcoord
|
||||
--- @return table indices Array of uint16 triangle indices
|
||||
function framework.generate_plane_mesh(width, depth, subdivisions, color)
|
||||
color = color or {1.0, 1.0, 1.0}
|
||||
subdivisions = subdivisions or 1
|
||||
|
||||
local vertices = {}
|
||||
local indices = {}
|
||||
local half_width = width / 2
|
||||
local half_depth = depth / 2
|
||||
local step_x = width / subdivisions
|
||||
local step_z = depth / subdivisions
|
||||
|
||||
-- Generate vertices
|
||||
for z = 0, subdivisions do
|
||||
for x = 0, subdivisions do
|
||||
local px = -half_width + x * step_x
|
||||
local pz = -half_depth + z * step_z
|
||||
local u = x / subdivisions
|
||||
local v = z / subdivisions
|
||||
|
||||
table.insert(vertices, {
|
||||
position = {px, 0.0, pz},
|
||||
normal = {0.0, 1.0, 0.0},
|
||||
color = color,
|
||||
texcoord = {u, v}
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Generate indices (two triangles per quad)
|
||||
for z = 0, subdivisions - 1 do
|
||||
for x = 0, subdivisions - 1 do
|
||||
local base = z * (subdivisions + 1) + x
|
||||
local v0 = base
|
||||
local v1 = base + 1
|
||||
local v2 = base + subdivisions + 1
|
||||
local v3 = base + subdivisions + 2
|
||||
|
||||
-- First triangle
|
||||
table.insert(indices, v0)
|
||||
table.insert(indices, v2)
|
||||
table.insert(indices, v1)
|
||||
|
||||
-- Second triangle
|
||||
table.insert(indices, v1)
|
||||
table.insert(indices, v2)
|
||||
table.insert(indices, v3)
|
||||
end
|
||||
end
|
||||
|
||||
return vertices, indices
|
||||
end
|
||||
|
||||
--- Apply a color to a copy of vertices array
|
||||
--- @param vertices table Source vertices array
|
||||
--- @param color table RGB color {r, g, b} with values 0-1
|
||||
--- @return table New vertices array with color applied
|
||||
function framework.apply_color_to_vertices(vertices, color)
|
||||
local colored = {}
|
||||
for i = 1, #vertices do
|
||||
local v = vertices[i]
|
||||
colored[i] = {
|
||||
position = v.position,
|
||||
normal = v.normal,
|
||||
color = color,
|
||||
texcoord = v.texcoord
|
||||
}
|
||||
end
|
||||
return colored
|
||||
end
|
||||
|
||||
--- Flip normals for a vertices array (useful for ceilings, inside-out geometry)
|
||||
--- @param vertices table Vertices array to modify in-place
|
||||
function framework.flip_normals(vertices)
|
||||
for i = 1, #vertices do
|
||||
local n = vertices[i].normal
|
||||
vertices[i].normal = {-n[1], -n[2], -n[3]}
|
||||
end
|
||||
end
|
||||
|
||||
--- Generate a cube mesh (unit cube from -0.5 to 0.5)
|
||||
--- @param double_sided boolean If true, generates back faces for inside-out rendering
|
||||
--- @return table vertices Array of 24 vertices (4 per face)
|
||||
--- @return table indices Array of triangle indices (36 for single-sided, 72 for double-sided)
|
||||
function framework.generate_cube_mesh(double_sided)
|
||||
-- Standard unit cube vertices (8 unique positions, but 24 vertices for proper normals per face)
|
||||
local vertices = {
|
||||
-- Front face (+Z)
|
||||
{position = {-0.5, -0.5, 0.5}, normal = { 0.0, 0.0, 1.0}, color = {1, 1, 1}, texcoord = {0, 0}},
|
||||
{position = { 0.5, -0.5, 0.5}, normal = { 0.0, 0.0, 1.0}, color = {1, 1, 1}, texcoord = {1, 0}},
|
||||
{position = { 0.5, 0.5, 0.5}, normal = { 0.0, 0.0, 1.0}, color = {1, 1, 1}, texcoord = {1, 1}},
|
||||
{position = {-0.5, 0.5, 0.5}, normal = { 0.0, 0.0, 1.0}, color = {1, 1, 1}, texcoord = {0, 1}},
|
||||
|
||||
-- Back face (-Z)
|
||||
{position = { 0.5, -0.5, -0.5}, normal = { 0.0, 0.0, -1.0}, color = {1, 1, 1}, texcoord = {0, 0}},
|
||||
{position = {-0.5, -0.5, -0.5}, normal = { 0.0, 0.0, -1.0}, color = {1, 1, 1}, texcoord = {1, 0}},
|
||||
{position = {-0.5, 0.5, -0.5}, normal = { 0.0, 0.0, -1.0}, color = {1, 1, 1}, texcoord = {1, 1}},
|
||||
{position = { 0.5, 0.5, -0.5}, normal = { 0.0, 0.0, -1.0}, color = {1, 1, 1}, texcoord = {0, 1}},
|
||||
|
||||
-- Top face (+Y)
|
||||
{position = {-0.5, 0.5, 0.5}, normal = { 0.0, 1.0, 0.0}, color = {1, 1, 1}, texcoord = {0, 0}},
|
||||
{position = { 0.5, 0.5, 0.5}, normal = { 0.0, 1.0, 0.0}, color = {1, 1, 1}, texcoord = {1, 0}},
|
||||
{position = { 0.5, 0.5, -0.5}, normal = { 0.0, 1.0, 0.0}, color = {1, 1, 1}, texcoord = {1, 1}},
|
||||
{position = {-0.5, 0.5, -0.5}, normal = { 0.0, 1.0, 0.0}, color = {1, 1, 1}, texcoord = {0, 1}},
|
||||
|
||||
-- Bottom face (-Y)
|
||||
{position = {-0.5, -0.5, -0.5}, normal = { 0.0, -1.0, 0.0}, color = {1, 1, 1}, texcoord = {0, 0}},
|
||||
{position = { 0.5, -0.5, -0.5}, normal = { 0.0, -1.0, 0.0}, color = {1, 1, 1}, texcoord = {1, 0}},
|
||||
{position = { 0.5, -0.5, 0.5}, normal = { 0.0, -1.0, 0.0}, color = {1, 1, 1}, texcoord = {1, 1}},
|
||||
{position = {-0.5, -0.5, 0.5}, normal = { 0.0, -1.0, 0.0}, color = {1, 1, 1}, texcoord = {0, 1}},
|
||||
|
||||
-- Right face (+X)
|
||||
{position = { 0.5, -0.5, 0.5}, normal = { 1.0, 0.0, 0.0}, color = {1, 1, 1}, texcoord = {0, 0}},
|
||||
{position = { 0.5, -0.5, -0.5}, normal = { 1.0, 0.0, 0.0}, color = {1, 1, 1}, texcoord = {1, 0}},
|
||||
{position = { 0.5, 0.5, -0.5}, normal = { 1.0, 0.0, 0.0}, color = {1, 1, 1}, texcoord = {1, 1}},
|
||||
{position = { 0.5, 0.5, 0.5}, normal = { 1.0, 0.0, 0.0}, color = {1, 1, 1}, texcoord = {0, 1}},
|
||||
|
||||
-- Left face (-X)
|
||||
{position = {-0.5, -0.5, -0.5}, normal = {-1.0, 0.0, 0.0}, color = {1, 1, 1}, texcoord = {0, 0}},
|
||||
{position = {-0.5, -0.5, 0.5}, normal = {-1.0, 0.0, 0.0}, color = {1, 1, 1}, texcoord = {1, 0}},
|
||||
{position = {-0.5, 0.5, 0.5}, normal = {-1.0, 0.0, 0.0}, color = {1, 1, 1}, texcoord = {1, 1}},
|
||||
{position = {-0.5, 0.5, -0.5}, normal = {-1.0, 0.0, 0.0}, color = {1, 1, 1}, texcoord = {0, 1}},
|
||||
}
|
||||
|
||||
local indices = {
|
||||
-- Front
|
||||
0, 1, 2, 2, 3, 0,
|
||||
-- Back
|
||||
4, 5, 6, 6, 7, 4,
|
||||
-- Top
|
||||
8, 9, 10, 10, 11, 8,
|
||||
-- Bottom
|
||||
12, 13, 14, 14, 15, 12,
|
||||
-- Right
|
||||
16, 17, 18, 18, 19, 16,
|
||||
-- Left
|
||||
20, 21, 22, 22, 23, 20,
|
||||
}
|
||||
|
||||
if double_sided then
|
||||
-- Add reversed winding order for back faces
|
||||
local reverse_indices = {
|
||||
-- Front (reversed)
|
||||
2, 1, 0, 0, 3, 2,
|
||||
-- Back (reversed)
|
||||
6, 5, 4, 4, 7, 6,
|
||||
-- Top (reversed)
|
||||
10, 9, 8, 8, 11, 10,
|
||||
-- Bottom (reversed)
|
||||
14, 13, 12, 12, 15, 14,
|
||||
-- Right (reversed)
|
||||
18, 17, 16, 16, 19, 18,
|
||||
-- Left (reversed)
|
||||
22, 21, 20, 20, 23, 22,
|
||||
}
|
||||
for i = 1, #reverse_indices do
|
||||
indices[#indices + 1] = reverse_indices[i]
|
||||
end
|
||||
end
|
||||
|
||||
return vertices, indices
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Transform Utilities
|
||||
-- ============================================================================
|
||||
|
||||
--- Build a scale matrix manually (math3d doesn't have scale function)
|
||||
--- @param x number X-axis scale
|
||||
--- @param y number Y-axis scale
|
||||
--- @param z number Z-axis scale
|
||||
--- @return table 4x4 scale matrix
|
||||
local function scale_matrix(x, y, z)
|
||||
return {
|
||||
x, 0.0, 0.0, 0.0,
|
||||
0.0, y, 0.0, 0.0,
|
||||
0.0, 0.0, z, 0.0,
|
||||
0.0, 0.0, 0.0, 1.0,
|
||||
}
|
||||
end
|
||||
|
||||
--- Build a static model matrix from position and scale
|
||||
--- @param position table {x, y, z} world position
|
||||
--- @param scale table {sx, sy, sz} scale factors
|
||||
--- @return table 4x4 matrix as flat array of 16 floats
|
||||
function framework.build_static_model_matrix(position, scale)
|
||||
local translation = math3d.translation(position[1], position[2], position[3])
|
||||
local scaling = scale_matrix(scale[1], scale[2], scale[3])
|
||||
return math3d.multiply(translation, scaling)
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Scene Object Builders
|
||||
-- ============================================================================
|
||||
|
||||
--- Create a static scene object (no animation)
|
||||
--- @param vertices table Vertex array
|
||||
--- @param indices table Index array
|
||||
--- @param position table {x, y, z} world position
|
||||
--- @param scale table {sx, sy, sz} scale factors
|
||||
--- @param shader_key string Material/shader identifier
|
||||
--- @param object_type string Semantic object type for identification
|
||||
--- @return table Scene object with standard structure
|
||||
function framework.create_static_object(vertices, indices, position, scale, shader_key, object_type)
|
||||
local model_matrix = framework.build_static_model_matrix(position, scale)
|
||||
|
||||
local function compute_model_matrix()
|
||||
return model_matrix
|
||||
end
|
||||
|
||||
return {
|
||||
vertices = vertices,
|
||||
indices = indices,
|
||||
compute_model_matrix = compute_model_matrix,
|
||||
shader_keys = {shader_key},
|
||||
object_type = object_type or "static",
|
||||
}
|
||||
end
|
||||
|
||||
--- Create a static cube object with color
|
||||
--- @param position table {x, y, z} world position
|
||||
--- @param scale table {sx, sy, sz} scale factors
|
||||
--- @param color table {r, g, b} vertex color
|
||||
--- @param shader_key string Material/shader identifier
|
||||
--- @param object_type string Semantic object type
|
||||
--- @param cube_mesh table Optional pre-generated cube mesh {vertices, indices}
|
||||
--- @return table Scene object
|
||||
function framework.create_static_cube(position, scale, color, shader_key, object_type, cube_mesh)
|
||||
if not cube_mesh then
|
||||
cube_mesh = {framework.generate_cube_mesh(false)}
|
||||
end
|
||||
|
||||
local vertices = cube_mesh[1] or cube_mesh.vertices
|
||||
local indices = cube_mesh[2] or cube_mesh.indices
|
||||
|
||||
if color then
|
||||
vertices = framework.apply_color_to_vertices(vertices, color)
|
||||
end
|
||||
|
||||
return framework.create_static_object(vertices, indices, position, scale, shader_key, object_type)
|
||||
end
|
||||
|
||||
--- Create a dynamic scene object with animation callback
|
||||
--- @param vertices table Vertex array
|
||||
--- @param indices table Index array
|
||||
--- @param compute_fn function Function(time) -> matrix that computes model matrix
|
||||
--- @param shader_key string Material/shader identifier
|
||||
--- @param object_type string Semantic object type
|
||||
--- @return table Scene object with dynamic transform
|
||||
function framework.create_dynamic_object(vertices, indices, compute_fn, shader_key, object_type)
|
||||
return {
|
||||
vertices = vertices,
|
||||
indices = indices,
|
||||
compute_model_matrix = compute_fn,
|
||||
shader_keys = {shader_key},
|
||||
object_type = object_type or "dynamic",
|
||||
}
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Material Registry
|
||||
-- ============================================================================
|
||||
|
||||
framework.MaterialRegistry = {}
|
||||
framework.MaterialRegistry.__index = framework.MaterialRegistry
|
||||
|
||||
function framework.MaterialRegistry.new(config)
|
||||
local self = setmetatable({}, framework.MaterialRegistry)
|
||||
self.materials = {}
|
||||
self.default_key = nil
|
||||
|
||||
if config and config.materialx_materials then
|
||||
for i, mat in ipairs(config.materialx_materials) do
|
||||
if mat.shader_key then
|
||||
self.materials[mat.shader_key] = mat
|
||||
if i == 1 then
|
||||
self.default_key = mat.shader_key
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
function framework.MaterialRegistry:get(shader_key)
|
||||
return self.materials[shader_key]
|
||||
end
|
||||
|
||||
function framework.MaterialRegistry:get_key(shader_key)
|
||||
if self.materials[shader_key] then
|
||||
return shader_key
|
||||
end
|
||||
return self.default_key
|
||||
end
|
||||
|
||||
function framework.MaterialRegistry:has(shader_key)
|
||||
return self.materials[shader_key] ~= nil
|
||||
end
|
||||
|
||||
return framework
|
||||
@@ -0,0 +1,261 @@
|
||||
-- Test suite for scene_framework.lua
|
||||
-- Run with: lua scripts/test_scene_framework.lua
|
||||
|
||||
-- Mock math3d for standalone testing
|
||||
package.preload['math3d'] = function()
|
||||
local math3d = {}
|
||||
|
||||
function math3d.translation(x, y, z)
|
||||
return {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1}
|
||||
end
|
||||
|
||||
function math3d.multiply(a, b)
|
||||
-- Simplified matrix multiply for test purposes
|
||||
return {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}
|
||||
end
|
||||
|
||||
return math3d
|
||||
end
|
||||
|
||||
local framework = require("scene_framework")
|
||||
|
||||
local tests_run = 0
|
||||
local tests_passed = 0
|
||||
local tests_failed = 0
|
||||
|
||||
local function assert_equal(actual, expected, message)
|
||||
tests_run = tests_run + 1
|
||||
if actual == expected then
|
||||
tests_passed = tests_passed + 1
|
||||
return true
|
||||
else
|
||||
tests_failed = tests_failed + 1
|
||||
print(string.format("FAIL: %s (expected %s, got %s)", message, tostring(expected), tostring(actual)))
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
local function assert_near(actual, expected, epsilon, message)
|
||||
tests_run = tests_run + 1
|
||||
local diff = math.abs(actual - expected)
|
||||
if diff <= epsilon then
|
||||
tests_passed = tests_passed + 1
|
||||
return true
|
||||
else
|
||||
tests_failed = tests_failed + 1
|
||||
print(string.format("FAIL: %s (expected %f±%f, got %f, diff %f)",
|
||||
message, expected, epsilon, actual, diff))
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
local function assert_true(condition, message)
|
||||
return assert_equal(condition, true, message)
|
||||
end
|
||||
|
||||
local function assert_not_nil(value, message)
|
||||
tests_run = tests_run + 1
|
||||
if value ~= nil then
|
||||
tests_passed = tests_passed + 1
|
||||
return true
|
||||
else
|
||||
tests_failed = tests_failed + 1
|
||||
print(string.format("FAIL: %s (value is nil)", message))
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Config Resolution Tests
|
||||
-- ============================================================================
|
||||
|
||||
print("Testing config resolution utilities...")
|
||||
|
||||
assert_equal(framework.resolve_number(42, 0), 42, "resolve_number with number")
|
||||
assert_equal(framework.resolve_number("not a number", 99), 99, "resolve_number with fallback")
|
||||
assert_equal(framework.resolve_number(nil, 123), 123, "resolve_number with nil")
|
||||
|
||||
assert_equal(framework.resolve_boolean(true, false), true, "resolve_boolean with true")
|
||||
assert_equal(framework.resolve_boolean(false, true), false, "resolve_boolean with false")
|
||||
assert_equal(framework.resolve_boolean("not bool", true), true, "resolve_boolean with fallback")
|
||||
|
||||
assert_equal(framework.resolve_string("hello", "default"), "hello", "resolve_string with string")
|
||||
assert_equal(framework.resolve_string(123, "default"), "default", "resolve_string with fallback")
|
||||
|
||||
local tbl = {1, 2, 3}
|
||||
assert_equal(framework.resolve_table(tbl, {}), tbl, "resolve_table with table")
|
||||
assert_equal(type(framework.resolve_table("not table", nil)), "table", "resolve_table creates empty table")
|
||||
|
||||
local vec = framework.resolve_vec3({1, 2, 3}, {0, 0, 0})
|
||||
assert_equal(vec[1], 1, "resolve_vec3 x component")
|
||||
assert_equal(vec[2], 2, "resolve_vec3 y component")
|
||||
assert_equal(vec[3], 3, "resolve_vec3 z component")
|
||||
|
||||
local fallback_vec = framework.resolve_vec3("invalid", {7, 8, 9})
|
||||
assert_equal(fallback_vec[1], 7, "resolve_vec3 fallback x")
|
||||
assert_equal(fallback_vec[2], 8, "resolve_vec3 fallback y")
|
||||
assert_equal(fallback_vec[3], 9, "resolve_vec3 fallback z")
|
||||
|
||||
-- ============================================================================
|
||||
-- Mesh Generation Tests
|
||||
-- ============================================================================
|
||||
|
||||
print("Testing mesh generation...")
|
||||
|
||||
-- Test plane mesh generation
|
||||
local plane_verts, plane_indices = framework.generate_plane_mesh(10, 10, 2, {1.0, 0.5, 0.25})
|
||||
assert_equal(#plane_verts, 9, "plane 2x2 subdivision has 9 vertices (3x3 grid)")
|
||||
assert_equal(#plane_indices, 24, "plane 2x2 subdivision has 24 indices (8 triangles)")
|
||||
|
||||
-- Check first vertex
|
||||
assert_not_nil(plane_verts[1], "plane first vertex exists")
|
||||
assert_equal(type(plane_verts[1].position), "table", "vertex has position")
|
||||
assert_equal(type(plane_verts[1].normal), "table", "vertex has normal")
|
||||
assert_equal(type(plane_verts[1].color), "table", "vertex has color")
|
||||
assert_equal(plane_verts[1].color[1], 1.0, "plane vertex color r")
|
||||
assert_equal(plane_verts[1].color[2], 0.5, "plane vertex color g")
|
||||
assert_equal(plane_verts[1].color[3], 0.25, "plane vertex color b")
|
||||
|
||||
-- Test plane with default color
|
||||
local white_verts, _ = framework.generate_plane_mesh(10, 10, 1)
|
||||
assert_equal(white_verts[1].color[1], 1.0, "default plane color is white r")
|
||||
assert_equal(white_verts[1].color[2], 1.0, "default plane color is white g")
|
||||
assert_equal(white_verts[1].color[3], 1.0, "default plane color is white b")
|
||||
|
||||
-- Test cube mesh generation
|
||||
local cube_verts, cube_indices = framework.generate_cube_mesh(false)
|
||||
assert_equal(#cube_verts, 24, "cube has 24 vertices (4 per face)")
|
||||
assert_equal(#cube_indices, 36, "single-sided cube has 36 indices (12 triangles)")
|
||||
|
||||
local cube_double, cube_double_idx = framework.generate_cube_mesh(true)
|
||||
assert_equal(#cube_double, 24, "double-sided cube still has 24 vertices")
|
||||
assert_equal(#cube_double_idx, 72, "double-sided cube has 72 indices (24 triangles)")
|
||||
|
||||
-- Test apply color
|
||||
local colored_verts = framework.apply_color_to_vertices(cube_verts, {0.8, 0.6, 0.4})
|
||||
assert_equal(#colored_verts, #cube_verts, "colored vertices same count as input")
|
||||
assert_equal(colored_verts[1].color[1], 0.8, "applied color r")
|
||||
assert_equal(colored_verts[1].color[2], 0.6, "applied color g")
|
||||
assert_equal(colored_verts[1].color[3], 0.4, "applied color b")
|
||||
-- Verify original unchanged
|
||||
assert_equal(cube_verts[1].color[1], 1.0, "original vertices unchanged")
|
||||
|
||||
-- Test flip normals
|
||||
local test_verts = {
|
||||
{position = {0, 0, 0}, normal = {0, 1, 0}, color = {1, 1, 1}, texcoord = {0, 0}},
|
||||
{position = {1, 0, 0}, normal = {1, 0, 0}, color = {1, 1, 1}, texcoord = {1, 0}},
|
||||
}
|
||||
framework.flip_normals(test_verts)
|
||||
assert_equal(test_verts[1].normal[2], -1, "flipped normal y to -1")
|
||||
assert_equal(test_verts[2].normal[1], -1, "flipped normal x to -1")
|
||||
|
||||
-- ============================================================================
|
||||
-- Object Builder Tests
|
||||
-- ============================================================================
|
||||
|
||||
print("Testing object builders...")
|
||||
|
||||
-- Test static object creation
|
||||
local static_obj = framework.create_static_object(
|
||||
cube_verts,
|
||||
cube_indices,
|
||||
{5, 10, 15},
|
||||
{2, 3, 4},
|
||||
"test_shader",
|
||||
"test_object"
|
||||
)
|
||||
|
||||
assert_not_nil(static_obj, "static object created")
|
||||
assert_equal(static_obj.vertices, cube_verts, "static object has vertices")
|
||||
assert_equal(static_obj.indices, cube_indices, "static object has indices")
|
||||
assert_equal(static_obj.shader_keys[1], "test_shader", "static object has shader key")
|
||||
assert_equal(static_obj.object_type, "test_object", "static object has type")
|
||||
assert_equal(type(static_obj.compute_model_matrix), "function", "static object has matrix function")
|
||||
|
||||
local matrix = static_obj.compute_model_matrix()
|
||||
assert_equal(type(matrix), "table", "compute_model_matrix returns table")
|
||||
|
||||
-- Test static cube creation
|
||||
local cube_obj = framework.create_static_cube(
|
||||
{1, 2, 3},
|
||||
{0.5, 0.5, 0.5},
|
||||
{0.9, 0.1, 0.1},
|
||||
"cube_shader",
|
||||
"my_cube"
|
||||
)
|
||||
|
||||
assert_not_nil(cube_obj, "static cube created")
|
||||
assert_equal(cube_obj.object_type, "my_cube", "cube has correct type")
|
||||
assert_equal(cube_obj.shader_keys[1], "cube_shader", "cube has correct shader")
|
||||
assert_equal(cube_obj.vertices[1].color[1], 0.9, "cube has applied color")
|
||||
|
||||
-- Test dynamic object creation
|
||||
local time_value = 0
|
||||
local dynamic_obj = framework.create_dynamic_object(
|
||||
plane_verts,
|
||||
plane_indices,
|
||||
function(time)
|
||||
time_value = time
|
||||
return {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}
|
||||
end,
|
||||
"dynamic_shader",
|
||||
"animated"
|
||||
)
|
||||
|
||||
assert_not_nil(dynamic_obj, "dynamic object created")
|
||||
assert_equal(dynamic_obj.object_type, "animated", "dynamic object has type")
|
||||
local _ = dynamic_obj.compute_model_matrix(42)
|
||||
assert_equal(time_value, 42, "dynamic compute function receives time")
|
||||
|
||||
-- ============================================================================
|
||||
-- Material Registry Tests
|
||||
-- ============================================================================
|
||||
|
||||
print("Testing material registry...")
|
||||
|
||||
local test_config = {
|
||||
materialx_materials = {
|
||||
{shader_key = "floor", document = "floor.mtlx", material = "Floor"},
|
||||
{shader_key = "wall", document = "wall.mtlx", material = "Wall"},
|
||||
{shader_key = "ceiling", document = "ceiling.mtlx", material = "Ceiling"},
|
||||
}
|
||||
}
|
||||
|
||||
local registry = framework.MaterialRegistry.new(test_config)
|
||||
assert_not_nil(registry, "material registry created")
|
||||
|
||||
assert_true(registry:has("floor"), "registry has floor material")
|
||||
assert_true(registry:has("wall"), "registry has wall material")
|
||||
assert_true(registry:has("ceiling"), "registry has ceiling material")
|
||||
assert_equal(registry:has("nonexistent"), false, "registry doesn't have fake material")
|
||||
|
||||
local floor_mat = registry:get("floor")
|
||||
assert_not_nil(floor_mat, "can get floor material")
|
||||
assert_equal(floor_mat.shader_key, "floor", "floor material has correct key")
|
||||
assert_equal(floor_mat.document, "floor.mtlx", "floor material has document")
|
||||
|
||||
assert_equal(registry:get_key("floor"), "floor", "get_key returns existing key")
|
||||
assert_equal(registry:get_key("fake"), "floor", "get_key returns default for missing key")
|
||||
|
||||
-- Test empty registry
|
||||
local empty_registry = framework.MaterialRegistry.new(nil)
|
||||
assert_not_nil(empty_registry, "empty registry created")
|
||||
assert_equal(empty_registry:has("anything"), false, "empty registry has nothing")
|
||||
|
||||
-- ============================================================================
|
||||
-- Summary
|
||||
-- ============================================================================
|
||||
|
||||
print("\n" .. string.rep("=", 60))
|
||||
print(string.format("Tests run: %d", tests_run))
|
||||
print(string.format("Passed: %d", tests_passed))
|
||||
print(string.format("Failed: %d", tests_failed))
|
||||
print(string.rep("=", 60))
|
||||
|
||||
if tests_failed == 0 then
|
||||
print("✓ ALL TESTS PASSED")
|
||||
os.exit(0)
|
||||
else
|
||||
print("✗ SOME TESTS FAILED")
|
||||
os.exit(1)
|
||||
end
|
||||
Reference in New Issue
Block a user