From 1a7f2dfc6ea9d85a1e03cf2c990291f2d52798af Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Wed, 7 Jan 2026 15:31:56 +0000 Subject: [PATCH] feat(scene_framework): Add reusable Lua framework for 3D scene construction with mesh generation and object builders --- docs/SCENE_FRAMEWORK.md | 135 ++++++++++++ scripts/cube_logic.lua | 67 ++---- scripts/scene_framework.lua | 360 +++++++++++++++++++++++++++++++ scripts/test_scene_framework.lua | 261 ++++++++++++++++++++++ 4 files changed, 768 insertions(+), 55 deletions(-) create mode 100644 docs/SCENE_FRAMEWORK.md create mode 100644 scripts/scene_framework.lua create mode 100644 scripts/test_scene_framework.lua diff --git a/docs/SCENE_FRAMEWORK.md b/docs/SCENE_FRAMEWORK.md new file mode 100644 index 0000000..bb91e77 --- /dev/null +++ b/docs/SCENE_FRAMEWORK.md @@ -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 diff --git a/scripts/cube_logic.lua b/scripts/cube_logic.lua index 3650800..19b6763 100644 --- a/scripts/cube_logic.lua +++ b/scripts/cube_logic.lua @@ -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) diff --git a/scripts/scene_framework.lua b/scripts/scene_framework.lua new file mode 100644 index 0000000..d63c893 --- /dev/null +++ b/scripts/scene_framework.lua @@ -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 diff --git a/scripts/test_scene_framework.lua b/scripts/test_scene_framework.lua new file mode 100644 index 0000000..9105839 --- /dev/null +++ b/scripts/test_scene_framework.lua @@ -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