From 8abba16137515513c17d164f4eeacdd8c4b7b1c6 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 19:58:56 +0900 Subject: [PATCH] docs: add implementation plans for JPG decoder, glTF parser, ECS filters/scheduler Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-25-phase2-gltf-parser.md | 959 ++++++++++++++++ .../plans/2026-03-25-phase2-jpg-decoder.md | 1012 +++++++++++++++++ ...026-03-25-phase3a-ecs-filters-scheduler.md | 441 +++++++ 3 files changed, 2412 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-25-phase2-gltf-parser.md create mode 100644 docs/superpowers/plans/2026-03-25-phase2-jpg-decoder.md create mode 100644 docs/superpowers/plans/2026-03-25-phase3a-ecs-filters-scheduler.md diff --git a/docs/superpowers/plans/2026-03-25-phase2-gltf-parser.md b/docs/superpowers/plans/2026-03-25-phase2-gltf-parser.md new file mode 100644 index 0000000..e027c9d --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-phase2-gltf-parser.md @@ -0,0 +1,959 @@ +# glTF/GLB Parser Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Self-contained glTF 2.0 / GLB parser that returns mesh data compatible with existing `MeshVertex` and `ObjData` patterns. + +**Architecture:** GLB header parser → mini JSON parser → accessor/bufferView extraction → vertex assembly with existing `compute_tangents`. Single file `gltf.rs` plus `json_parser.rs` for the JSON subset parser. + +**Tech Stack:** Pure Rust, no external dependencies. Reuses `MeshVertex` from `vertex.rs` and `compute_tangents` from `obj.rs`. + +--- + +### Task 1: Mini JSON Parser + +**Files:** +- Create: `crates/voltex_renderer/src/json_parser.rs` +- Modify: `crates/voltex_renderer/src/lib.rs` + +- [ ] **Step 1: Write tests for JSON parsing** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_null() { + assert_eq!(parse_json("null").unwrap(), JsonValue::Null); + } + + #[test] + fn test_parse_bool() { + assert_eq!(parse_json("true").unwrap(), JsonValue::Bool(true)); + assert_eq!(parse_json("false").unwrap(), JsonValue::Bool(false)); + } + + #[test] + fn test_parse_number() { + match parse_json("42").unwrap() { + JsonValue::Number(n) => assert!((n - 42.0).abs() < 1e-10), + other => panic!("Expected Number, got {:?}", other), + } + match parse_json("-3.14").unwrap() { + JsonValue::Number(n) => assert!((n - (-3.14)).abs() < 1e-10), + other => panic!("Expected Number, got {:?}", other), + } + } + + #[test] + fn test_parse_string() { + assert_eq!(parse_json("\"hello\"").unwrap(), JsonValue::String("hello".into())); + } + + #[test] + fn test_parse_string_escapes() { + assert_eq!( + parse_json(r#""hello\nworld""#).unwrap(), + JsonValue::String("hello\nworld".into()) + ); + } + + #[test] + fn test_parse_array() { + let val = parse_json("[1, 2, 3]").unwrap(); + match val { + JsonValue::Array(arr) => assert_eq!(arr.len(), 3), + other => panic!("Expected Array, got {:?}", other), + } + } + + #[test] + fn test_parse_object() { + let val = parse_json(r#"{"name": "test", "value": 42}"#).unwrap(); + match val { + JsonValue::Object(map) => { + assert_eq!(map.len(), 2); + assert_eq!(map[0].0, "name"); + } + other => panic!("Expected Object, got {:?}", other), + } + } + + #[test] + fn test_parse_nested() { + let json = r#"{"meshes": [{"name": "Cube", "primitives": [{"attributes": {"POSITION": 0}}]}]}"#; + let val = parse_json(json).unwrap(); + assert!(matches!(val, JsonValue::Object(_))); + } + + #[test] + fn test_parse_empty_array() { + assert_eq!(parse_json("[]").unwrap(), JsonValue::Array(vec![])); + } + + #[test] + fn test_parse_empty_object() { + assert_eq!(parse_json("{}").unwrap(), JsonValue::Object(vec![])); + } +} +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `cargo test --package voltex_renderer -- json_parser::tests -v` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement mini JSON parser** + +```rust +// crates/voltex_renderer/src/json_parser.rs + +/// Minimal JSON parser for glTF. No external dependencies. + +#[derive(Debug, Clone, PartialEq)] +pub enum JsonValue { + Null, + Bool(bool), + Number(f64), + String(String), + Array(Vec), + Object(Vec<(String, JsonValue)>), // preserve order +} + +impl JsonValue { + pub fn as_object(&self) -> Option<&[(String, JsonValue)]> { + match self { JsonValue::Object(v) => Some(v), _ => None } + } + pub fn as_array(&self) -> Option<&[JsonValue]> { + match self { JsonValue::Array(v) => Some(v), _ => None } + } + pub fn as_str(&self) -> Option<&str> { + match self { JsonValue::String(s) => Some(s), _ => None } + } + pub fn as_f64(&self) -> Option { + match self { JsonValue::Number(n) => Some(*n), _ => None } + } + pub fn as_u32(&self) -> Option { + self.as_f64().map(|n| n as u32) + } + pub fn as_bool(&self) -> Option { + match self { JsonValue::Bool(b) => Some(*b), _ => None } + } + pub fn get(&self, key: &str) -> Option<&JsonValue> { + self.as_object()?.iter().find(|(k, _)| k == key).map(|(_, v)| v) + } + pub fn index(&self, i: usize) -> Option<&JsonValue> { + self.as_array()?.get(i) + } +} + +pub fn parse_json(input: &str) -> Result { + let mut parser = JsonParser::new(input); + let val = parser.parse_value()?; + Ok(val) +} + +struct JsonParser<'a> { + input: &'a [u8], + pos: usize, +} + +impl<'a> JsonParser<'a> { + fn new(input: &'a str) -> Self { + Self { input: input.as_bytes(), pos: 0 } + } + + fn skip_whitespace(&mut self) { + while self.pos < self.input.len() { + match self.input[self.pos] { + b' ' | b'\t' | b'\n' | b'\r' => self.pos += 1, + _ => break, + } + } + } + + fn peek(&self) -> Option { + self.input.get(self.pos).copied() + } + + fn advance(&mut self) -> Result { + if self.pos >= self.input.len() { + return Err("Unexpected end of JSON".into()); + } + let b = self.input[self.pos]; + self.pos += 1; + Ok(b) + } + + fn expect(&mut self, ch: u8) -> Result<(), String> { + let b = self.advance()?; + if b != ch { + return Err(format!("Expected '{}', got '{}'", ch as char, b as char)); + } + Ok(()) + } + + fn parse_value(&mut self) -> Result { + self.skip_whitespace(); + match self.peek() { + Some(b'"') => self.parse_string().map(JsonValue::String), + Some(b'{') => self.parse_object(), + Some(b'[') => self.parse_array(), + Some(b't') => self.parse_literal("true", JsonValue::Bool(true)), + Some(b'f') => self.parse_literal("false", JsonValue::Bool(false)), + Some(b'n') => self.parse_literal("null", JsonValue::Null), + Some(b'-') | Some(b'0'..=b'9') => self.parse_number(), + Some(ch) => Err(format!("Unexpected character: '{}'", ch as char)), + None => Err("Unexpected end of JSON".into()), + } + } + + fn parse_string(&mut self) -> Result { + self.expect(b'"')?; + let mut s = String::new(); + loop { + let b = self.advance()?; + match b { + b'"' => return Ok(s), + b'\\' => { + let esc = self.advance()?; + match esc { + b'"' => s.push('"'), + b'\\' => s.push('\\'), + b'/' => s.push('/'), + b'b' => s.push('\u{08}'), + b'f' => s.push('\u{0C}'), + b'n' => s.push('\n'), + b'r' => s.push('\r'), + b't' => s.push('\t'), + b'u' => { + let mut hex = String::new(); + for _ in 0..4 { + hex.push(self.advance()? as char); + } + let code = u32::from_str_radix(&hex, 16) + .map_err(|_| format!("Invalid unicode escape: {}", hex))?; + if let Some(ch) = char::from_u32(code) { + s.push(ch); + } + } + _ => return Err(format!("Invalid escape: \\{}", esc as char)), + } + } + _ => s.push(b as char), + } + } + } + + fn parse_number(&mut self) -> Result { + let start = self.pos; + if self.peek() == Some(b'-') { self.pos += 1; } + while self.pos < self.input.len() && self.input[self.pos].is_ascii_digit() { + self.pos += 1; + } + if self.pos < self.input.len() && self.input[self.pos] == b'.' { + self.pos += 1; + while self.pos < self.input.len() && self.input[self.pos].is_ascii_digit() { + self.pos += 1; + } + } + if self.pos < self.input.len() && (self.input[self.pos] == b'e' || self.input[self.pos] == b'E') { + self.pos += 1; + if self.pos < self.input.len() && (self.input[self.pos] == b'+' || self.input[self.pos] == b'-') { + self.pos += 1; + } + while self.pos < self.input.len() && self.input[self.pos].is_ascii_digit() { + self.pos += 1; + } + } + let s = std::str::from_utf8(&self.input[start..self.pos]) + .map_err(|_| "Invalid UTF-8 in number")?; + let n: f64 = s.parse().map_err(|_| format!("Invalid number: {}", s))?; + Ok(JsonValue::Number(n)) + } + + fn parse_object(&mut self) -> Result { + self.expect(b'{')?; + self.skip_whitespace(); + let mut pairs = Vec::new(); + if self.peek() == Some(b'}') { + self.pos += 1; + return Ok(JsonValue::Object(pairs)); + } + loop { + self.skip_whitespace(); + let key = self.parse_string()?; + self.skip_whitespace(); + self.expect(b':')?; + let val = self.parse_value()?; + pairs.push((key, val)); + self.skip_whitespace(); + match self.peek() { + Some(b',') => { self.pos += 1; } + Some(b'}') => { self.pos += 1; return Ok(JsonValue::Object(pairs)); } + _ => return Err("Expected ',' or '}' in object".into()), + } + } + } + + fn parse_array(&mut self) -> Result { + self.expect(b'[')?; + self.skip_whitespace(); + let mut items = Vec::new(); + if self.peek() == Some(b']') { + self.pos += 1; + return Ok(JsonValue::Array(items)); + } + loop { + let val = self.parse_value()?; + items.push(val); + self.skip_whitespace(); + match self.peek() { + Some(b',') => { self.pos += 1; } + Some(b']') => { self.pos += 1; return Ok(JsonValue::Array(items)); } + _ => return Err("Expected ',' or ']' in array".into()), + } + } + } + + fn parse_literal(&mut self, expected: &str, value: JsonValue) -> Result { + for &b in expected.as_bytes() { + let actual = self.advance()?; + if actual != b { + return Err(format!("Expected '{}', got '{}'", b as char, actual as char)); + } + } + Ok(value) + } +} +``` + +Register in lib.rs: `pub mod json_parser;` + +- [ ] **Step 4: Run tests** + +Run: `cargo test --package voltex_renderer -- json_parser::tests -v` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add crates/voltex_renderer/src/json_parser.rs crates/voltex_renderer/src/lib.rs +git commit -m "feat(renderer): add self-contained JSON parser for glTF support" +``` + +--- + +### Task 2: GLB Header + Base64 Decoder + +**Files:** +- Create: `crates/voltex_renderer/src/gltf.rs` +- Modify: `crates/voltex_renderer/src/lib.rs` + +- [ ] **Step 1: Write tests for GLB header parsing and base64** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_glb_header_magic() { + // Invalid magic + let data = [0u8; 12]; + assert!(parse_gltf(&data).is_err()); + } + + #[test] + fn test_glb_header_version() { + // Valid magic but wrong version + let mut data = Vec::new(); + data.extend_from_slice(&0x46546C67u32.to_le_bytes()); // magic "glTF" + data.extend_from_slice(&1u32.to_le_bytes()); // version 1 (we need 2) + data.extend_from_slice(&12u32.to_le_bytes()); // length + assert!(parse_gltf(&data).is_err()); + } + + #[test] + fn test_base64_decode() { + let encoded = "SGVsbG8="; // "Hello" + let decoded = decode_base64(encoded).unwrap(); + assert_eq!(decoded, b"Hello"); + } + + #[test] + fn test_base64_decode_no_padding() { + let encoded = "SGVsbG8"; // "Hello" without padding + let decoded = decode_base64(encoded).unwrap(); + assert_eq!(decoded, b"Hello"); + } +} +``` + +- [ ] **Step 2: Run tests to verify failure** + +- [ ] **Step 3: Implement GLB parser skeleton and base64 decoder** + +```rust +// crates/voltex_renderer/src/gltf.rs + +use crate::json_parser::{self, JsonValue}; +use crate::vertex::MeshVertex; +use crate::obj::compute_tangents; + +pub struct GltfData { + pub meshes: Vec, +} + +pub struct GltfMesh { + pub vertices: Vec, + pub indices: Vec, + pub name: Option, + pub material: Option, +} + +pub struct GltfMaterial { + pub base_color: [f32; 4], + pub metallic: f32, + pub roughness: f32, +} + +const GLB_MAGIC: u32 = 0x46546C67; +const GLB_VERSION: u32 = 2; +const CHUNK_JSON: u32 = 0x4E4F534A; +const CHUNK_BIN: u32 = 0x004E4942; + +pub fn parse_gltf(data: &[u8]) -> Result { + if data.len() < 4 { + return Err("Data too short".into()); + } + + // Detect format: GLB (binary) or JSON + let magic = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); + if magic == GLB_MAGIC { + parse_glb(data) + } else if data[0] == b'{' { + parse_gltf_json(data) + } else { + Err("Unknown glTF format: not GLB or JSON".into()) + } +} + +fn parse_glb(data: &[u8]) -> Result { + if data.len() < 12 { + return Err("GLB header too short".into()); + } + let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]); + if version != GLB_VERSION { + return Err(format!("Unsupported GLB version: {} (expected 2)", version)); + } + let _total_len = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize; + + // Parse chunks + let mut pos = 12; + let mut json_str = String::new(); + let mut bin_data: Vec = Vec::new(); + + while pos + 8 <= data.len() { + let chunk_len = u32::from_le_bytes([data[pos], data[pos+1], data[pos+2], data[pos+3]]) as usize; + let chunk_type = u32::from_le_bytes([data[pos+4], data[pos+5], data[pos+6], data[pos+7]]); + pos += 8; + + if pos + chunk_len > data.len() { + return Err("Chunk extends past data".into()); + } + + match chunk_type { + CHUNK_JSON => { + json_str = std::str::from_utf8(&data[pos..pos + chunk_len]) + .map_err(|_| "Invalid UTF-8 in JSON chunk")? + .to_string(); + } + CHUNK_BIN => { + bin_data = data[pos..pos + chunk_len].to_vec(); + } + _ => {} // skip unknown chunks + } + pos += chunk_len; + // Chunks are 4-byte aligned + pos = (pos + 3) & !3; + } + + if json_str.is_empty() { + return Err("No JSON chunk found in GLB".into()); + } + + let json = json_parser::parse_json(&json_str)?; + let buffers = vec![bin_data]; // GLB has one implicit binary buffer + extract_meshes(&json, &buffers) +} + +fn parse_gltf_json(data: &[u8]) -> Result { + let json_str = std::str::from_utf8(data).map_err(|_| "Invalid UTF-8")?; + let json = json_parser::parse_json(json_str)?; + + // Resolve buffers (embedded base64 URIs) + let mut buffers = Vec::new(); + if let Some(bufs) = json.get("buffers").and_then(|v| v.as_array()) { + for buf in bufs { + if let Some(uri) = buf.get("uri").and_then(|v| v.as_str()) { + if let Some(b64) = uri.strip_prefix("data:application/octet-stream;base64,") { + buffers.push(decode_base64(b64)?); + } else if let Some(b64) = uri.strip_prefix("data:application/gltf-buffer;base64,") { + buffers.push(decode_base64(b64)?); + } else { + return Err(format!("External buffer URIs not supported: {}", uri)); + } + } else { + buffers.push(Vec::new()); + } + } + } + + extract_meshes(&json, &buffers) +} + +fn decode_base64(input: &str) -> Result, String> { + let table = |c: u8| -> Result { + match c { + b'A'..=b'Z' => Ok(c - b'A'), + b'a'..=b'z' => Ok(c - b'a' + 26), + b'0'..=b'9' => Ok(c - b'0' + 52), + b'+' => Ok(62), + b'/' => Ok(63), + b'=' => Ok(0), // padding + _ => Err(format!("Invalid base64 character: {}", c as char)), + } + }; + + let bytes: Vec = input.bytes().filter(|&b| b != b'\n' && b != b'\r' && b != b' ').collect(); + let mut out = Vec::with_capacity(bytes.len() * 3 / 4); + + for chunk in bytes.chunks(4) { + let b0 = table(chunk[0])?; + let b1 = if chunk.len() > 1 { table(chunk[1])? } else { 0 }; + let b2 = if chunk.len() > 2 { table(chunk[2])? } else { 0 }; + let b3 = if chunk.len() > 3 { table(chunk[3])? } else { 0 }; + + out.push((b0 << 2) | (b1 >> 4)); + if chunk.len() > 2 && chunk[2] != b'=' { + out.push((b1 << 4) | (b2 >> 2)); + } + if chunk.len() > 3 && chunk[3] != b'=' { + out.push((b2 << 6) | b3); + } + } + Ok(out) +} +``` + +Register in lib.rs: +```rust +pub mod gltf; +pub use gltf::{parse_gltf, GltfData, GltfMesh, GltfMaterial}; +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test --package voltex_renderer -- gltf::tests -v` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add crates/voltex_renderer/src/gltf.rs crates/voltex_renderer/src/lib.rs +git commit -m "feat(renderer): add GLB header parser and base64 decoder for glTF" +``` + +--- + +### Task 3: Accessor/BufferView Data Extraction + +**Files:** +- Modify: `crates/voltex_renderer/src/gltf.rs` + +- [ ] **Step 1: Write tests for accessor reading** + +```rust +#[test] +fn test_read_f32_accessor() { + // Simulate a buffer with 3 float32 values + let buffer: Vec = [1.0f32, 2.0, 3.0].iter() + .flat_map(|f| f.to_le_bytes()) + .collect(); + let data = read_floats(&buffer, 0, 3); + assert_eq!(data, vec![1.0, 2.0, 3.0]); +} + +#[test] +fn test_read_u16_indices() { + let buffer: Vec = [0u16, 1, 2].iter() + .flat_map(|i| i.to_le_bytes()) + .collect(); + let indices = read_indices_u16(&buffer, 0, 3); + assert_eq!(indices, vec![0u32, 1, 2]); +} +``` + +- [ ] **Step 2: Run tests to verify failure** + +- [ ] **Step 3: Implement accessor reading and mesh extraction** + +```rust +fn extract_meshes(json: &JsonValue, buffers: &[Vec]) -> Result { + let accessors = json.get("accessors").and_then(|v| v.as_array()).unwrap_or(&[]); + let buffer_views = json.get("bufferViews").and_then(|v| v.as_array()).unwrap_or(&[]); + let materials_json = json.get("materials").and_then(|v| v.as_array()); + + let mut meshes = Vec::new(); + + let mesh_list = json.get("meshes").and_then(|v| v.as_array()) + .ok_or("No meshes in glTF")?; + + for mesh_val in mesh_list { + let name = mesh_val.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()); + let primitives = mesh_val.get("primitives").and_then(|v| v.as_array()) + .ok_or("Mesh has no primitives")?; + + for prim in primitives { + let attrs = prim.get("attributes").and_then(|v| v.as_object()) + .ok_or("Primitive has no attributes")?; + + // Read position data (required) + let pos_idx = attrs.iter().find(|(k, _)| k == "POSITION") + .and_then(|(_, v)| v.as_u32()) + .ok_or("Missing POSITION attribute")? as usize; + let positions = read_accessor_vec3(accessors, buffer_views, buffers, pos_idx)?; + + // Read normals (optional) + let normals = if let Some(idx) = attrs.iter().find(|(k, _)| k == "NORMAL").and_then(|(_, v)| v.as_u32()) { + read_accessor_vec3(accessors, buffer_views, buffers, idx as usize)? + } else { + vec![[0.0, 1.0, 0.0]; positions.len()] + }; + + // Read UVs (optional) + let uvs = if let Some(idx) = attrs.iter().find(|(k, _)| k == "TEXCOORD_0").and_then(|(_, v)| v.as_u32()) { + read_accessor_vec2(accessors, buffer_views, buffers, idx as usize)? + } else { + vec![[0.0, 0.0]; positions.len()] + }; + + // Read tangents (optional) + let tangents = if let Some(idx) = attrs.iter().find(|(k, _)| k == "TANGENT").and_then(|(_, v)| v.as_u32()) { + Some(read_accessor_vec4(accessors, buffer_views, buffers, idx as usize)?) + } else { + None + }; + + // Read indices + let indices = if let Some(idx) = prim.get("indices").and_then(|v| v.as_u32()) { + read_accessor_indices(accessors, buffer_views, buffers, idx as usize)? + } else { + // No indices — generate sequential + (0..positions.len() as u32).collect() + }; + + // Assemble vertices + let mut vertices: Vec = Vec::with_capacity(positions.len()); + for i in 0..positions.len() { + vertices.push(MeshVertex { + position: positions[i], + normal: normals[i], + uv: uvs[i], + tangent: tangents.as_ref().map_or([0.0; 4], |t| t[i]), + }); + } + + // Compute tangents if not provided + if tangents.is_none() { + compute_tangents(&mut vertices, &indices); + } + + // Read material + let material = prim.get("material") + .and_then(|v| v.as_u32()) + .and_then(|idx| materials_json?.get(idx as usize)) + .and_then(|mat| extract_material(mat)); + + meshes.push(GltfMesh { vertices, indices, name: name.clone(), material }); + } + } + + Ok(GltfData { meshes }) +} + +fn get_buffer_data<'a>( + accessor: &JsonValue, + buffer_views: &[JsonValue], + buffers: &'a [Vec], +) -> Result<(&'a [u8], usize), String> { + let bv_idx = accessor.get("bufferView").and_then(|v| v.as_u32()) + .ok_or("Accessor missing bufferView")? as usize; + let bv = buffer_views.get(bv_idx).ok_or("BufferView index out of range")?; + let buf_idx = bv.get("buffer").and_then(|v| v.as_u32()).unwrap_or(0) as usize; + let bv_offset = bv.get("byteOffset").and_then(|v| v.as_u32()).unwrap_or(0) as usize; + let acc_offset = accessor.get("byteOffset").and_then(|v| v.as_u32()).unwrap_or(0) as usize; + let buffer = buffers.get(buf_idx).ok_or("Buffer index out of range")?; + let offset = bv_offset + acc_offset; + Ok((buffer, offset)) +} + +fn read_accessor_vec3( + accessors: &[JsonValue], buffer_views: &[JsonValue], buffers: &[Vec], idx: usize, +) -> Result, String> { + let acc = accessors.get(idx).ok_or("Accessor index out of range")?; + let count = acc.get("count").and_then(|v| v.as_u32()).ok_or("Missing count")? as usize; + let (buffer, offset) = get_buffer_data(acc, buffer_views, buffers)?; + let mut result = Vec::with_capacity(count); + for i in 0..count { + let o = offset + i * 12; + if o + 12 > buffer.len() { return Err("Buffer overflow reading vec3".into()); } + let x = f32::from_le_bytes([buffer[o], buffer[o+1], buffer[o+2], buffer[o+3]]); + let y = f32::from_le_bytes([buffer[o+4], buffer[o+5], buffer[o+6], buffer[o+7]]); + let z = f32::from_le_bytes([buffer[o+8], buffer[o+9], buffer[o+10], buffer[o+11]]); + result.push([x, y, z]); + } + Ok(result) +} + +fn read_accessor_vec2( + accessors: &[JsonValue], buffer_views: &[JsonValue], buffers: &[Vec], idx: usize, +) -> Result, String> { + let acc = accessors.get(idx).ok_or("Accessor index out of range")?; + let count = acc.get("count").and_then(|v| v.as_u32()).ok_or("Missing count")? as usize; + let (buffer, offset) = get_buffer_data(acc, buffer_views, buffers)?; + let mut result = Vec::with_capacity(count); + for i in 0..count { + let o = offset + i * 8; + if o + 8 > buffer.len() { return Err("Buffer overflow reading vec2".into()); } + let x = f32::from_le_bytes([buffer[o], buffer[o+1], buffer[o+2], buffer[o+3]]); + let y = f32::from_le_bytes([buffer[o+4], buffer[o+5], buffer[o+6], buffer[o+7]]); + result.push([x, y]); + } + Ok(result) +} + +fn read_accessor_vec4( + accessors: &[JsonValue], buffer_views: &[JsonValue], buffers: &[Vec], idx: usize, +) -> Result, String> { + let acc = accessors.get(idx).ok_or("Accessor index out of range")?; + let count = acc.get("count").and_then(|v| v.as_u32()).ok_or("Missing count")? as usize; + let (buffer, offset) = get_buffer_data(acc, buffer_views, buffers)?; + let mut result = Vec::with_capacity(count); + for i in 0..count { + let o = offset + i * 16; + if o + 16 > buffer.len() { return Err("Buffer overflow reading vec4".into()); } + let x = f32::from_le_bytes([buffer[o], buffer[o+1], buffer[o+2], buffer[o+3]]); + let y = f32::from_le_bytes([buffer[o+4], buffer[o+5], buffer[o+6], buffer[o+7]]); + let z = f32::from_le_bytes([buffer[o+8], buffer[o+9], buffer[o+10], buffer[o+11]]); + let w = f32::from_le_bytes([buffer[o+12], buffer[o+13], buffer[o+14], buffer[o+15]]); + result.push([x, y, z, w]); + } + Ok(result) +} + +fn read_accessor_indices( + accessors: &[JsonValue], buffer_views: &[JsonValue], buffers: &[Vec], idx: usize, +) -> Result, String> { + let acc = accessors.get(idx).ok_or("Accessor index out of range")?; + let count = acc.get("count").and_then(|v| v.as_u32()).ok_or("Missing count")? as usize; + let comp_type = acc.get("componentType").and_then(|v| v.as_u32()).ok_or("Missing componentType")?; + let (buffer, offset) = get_buffer_data(acc, buffer_views, buffers)?; + + let mut result = Vec::with_capacity(count); + match comp_type { + 5121 => { // UNSIGNED_BYTE + for i in 0..count { + result.push(buffer[offset + i] as u32); + } + } + 5123 => { // UNSIGNED_SHORT + for i in 0..count { + let o = offset + i * 2; + result.push(u16::from_le_bytes([buffer[o], buffer[o+1]]) as u32); + } + } + 5125 => { // UNSIGNED_INT + for i in 0..count { + let o = offset + i * 4; + result.push(u32::from_le_bytes([buffer[o], buffer[o+1], buffer[o+2], buffer[o+3]])); + } + } + _ => return Err(format!("Unsupported index component type: {}", comp_type)), + } + Ok(result) +} + +fn extract_material(mat: &JsonValue) -> Option { + let pbr = mat.get("pbrMetallicRoughness")?; + let base_color = if let Some(arr) = pbr.get("baseColorFactor").and_then(|v| v.as_array()) { + [ + arr.get(0).and_then(|v| v.as_f64()).unwrap_or(1.0) as f32, + arr.get(1).and_then(|v| v.as_f64()).unwrap_or(1.0) as f32, + arr.get(2).and_then(|v| v.as_f64()).unwrap_or(1.0) as f32, + arr.get(3).and_then(|v| v.as_f64()).unwrap_or(1.0) as f32, + ] + } else { + [1.0, 1.0, 1.0, 1.0] + }; + let metallic = pbr.get("metallicFactor").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32; + let roughness = pbr.get("roughnessFactor").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32; + Some(GltfMaterial { base_color, metallic, roughness }) +} + +// Helper functions for tests +fn read_floats(buffer: &[u8], offset: usize, count: usize) -> Vec { + (0..count).map(|i| { + let o = offset + i * 4; + f32::from_le_bytes([buffer[o], buffer[o+1], buffer[o+2], buffer[o+3]]) + }).collect() +} + +fn read_indices_u16(buffer: &[u8], offset: usize, count: usize) -> Vec { + (0..count).map(|i| { + let o = offset + i * 2; + u16::from_le_bytes([buffer[o], buffer[o+1]]) as u32 + }).collect() +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test --package voltex_renderer -- gltf::tests -v` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add crates/voltex_renderer/src/gltf.rs +git commit -m "feat(renderer): add glTF accessor/bufferView extraction and mesh assembly" +``` + +--- + +### Task 4: GLB Integration Test with Synthetic Triangle + +**Files:** +- Modify: `crates/voltex_renderer/src/gltf.rs` + +- [ ] **Step 1: Write integration test** + +```rust +#[test] +fn test_parse_minimal_glb() { + let glb = build_minimal_glb_triangle(); + let data = parse_gltf(&glb).unwrap(); + assert_eq!(data.meshes.len(), 1); + let mesh = &data.meshes[0]; + assert_eq!(mesh.vertices.len(), 3); + assert_eq!(mesh.indices.len(), 3); + // Verify positions + assert_eq!(mesh.vertices[0].position, [0.0, 0.0, 0.0]); + assert_eq!(mesh.vertices[1].position, [1.0, 0.0, 0.0]); + assert_eq!(mesh.vertices[2].position, [0.0, 1.0, 0.0]); +} + +/// Build a minimal GLB with one triangle. +fn build_minimal_glb_triangle() -> Vec { + // Binary buffer: 3 positions (vec3) + 3 indices (u16) + let mut bin = Vec::new(); + // Positions: 3 * vec3 = 36 bytes + for &v in &[0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0] { + bin.extend_from_slice(&v.to_le_bytes()); + } + // Indices: 3 * u16 = 6 bytes + 2 padding = 8 bytes + for &i in &[0u16, 1, 2] { + bin.extend_from_slice(&i.to_le_bytes()); + } + bin.extend_from_slice(&[0, 0]); // padding to 4-byte alignment + + let json_str = format!(r#"{{ + "asset": {{"version": "2.0"}}, + "buffers": [{{"byteLength": {}}}], + "bufferViews": [ + {{"buffer": 0, "byteOffset": 0, "byteLength": 36}}, + {{"buffer": 0, "byteOffset": 36, "byteLength": 6}} + ], + "accessors": [ + {{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3", + "max": [1.0, 1.0, 0.0], "min": [0.0, 0.0, 0.0]}}, + {{"bufferView": 1, "componentType": 5123, "count": 3, "type": "SCALAR"}} + ], + "meshes": [{{ + "name": "Triangle", + "primitives": [{{ + "attributes": {{"POSITION": 0}}, + "indices": 1 + }}] + }}] + }}"#, bin.len()); + + let json_bytes = json_str.as_bytes(); + // Pad JSON to 4-byte alignment + let json_padded_len = (json_bytes.len() + 3) & !3; + let mut json_padded = json_bytes.to_vec(); + while json_padded.len() < json_padded_len { + json_padded.push(b' '); + } + + let total_len = 12 + 8 + json_padded.len() + 8 + bin.len(); + let mut glb = Vec::with_capacity(total_len); + + // Header + glb.extend_from_slice(&0x46546C67u32.to_le_bytes()); // magic + glb.extend_from_slice(&2u32.to_le_bytes()); // version + glb.extend_from_slice(&(total_len as u32).to_le_bytes()); + + // JSON chunk + glb.extend_from_slice(&(json_padded.len() as u32).to_le_bytes()); + glb.extend_from_slice(&0x4E4F534Au32.to_le_bytes()); // "JSON" + glb.extend_from_slice(&json_padded); + + // BIN chunk + glb.extend_from_slice(&(bin.len() as u32).to_le_bytes()); + glb.extend_from_slice(&0x004E4942u32.to_le_bytes()); // "BIN\0" + glb.extend_from_slice(&bin); + + glb +} +``` + +- [ ] **Step 2: Run test** + +Run: `cargo test --package voltex_renderer -- gltf::tests::test_parse_minimal_glb -v` +Expected: PASS + +- [ ] **Step 3: Add material test** + +```rust +#[test] +fn test_parse_glb_with_material() { + // Same triangle but with a material + let glb = build_glb_with_material(); + let data = parse_gltf(&glb).unwrap(); + let mesh = &data.meshes[0]; + let mat = mesh.material.as_ref().unwrap(); + assert!((mat.base_color[0] - 1.0).abs() < 0.01); + assert!((mat.metallic - 0.5).abs() < 0.01); + assert!((mat.roughness - 0.8).abs() < 0.01); +} +``` + +- [ ] **Step 4: Run all glTF tests** + +Run: `cargo test --package voltex_renderer -- gltf::tests -v` +Expected: All PASS + +- [ ] **Step 5: Run full workspace build** + +Run: `cargo build --workspace` +Expected: BUILD SUCCESS + +- [ ] **Step 6: Commit** + +```bash +git add crates/voltex_renderer/src/gltf.rs +git commit -m "feat(renderer): complete glTF/GLB parser with mesh and material extraction" +``` diff --git a/docs/superpowers/plans/2026-03-25-phase2-jpg-decoder.md b/docs/superpowers/plans/2026-03-25-phase2-jpg-decoder.md new file mode 100644 index 0000000..1541bc1 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-phase2-jpg-decoder.md @@ -0,0 +1,1012 @@ +# JPG Decoder Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Self-contained Baseline JPEG decoder returning RGBA pixel data, matching existing `parse_png` API pattern. + +**Architecture:** JFIF marker-based parser → Huffman bit-stream decoder → inverse quantization → 8x8 IDCT → YCbCr→RGB → MCU assembly. Single file `jpg.rs` with helper structs, registered in `lib.rs`. + +**Tech Stack:** Pure Rust, no external dependencies. `Result<(Vec, u32, u32), String>` error pattern. + +--- + +### Task 1: JFIF Marker Parser + Skeleton + +**Files:** +- Create: `crates/voltex_renderer/src/jpg.rs` +- Modify: `crates/voltex_renderer/src/lib.rs` + +- [ ] **Step 1: Write failing test for marker parsing** + +```rust +// In crates/voltex_renderer/src/jpg.rs +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_invalid_signature() { + let data = [0u8; 10]; + assert!(parse_jpg(&data).is_err()); + } + + #[test] + fn test_empty_data() { + assert!(parse_jpg(&[]).is_err()); + } + + #[test] + fn test_soi_only() { + let data = [0xFF, 0xD8]; // SOI only, no SOF/SOS + assert!(parse_jpg(&data).is_err()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --package voltex_renderer -- jpg::tests -v` +Expected: FAIL — module `jpg` not found + +- [ ] **Step 3: Implement marker parser skeleton** + +```rust +// crates/voltex_renderer/src/jpg.rs + +/// Baseline JPEG decoder. Supports SOF0 (sequential DCT, Huffman). +/// Returns RGBA pixel data like parse_png. + +pub fn parse_jpg(data: &[u8]) -> Result<(Vec, u32, u32), String> { + if data.len() < 2 || data[0] != 0xFF || data[1] != 0xD8 { + return Err("Invalid JPEG: missing SOI marker".into()); + } + + let mut pos = 2; + let mut width: u16 = 0; + let mut height: u16 = 0; + let mut num_components: u8 = 0; + let mut components: Vec = Vec::new(); + let mut qt_tables: [[u16; 64]; 4] = [[0; 64]; 4]; + let mut dc_tables: [Option; 4] = [None, None, None, None]; + let mut ac_tables: [Option; 4] = [None, None, None, None]; + let mut found_sof = false; + + while pos + 1 < data.len() { + if data[pos] != 0xFF { + return Err(format!("Expected marker at position {}", pos)); + } + // Skip padding 0xFF bytes + while pos + 1 < data.len() && data[pos + 1] == 0xFF { + pos += 1; + } + if pos + 1 >= data.len() { + return Err("Unexpected end of data".into()); + } + let marker = data[pos + 1]; + pos += 2; + + match marker { + 0xD8 => {} // SOI (already handled) + 0xD9 => break, // EOI + 0xDA => { + // SOS — Start of Scan + if !found_sof { + return Err("SOS before SOF".into()); + } + let (rgb, scan_end) = decode_scan( + data, pos, width, height, num_components, + &components, &qt_tables, &dc_tables, &ac_tables, + )?; + pos = scan_end; + // Convert RGB to RGBA + let w = width as u32; + let h = height as u32; + let mut rgba = Vec::with_capacity((w * h * 4) as usize); + for pixel in rgb.chunks_exact(3) { + rgba.push(pixel[0]); + rgba.push(pixel[1]); + rgba.push(pixel[2]); + rgba.push(255); + } + return Ok((rgba, w, h)); + } + 0xC0 => { + // SOF0 — Baseline DCT + let (sof, len) = parse_sof(data, pos)?; + width = sof.width; + height = sof.height; + num_components = sof.num_components; + components = sof.components; + found_sof = true; + pos += len; + } + 0xC4 => { + // DHT — Define Huffman Table + let len = parse_dht(data, pos, &mut dc_tables, &mut ac_tables)?; + pos += len; + } + 0xDB => { + // DQT — Define Quantization Table + let len = parse_dqt(data, pos, &mut qt_tables)?; + pos += len; + } + 0xDD => { + // DRI — Define Restart Interval + if pos + 4 > data.len() { return Err("DRI too short".into()); } + // Skip restart interval (2 byte length + 2 byte interval) + let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + pos += seg_len; + } + 0xD0..=0xD7 => { + // RST markers — handled inside scan decoder + } + 0xE0..=0xEF | 0xFE => { + // APP0-APP15, COM — skip + if pos + 2 > data.len() { return Err("Segment too short".into()); } + let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + pos += seg_len; + } + _ => { + // Unknown marker with length — skip + if pos + 2 > data.len() { return Err(format!("Unknown marker 0x{:02X}", marker)); } + let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + pos += seg_len; + } + } + } + + Err("No image data found (missing SOS)".into()) +} + +#[derive(Clone)] +struct JpegComponent { + id: u8, + h_sample: u8, // horizontal sampling factor + v_sample: u8, // vertical sampling factor + qt_id: u8, // quantization table ID + dc_table: u8, // DC Huffman table ID + ac_table: u8, // AC Huffman table ID +} + +struct SofData { + width: u16, + height: u16, + num_components: u8, + components: Vec, +} + +struct HuffTable { + // lookup[code_length - 1] = Vec of (code, symbol) + symbols: Vec, // symbols in code-length order + offsets: [u16; 17], // offset into symbols for each code length + maxcode: [i32; 17], // max code value for each length (-1 if unused) + mincode: [u16; 17], // min code value for each length +} +``` + +Register module in lib.rs: +```rust +pub mod jpg; +pub use jpg::parse_jpg; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --package voltex_renderer -- jpg::tests -v` +Expected: 3 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add crates/voltex_renderer/src/jpg.rs crates/voltex_renderer/src/lib.rs +git commit -m "feat(renderer): add JPEG decoder skeleton with marker parser" +``` + +--- + +### Task 2: Quantization Table (DQT) + Huffman Table (DHT) Parsers + +**Files:** +- Modify: `crates/voltex_renderer/src/jpg.rs` + +- [ ] **Step 1: Write tests for DQT and DHT parsing** + +```rust +#[test] +fn test_parse_dqt_8bit() { + // DQT segment: length=67, precision=0(8-bit), table_id=0, 64 bytes of quantization values + let mut seg = Vec::new(); + seg.extend_from_slice(&67u16.to_be_bytes()); // segment length + seg.push(0x00); // precision=0 (8-bit), table_id=0 + for i in 0..64u8 { + seg.push(i + 1); // quant values 1..64 + } + let mut qt_tables = [[0u16; 64]; 4]; + let len = parse_dqt(&seg, 0, &mut qt_tables).unwrap(); + assert_eq!(len, 67); + assert_eq!(qt_tables[0][0], 1); + assert_eq!(qt_tables[0][63], 64); +} + +#[test] +fn test_parse_dht() { + // DHT segment: DC table, class=0, id=0 + // Simple table: 1 symbol of length 1 (symbol = 0x05) + let mut seg = Vec::new(); + let mut body = Vec::new(); + body.push(0x00); // class=0 (DC), id=0 + // counts: 1 symbol at length 1, 0 for lengths 2-16 + body.push(1); + for _ in 1..16 { body.push(0); } + body.push(0x05); // the symbol + + seg.extend_from_slice(&((body.len() + 2) as u16).to_be_bytes()); + seg.extend_from_slice(&body); + + let mut dc_tables: [Option; 4] = [None, None, None, None]; + let mut ac_tables: [Option; 4] = [None, None, None, None]; + let len = parse_dht(&seg, 0, &mut dc_tables, &mut ac_tables).unwrap(); + assert!(dc_tables[0].is_some()); + let table = dc_tables[0].as_ref().unwrap(); + assert_eq!(table.symbols[0], 0x05); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --package voltex_renderer -- jpg::tests::test_parse_dqt -v` +Expected: FAIL — `parse_dqt` and `parse_dht` not found / not implemented + +- [ ] **Step 3: Implement DQT and DHT parsers** + +```rust +fn parse_dqt(data: &[u8], pos: usize, qt_tables: &mut [[u16; 64]; 4]) -> Result { + if pos + 2 > data.len() { return Err("DQT too short".into()); } + let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + if pos + seg_len > data.len() { return Err("DQT segment extends past data".into()); } + + let mut off = pos + 2; + let seg_end = pos + seg_len; + while off < seg_end { + let pq_tq = data[off]; + let precision = pq_tq >> 4; // 0=8-bit, 1=16-bit + let table_id = (pq_tq & 0x0F) as usize; + off += 1; + if table_id >= 4 { return Err(format!("DQT table id {} out of range", table_id)); } + + if precision == 0 { + // 8-bit values + if off + 64 > seg_end { return Err("DQT 8-bit data too short".into()); } + for i in 0..64 { + qt_tables[table_id][i] = data[off + i] as u16; + } + off += 64; + } else { + // 16-bit values + if off + 128 > seg_end { return Err("DQT 16-bit data too short".into()); } + for i in 0..64 { + qt_tables[table_id][i] = u16::from_be_bytes([data[off + i * 2], data[off + i * 2 + 1]]); + } + off += 128; + } + } + Ok(seg_len) +} + +fn parse_sof(data: &[u8], pos: usize) -> Result<(SofData, usize), String> { + if pos + 2 > data.len() { return Err("SOF too short".into()); } + let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + if pos + seg_len > data.len() { return Err("SOF segment extends past data".into()); } + + let precision = data[pos + 2]; + if precision != 8 { return Err(format!("Unsupported sample precision: {}", precision)); } + + let height = u16::from_be_bytes([data[pos + 3], data[pos + 4]]); + let width = u16::from_be_bytes([data[pos + 5], data[pos + 6]]); + let num_comp = data[pos + 7]; + + let mut components = Vec::new(); + let mut off = pos + 8; + for _ in 0..num_comp { + if off + 3 > pos + seg_len { return Err("SOF component data too short".into()); } + let id = data[off]; + let sampling = data[off + 1]; + let h_sample = sampling >> 4; + let v_sample = sampling & 0x0F; + let qt_id = data[off + 2]; + components.push(JpegComponent { + id, h_sample, v_sample, qt_id, + dc_table: 0, ac_table: 0, + }); + off += 3; + } + + Ok((SofData { width, height, num_components: num_comp, components }, seg_len)) +} + +fn parse_dht( + data: &[u8], pos: usize, + dc_tables: &mut [Option; 4], + ac_tables: &mut [Option; 4], +) -> Result { + if pos + 2 > data.len() { return Err("DHT too short".into()); } + let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + if pos + seg_len > data.len() { return Err("DHT segment extends past data".into()); } + + let mut off = pos + 2; + let seg_end = pos + seg_len; + while off < seg_end { + let tc_th = data[off]; + let table_class = tc_th >> 4; // 0=DC, 1=AC + let table_id = (tc_th & 0x0F) as usize; + off += 1; + if table_id >= 4 { return Err(format!("DHT table id {} out of range", table_id)); } + + // Read 16 counts (number of symbols for each code length 1..16) + if off + 16 > seg_end { return Err("DHT counts too short".into()); } + let mut counts = [0u8; 16]; + counts.copy_from_slice(&data[off..off + 16]); + off += 16; + + let total_symbols: usize = counts.iter().map(|&c| c as usize).sum(); + if off + total_symbols > seg_end { return Err("DHT symbols too short".into()); } + + let symbols: Vec = data[off..off + total_symbols].to_vec(); + off += total_symbols; + + // Build lookup tables + let mut offsets = [0u16; 17]; + let mut maxcode = [-1i32; 17]; + let mut mincode = [0u16; 17]; + let mut code: u16 = 0; + let mut sym_offset: u16 = 0; + + for i in 0..16 { + offsets[i] = sym_offset; + if counts[i] > 0 { + mincode[i] = code; + maxcode[i] = (code + counts[i] as u16 - 1) as i32; + sym_offset += counts[i] as u16; + } + code = (code + counts[i] as u16) << 1; + } + offsets[16] = sym_offset; + + let table = HuffTable { symbols, offsets, maxcode, mincode }; + if table_class == 0 { + dc_tables[table_id] = Some(table); + } else { + ac_tables[table_id] = Some(table); + } + } + Ok(seg_len) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --package voltex_renderer -- jpg::tests -v` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add crates/voltex_renderer/src/jpg.rs +git commit -m "feat(renderer): add JPEG DQT/DHT/SOF marker parsers" +``` + +--- + +### Task 3: Huffman Bit-Stream Reader + +**Files:** +- Modify: `crates/voltex_renderer/src/jpg.rs` + +- [ ] **Step 1: Write tests for bit reader** + +```rust +#[test] +fn test_bit_reader_basic() { + // 0xA5 = 10100101 + let data = [0xA5]; + let mut reader = BitReader::new(&data, 0); + assert_eq!(reader.read_bits(1).unwrap(), 1); // 1 + assert_eq!(reader.read_bits(1).unwrap(), 0); // 0 + assert_eq!(reader.read_bits(3).unwrap(), 0b100); // 100 + assert_eq!(reader.read_bits(3).unwrap(), 0b101); // 101 +} + +#[test] +fn test_bit_reader_byte_stuffing() { + // JPEG byte stuffing: 0xFF 0x00 → single 0xFF byte + let data = [0xFF, 0x00, 0x80]; + let mut reader = BitReader::new(&data, 0); + let val = reader.read_bits(8).unwrap(); + assert_eq!(val, 0xFF); + let val2 = reader.read_bits(1).unwrap(); + assert_eq!(val2, 1); // 0x80 = 10000000 +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +- [ ] **Step 3: Implement BitReader** + +```rust +struct BitReader<'a> { + data: &'a [u8], + pos: usize, + bit_pos: u8, // 0-7, MSB first + current: u8, +} + +impl<'a> BitReader<'a> { + fn new(data: &'a [u8], start: usize) -> Self { + Self { data, pos: start, bit_pos: 0, current: 0 } + } + + fn read_byte(&mut self) -> Result { + if self.pos >= self.data.len() { + return Err("Unexpected end of scan data".into()); + } + let byte = self.data[self.pos]; + self.pos += 1; + // Handle byte stuffing: 0xFF 0x00 → 0xFF + if byte == 0xFF { + if self.pos >= self.data.len() { + return Err("Unexpected end after 0xFF".into()); + } + let next = self.data[self.pos]; + if next == 0x00 { + self.pos += 1; // skip stuffed 0x00 + } else if next >= 0xD0 && next <= 0xD7 { + // RST marker — skip and read next byte + self.pos += 1; + return self.read_byte(); + } else { + // Marker found — end of scan + return Err("Marker found in scan data".into()); + } + } + Ok(byte) + } + + fn ensure_bits(&mut self) -> Result<(), String> { + if self.bit_pos == 0 { + self.current = self.read_byte()?; + self.bit_pos = 8; + } + Ok(()) + } + + fn read_bit(&mut self) -> Result { + self.ensure_bits()?; + self.bit_pos -= 1; + Ok((self.current >> self.bit_pos) & 1) + } + + fn read_bits(&mut self, count: u8) -> Result { + let mut val: u16 = 0; + for _ in 0..count { + val = (val << 1) | self.read_bit()? as u16; + } + Ok(val) + } + + /// Decode one Huffman symbol using the given table + fn decode_huffman(&mut self, table: &HuffTable) -> Result { + let mut code: u16 = 0; + for len in 0..16 { + code = (code << 1) | self.read_bit()? as u16; + if table.maxcode[len] >= 0 && code as i32 <= table.maxcode[len] { + let idx = table.offsets[len] as usize + (code - table.mincode[len]) as usize; + return Ok(table.symbols[idx]); + } + } + Err("Invalid Huffman code".into()) + } + + fn scan_end_pos(&self) -> usize { + self.pos + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test --package voltex_renderer -- jpg::tests -v` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add crates/voltex_renderer/src/jpg.rs +git commit -m "feat(renderer): add JPEG Huffman bit-stream reader with byte stuffing" +``` + +--- + +### Task 4: 8x8 IDCT + +**Files:** +- Modify: `crates/voltex_renderer/src/jpg.rs` + +- [ ] **Step 1: Write IDCT test** + +```rust +#[test] +fn test_idct_dc_only() { + // DC-only block: only coefficient [0] is set + let mut block = [0i32; 64]; + block[0] = 800; // after dequantization + let result = idct(&block); + // All 64 values should be the same: 800/8 = 100 + let expected = 100; + for &v in &result { + assert!((v - expected).abs() <= 1, "DC-only IDCT: expected ~{}, got {}", expected, v); + } +} + +#[test] +fn test_idct_known_values() { + // A simple known block + let mut block = [0i32; 64]; + block[0] = 640; + block[1] = 100; + let result = idct(&block); + // DC component should dominate: average ~80 + let avg: i32 = result.iter().sum::() / 64; + assert!((avg - 80).abs() <= 2); +} +``` + +- [ ] **Step 2: Run to verify failure** + +- [ ] **Step 3: Implement IDCT** + +```rust +/// Zig-zag order for 8x8 block +const ZIGZAG: [usize; 64] = [ + 0, 1, 8, 16, 9, 2, 3, 10, + 17, 24, 32, 25, 18, 11, 4, 5, + 12, 19, 26, 33, 40, 48, 41, 34, + 27, 20, 13, 6, 7, 14, 21, 28, + 35, 42, 49, 56, 57, 50, 43, 36, + 29, 22, 15, 23, 30, 37, 44, 51, + 58, 59, 52, 45, 38, 31, 39, 46, + 53, 60, 61, 54, 47, 55, 62, 63, +]; + +fn idct(coeffs: &[i32; 64]) -> [i32; 64] { + // AAN-based float IDCT + let mut workspace = [0.0f64; 64]; + + // Arrange from zigzag to row-major + let mut block = [0.0f64; 64]; + for i in 0..64 { + block[ZIGZAG[i]] = coeffs[i] as f64; + } + + // 1D IDCT on rows + for row in 0..8 { + let off = row * 8; + idct_1d(&mut block, off); + } + + // Transpose + for r in 0..8 { + for c in 0..8 { + workspace[c * 8 + r] = block[r * 8 + c]; + } + } + + // 1D IDCT on columns (now rows after transpose) + for row in 0..8 { + let off = row * 8; + idct_1d(&mut workspace, off); + } + + // Transpose back and round + let mut result = [0i32; 64]; + for r in 0..8 { + for c in 0..8 { + result[r * 8 + c] = (workspace[c * 8 + r] / 8.0).round() as i32; + } + } + result +} + +fn idct_1d(data: &mut [f64], off: usize) { + use std::f64::consts::PI; + let mut tmp = [0.0f64; 8]; + for x in 0..8 { + let mut sum = 0.0; + for u in 0..8 { + let cu = if u == 0 { 1.0 / 2.0f64.sqrt() } else { 1.0 }; + sum += cu * data[off + u] * ((2.0 * x as f64 + 1.0) * u as f64 * PI / 16.0).cos(); + } + tmp[x] = sum / 2.0; + } + data[off..off + 8].copy_from_slice(&tmp); +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test --package voltex_renderer -- jpg::tests::test_idct -v` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add crates/voltex_renderer/src/jpg.rs +git commit -m "feat(renderer): add JPEG 8x8 IDCT with zig-zag reordering" +``` + +--- + +### Task 5: Scan Decoder (MCU Decoding + YCbCr→RGB) + +**Files:** +- Modify: `crates/voltex_renderer/src/jpg.rs` + +- [ ] **Step 1: Write integration test with synthetic minimal JPEG** + +```rust +#[test] +fn test_decode_synthetic_1x1_gray_like() { + // Test the MCU decode + color conversion path + // We'll create a minimal 8x8 JPEG by hand + // This is complex — test via the full parse_jpg path with a known-good JPEG + let jpg_data = build_minimal_jpeg_8x8(); + let result = parse_jpg(&jpg_data); + assert!(result.is_ok(), "Failed to decode minimal JPEG: {:?}", result.err()); + let (rgba, w, h) = result.unwrap(); + assert_eq!(w, 8); + assert_eq!(h, 8); + assert_eq!(rgba.len(), 8 * 8 * 4); +} +``` + +- [ ] **Step 2: Run test to verify failure** + +- [ ] **Step 3: Implement scan decoder** + +Implement `decode_scan` function: +- Parse SOS header (component → Huffman table mapping) +- For each MCU: decode DC (differential) + AC (run-length) for each component +- Dequantize coefficients using DQT table +- IDCT each 8x8 block +- Handle chroma subsampling (assemble MCU from Y/Cb/Cr blocks) +- YCbCr → RGB conversion: `R = Y + 1.402*(Cr-128)`, `G = Y - 0.344136*(Cb-128) - 0.714136*(Cr-128)`, `B = Y + 1.772*(Cb-128)` +- Clamp to 0..255 +- Handle restart markers (DRI/RST): reset DC predictors + +```rust +fn decode_scan( + data: &[u8], pos: usize, + width: u16, height: u16, num_components: u8, + components: &[JpegComponent], + qt_tables: &[[u16; 64]; 4], + dc_tables: &[Option; 4], + ac_tables: &[Option; 4], +) -> Result<(Vec, usize), String> { + // Parse SOS header + if pos + 2 > data.len() { return Err("SOS too short".into()); } + let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + let ns = data[pos + 2] as usize; + let mut scan_components = components.to_vec(); + let mut off = pos + 3; + for i in 0..ns { + let _cs = data[off]; // component selector + let td_ta = data[off + 1]; + scan_components[i].dc_table = td_ta >> 4; + scan_components[i].ac_table = td_ta & 0x0F; + off += 2; + } + // Skip spectral selection and successive approximation bytes + let scan_data_start = pos + seg_len; + + let mut reader = BitReader::new(data, scan_data_start); + + // Calculate MCU dimensions + let max_h = scan_components.iter().map(|c| c.h_sample).max().unwrap_or(1); + let max_v = scan_components.iter().map(|c| c.v_sample).max().unwrap_or(1); + let mcu_width = (max_h as u16) * 8; + let mcu_height = (max_v as u16) * 8; + let mcus_x = (width + mcu_width - 1) / mcu_width; + let mcus_y = (height + mcu_height - 1) / mcu_height; + + let mut dc_pred = vec![0i32; num_components as usize]; + let mut rgb = vec![0u8; (width as usize) * (height as usize) * 3]; + + for mcu_row in 0..mcus_y { + for mcu_col in 0..mcus_x { + // Decode blocks for each component in this MCU + let mut mcu_blocks: Vec> = Vec::new(); + + for (ci, comp) in scan_components.iter().enumerate().take(num_components as usize) { + let blocks_h = comp.h_sample as usize; + let blocks_v = comp.v_sample as usize; + let mut blocks = Vec::with_capacity(blocks_h * blocks_v); + + for _ in 0..(blocks_h * blocks_v) { + let block = decode_block( + &mut reader, + dc_tables[comp.dc_table as usize].as_ref() + .ok_or("Missing DC Huffman table")?, + ac_tables[comp.ac_table as usize].as_ref() + .ok_or("Missing AC Huffman table")?, + &mut dc_pred[ci], + &qt_tables[comp.qt_id as usize], + )?; + blocks.push(block); + } + mcu_blocks.push(blocks); + } + + // Assemble MCU pixels to RGB + assemble_mcu( + &mcu_blocks, &scan_components, num_components, + max_h, max_v, mcu_col as usize, mcu_row as usize, + width as usize, height as usize, &mut rgb, + ); + } + } + + Ok((rgb, reader.scan_end_pos())) +} + +fn decode_block( + reader: &mut BitReader, + dc_table: &HuffTable, + ac_table: &HuffTable, + dc_pred: &mut i32, + qt: &[u16; 64], +) -> Result<[i32; 64], String> { + let mut coeffs = [0i32; 64]; + + // DC coefficient + let dc_len = reader.decode_huffman(dc_table)?; + let dc_val = if dc_len > 0 { + let bits = reader.read_bits(dc_len)? as i32; + // Extend sign + if bits < (1 << (dc_len - 1)) { + bits - (1 << dc_len) + 1 + } else { + bits + } + } else { 0 }; + *dc_pred += dc_val; + coeffs[0] = *dc_pred * qt[0] as i32; + + // AC coefficients + let mut k = 1; + while k < 64 { + let rs = reader.decode_huffman(ac_table)?; + let run = (rs >> 4) as usize; // zero run length + let size = (rs & 0x0F) as u8; // value bit length + + if size == 0 { + if run == 0 { break; } // EOB + if run == 15 { k += 16; continue; } // ZRL (16 zeros) + break; + } + + k += run; + if k >= 64 { break; } + + let bits = reader.read_bits(size)? as i32; + let val = if bits < (1 << (size - 1)) { + bits - (1 << size) + 1 + } else { + bits + }; + coeffs[k] = val * qt[k] as i32; + k += 1; + } + + Ok(idct(&coeffs)) +} + +fn assemble_mcu( + mcu_blocks: &[Vec<[i32; 64]>], + components: &[JpegComponent], + num_components: u8, + max_h: u8, max_v: u8, + mcu_col: usize, mcu_row: usize, + img_width: usize, img_height: usize, + rgb: &mut [u8], +) { + let mcu_px = mcu_col * max_h as usize * 8; + let mcu_py = mcu_row * max_v as usize * 8; + + for py in 0..(max_v as usize * 8) { + for px in 0..(max_h as usize * 8) { + let x = mcu_px + px; + let y = mcu_py + py; + if x >= img_width || y >= img_height { continue; } + + if num_components == 1 { + // Grayscale + let val = sample_component(&mcu_blocks[0], &components[0], max_h, max_v, px, py); + let clamped = val.clamp(0, 255) as u8; + let offset = (y * img_width + x) * 3; + rgb[offset] = clamped; + rgb[offset + 1] = clamped; + rgb[offset + 2] = clamped; + } else { + // YCbCr → RGB + let yy = sample_component(&mcu_blocks[0], &components[0], max_h, max_v, px, py) as f32 + 128.0; + let cb = sample_component(&mcu_blocks[1], &components[1], max_h, max_v, px, py) as f32; + let cr = sample_component(&mcu_blocks[2], &components[2], max_h, max_v, px, py) as f32; + + let r = (yy + 1.402 * cr).round().clamp(0.0, 255.0) as u8; + let g = (yy - 0.344136 * cb - 0.714136 * cr).round().clamp(0.0, 255.0) as u8; + let b = (yy + 1.772 * cb).round().clamp(0.0, 255.0) as u8; + + let offset = (y * img_width + x) * 3; + rgb[offset] = r; + rgb[offset + 1] = g; + rgb[offset + 2] = b; + } + } + } +} + +fn sample_component( + blocks: &[[i32; 64]], + comp: &JpegComponent, + max_h: u8, max_v: u8, + px: usize, py: usize, +) -> i32 { + // Map pixel position to block + pixel within block + let scale_x = comp.h_sample as usize; + let scale_y = comp.v_sample as usize; + let cx = px * scale_x / (max_h as usize * 8); + let cy = py * scale_y / (max_v as usize * 8); + let bx = (px * scale_x / (max_h as usize)) % 8; + let by = (py * scale_y / (max_v as usize)) % 8; + let block_idx = cy * scale_x + cx; + if block_idx < blocks.len() { + blocks[block_idx][by * 8 + bx] + } else { + 0 + } +} + +/// Build a minimal valid 8x8 Baseline JPEG for testing. +/// Encodes a flat mid-gray image (Y=128, Cb=0, Cr=0 → RGB ~128,128,128). +fn build_minimal_jpeg_8x8() -> Vec { + // ... (test helper: builds SOI + DQT + SOF0 + DHT + SOS + scan data + EOI) + // This is a known-good hand-crafted JPEG byte stream + let mut out = Vec::new(); + + // SOI + out.extend_from_slice(&[0xFF, 0xD8]); + + // DQT — all-ones quantization table (id=0) + let mut dqt = Vec::new(); + dqt.extend_from_slice(&0x0043u16.to_be_bytes()); // length = 67 + dqt.push(0x00); // 8-bit, table 0 + for _ in 0..64 { dqt.push(1); } + out.extend_from_slice(&[0xFF, 0xDB]); + out.extend_from_slice(&dqt); + + // SOF0 — 8x8, 1 component (grayscale for simplicity) + out.extend_from_slice(&[0xFF, 0xC0]); + out.extend_from_slice(&0x000Bu16.to_be_bytes()); // length = 11 + out.push(8); // precision + out.extend_from_slice(&8u16.to_be_bytes()); // height + out.extend_from_slice(&8u16.to_be_bytes()); // width + out.push(1); // 1 component + out.push(1); // component ID + out.push(0x11); // h_sample=1, v_sample=1 + out.push(0); // qt table 0 + + // DHT — DC table (class=0, id=0): symbol 0x04 at length 2 (code=00), symbol 0x00 at length 2 (code=01) + // Minimal: only need DC length=4 → symbol 0x04 means "4 bits follow" + out.extend_from_slice(&[0xFF, 0xC4]); + let mut dht_body = Vec::new(); + dht_body.push(0x00); // DC, id=0 + // Counts for lengths 1-16: 1 symbol at length 1 + dht_body.push(1); + for _ in 1..16 { dht_body.push(0); } + dht_body.push(0x00); // symbol: category 0 (DC diff = 0) + let dht_len = (dht_body.len() + 2) as u16; + out.extend_from_slice(&dht_len.to_be_bytes()); + out.extend_from_slice(&dht_body); + + // DHT — AC table (class=1, id=0): just EOB symbol + out.extend_from_slice(&[0xFF, 0xC4]); + let mut dht_ac = Vec::new(); + dht_ac.push(0x10); // AC, id=0 + dht_ac.push(1); // 1 symbol at length 1 + for _ in 1..16 { dht_ac.push(0); } + dht_ac.push(0x00); // symbol: 0x00 = EOB + let dht_ac_len = (dht_ac.len() + 2) as u16; + out.extend_from_slice(&dht_ac_len.to_be_bytes()); + out.extend_from_slice(&dht_ac); + + // SOS + out.extend_from_slice(&[0xFF, 0xDA]); + out.extend_from_slice(&0x0008u16.to_be_bytes()); // length=8 + out.push(1); // 1 component + out.push(1); // component id=1 + out.push(0x00); // DC table 0, AC table 0 + out.push(0); // Ss + out.push(63); // Se + out.push(0); // Ah=0, Al=0 + + // Scan data: DC=0 (code=0, 1 bit), AC=EOB (code=0, 1 bit) + // Bits: 0 (DC diff=0) + 0 (EOB) = 0b00 → padded to byte: 0x00 + out.push(0x00); + // Pad to byte boundary + out.push(0x00); + + // EOI + out.extend_from_slice(&[0xFF, 0xD9]); + + out +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test --package voltex_renderer -- jpg::tests -v` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add crates/voltex_renderer/src/jpg.rs +git commit -m "feat(renderer): add JPEG scan decoder with MCU assembly and YCbCr conversion" +``` + +--- + +### Task 6: Subsampling Tests + Full Integration Test + +**Files:** +- Modify: `crates/voltex_renderer/src/jpg.rs` + +- [ ] **Step 1: Add comprehensive tests** + +```rust +#[test] +fn test_grayscale_flat() { + let jpg_data = build_minimal_jpeg_8x8(); + let (rgba, w, h) = parse_jpg(&jpg_data).unwrap(); + assert_eq!(w, 8); + assert_eq!(h, 8); + // Grayscale mid-gray: all pixels should be ~128 + for i in (0..rgba.len()).step_by(4) { + assert_eq!(rgba[i], rgba[i + 1]); // R == G + assert_eq!(rgba[i + 1], rgba[i + 2]); // G == B + assert_eq!(rgba[i + 3], 255); // alpha + } +} + +#[test] +fn test_invalid_marker() { + let data = [0xFF, 0xD8, 0x00]; // SOI then garbage + assert!(parse_jpg(&data).is_err()); +} +``` + +- [ ] **Step 2: Run all tests** + +Run: `cargo test --package voltex_renderer -- jpg -v` +Expected: All PASS + +- [ ] **Step 3: Run full workspace build** + +Run: `cargo build --workspace` +Expected: BUILD SUCCESS + +- [ ] **Step 4: Commit** + +```bash +git add crates/voltex_renderer/src/jpg.rs +git commit -m "feat(renderer): complete JPEG decoder with integration tests" +``` diff --git a/docs/superpowers/plans/2026-03-25-phase3a-ecs-filters-scheduler.md b/docs/superpowers/plans/2026-03-25-phase3a-ecs-filters-scheduler.md new file mode 100644 index 0000000..bb3a769 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-phase3a-ecs-filters-scheduler.md @@ -0,0 +1,441 @@ +# ECS Query Filters + System Scheduler Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `With` / `Without` query filters and a simple ordered system scheduler to voltex_ecs. + +**Architecture:** Query filters use existing `SparseSet::contains()` for per-entity filtering. Scheduler stores `Box` and runs them in registration order. `fn(&mut World)` auto-implements `System`. + +**Tech Stack:** Pure Rust, no external dependencies. Extends existing `World` in `world.rs`, new `scheduler.rs`. + +--- + +### Task 1: `has_component` Helper on World + +**Files:** +- Modify: `crates/voltex_ecs/src/world.rs` + +- [ ] **Step 1: Write test** + +```rust +#[test] +fn test_has_component() { + let mut world = World::new(); + let e = world.spawn(); + world.add(e, Position { x: 1.0, y: 2.0 }); + assert!(world.has_component::(e)); + assert!(!world.has_component::(e)); +} +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `cargo test --package voltex_ecs -- world::tests::test_has_component -v` +Expected: FAIL — method `has_component` not found + +- [ ] **Step 3: Implement** + +Add to `impl World` in `crates/voltex_ecs/src/world.rs`: + +```rust +pub fn has_component(&self, entity: Entity) -> bool { + self.storage::().map_or(false, |s| s.contains(entity)) +} +``` + +- [ ] **Step 4: Run test** + +Run: `cargo test --package voltex_ecs -- world::tests::test_has_component -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add crates/voltex_ecs/src/world.rs +git commit -m "feat(ecs): add has_component helper to World" +``` + +--- + +### Task 2: `query_with` and `query_without` (Single Component) + +**Files:** +- Modify: `crates/voltex_ecs/src/world.rs` + +- [ ] **Step 1: Write tests** + +```rust +#[test] +fn test_query_with() { + let mut world = World::new(); + let e0 = world.spawn(); + let e1 = world.spawn(); + let e2 = world.spawn(); + world.add(e0, Position { x: 1.0, y: 0.0 }); + world.add(e0, Velocity { dx: 1.0, dy: 0.0 }); + world.add(e1, Position { x: 2.0, y: 0.0 }); + // e1 has Position but no Velocity + world.add(e2, Position { x: 3.0, y: 0.0 }); + world.add(e2, Velocity { dx: 3.0, dy: 0.0 }); + + let results = world.query_with::(); + assert_eq!(results.len(), 2); + let entities: Vec = results.iter().map(|(e, _)| *e).collect(); + assert!(entities.contains(&e0)); + assert!(entities.contains(&e2)); + assert!(!entities.contains(&e1)); +} + +#[test] +fn test_query_without() { + let mut world = World::new(); + let e0 = world.spawn(); + let e1 = world.spawn(); + let e2 = world.spawn(); + world.add(e0, Position { x: 1.0, y: 0.0 }); + world.add(e0, Velocity { dx: 1.0, dy: 0.0 }); + world.add(e1, Position { x: 2.0, y: 0.0 }); + // e1 has Position but no Velocity — should be included + world.add(e2, Position { x: 3.0, y: 0.0 }); + world.add(e2, Velocity { dx: 3.0, dy: 0.0 }); + + let results = world.query_without::(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, e1); +} +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `cargo test --package voltex_ecs -- world::tests::test_query_with -v` +Expected: FAIL + +- [ ] **Step 3: Implement query_with and query_without** + +Add to `impl World`: + +```rust +/// Query entities that have component T AND also have component W. +pub fn query_with(&self) -> Vec<(Entity, &T)> { + let t_storage = match self.storage::() { + Some(s) => s, + None => return Vec::new(), + }; + let mut result = Vec::new(); + for (entity, data) in t_storage.iter() { + if self.has_component::(entity) { + result.push((entity, data)); + } + } + result +} + +/// Query entities that have component T but NOT component W. +pub fn query_without(&self) -> Vec<(Entity, &T)> { + let t_storage = match self.storage::() { + Some(s) => s, + None => return Vec::new(), + }; + let mut result = Vec::new(); + for (entity, data) in t_storage.iter() { + if !self.has_component::(entity) { + result.push((entity, data)); + } + } + result +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test --package voltex_ecs -- world::tests -v` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add crates/voltex_ecs/src/world.rs +git commit -m "feat(ecs): add query_with and query_without filters" +``` + +--- + +### Task 3: `query2_with` and `query2_without` + +**Files:** +- Modify: `crates/voltex_ecs/src/world.rs` + +- [ ] **Step 1: Write tests** + +```rust +#[test] +fn test_query2_with() { + #[derive(Debug, PartialEq)] + struct Health(i32); + + let mut world = World::new(); + let e0 = world.spawn(); + world.add(e0, Position { x: 1.0, y: 0.0 }); + world.add(e0, Velocity { dx: 1.0, dy: 0.0 }); + world.add(e0, Health(100)); + + let e1 = world.spawn(); + world.add(e1, Position { x: 2.0, y: 0.0 }); + world.add(e1, Velocity { dx: 2.0, dy: 0.0 }); + // e1 has no Health + + let results = world.query2_with::(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, e0); +} + +#[test] +fn test_query2_without() { + #[derive(Debug, PartialEq)] + struct Health(i32); + + let mut world = World::new(); + let e0 = world.spawn(); + world.add(e0, Position { x: 1.0, y: 0.0 }); + world.add(e0, Velocity { dx: 1.0, dy: 0.0 }); + world.add(e0, Health(100)); + + let e1 = world.spawn(); + world.add(e1, Position { x: 2.0, y: 0.0 }); + world.add(e1, Velocity { dx: 2.0, dy: 0.0 }); + // e1 has no Health + + let results = world.query2_without::(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, e1); +} +``` + +- [ ] **Step 2: Run tests to verify failure** + +- [ ] **Step 3: Implement** + +```rust +/// Query entities with components A and B, that also have component W. +pub fn query2_with(&self) -> Vec<(Entity, &A, &B)> { + self.query2::().into_iter() + .filter(|(e, _, _)| self.has_component::(*e)) + .collect() +} + +/// Query entities with components A and B, that do NOT have component W. +pub fn query2_without(&self) -> Vec<(Entity, &A, &B)> { + self.query2::().into_iter() + .filter(|(e, _, _)| !self.has_component::(*e)) + .collect() +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test --package voltex_ecs -- world::tests -v` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add crates/voltex_ecs/src/world.rs +git commit -m "feat(ecs): add query2_with and query2_without filters" +``` + +--- + +### Task 4: System Trait + Scheduler + +**Files:** +- Create: `crates/voltex_ecs/src/scheduler.rs` +- Modify: `crates/voltex_ecs/src/lib.rs` + +- [ ] **Step 1: Write tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::World; + + #[derive(Debug, PartialEq)] + struct Counter(u32); + + #[test] + fn test_scheduler_runs_in_order() { + let mut world = World::new(); + let e = world.spawn(); + world.add(e, Counter(0)); + + let mut scheduler = Scheduler::new(); + scheduler.add(|world: &mut World| { + let c = world.get_mut::(world.query::().next().unwrap().0).unwrap(); + c.0 += 1; // 0 → 1 + }); + scheduler.add(|world: &mut World| { + let c = world.get_mut::(world.query::().next().unwrap().0).unwrap(); + c.0 *= 10; // 1 → 10 + }); + + scheduler.run_all(&mut world); + + let c = world.get::(e).unwrap(); + assert_eq!(c.0, 10); // proves order: add first, then multiply + } + + #[test] + fn test_scheduler_empty() { + let mut world = World::new(); + let mut scheduler = Scheduler::new(); + scheduler.run_all(&mut world); // should not panic + } + + #[test] + fn test_scheduler_multiple_runs() { + let mut world = World::new(); + let e = world.spawn(); + world.add(e, Counter(0)); + + let mut scheduler = Scheduler::new(); + scheduler.add(|world: &mut World| { + let c = world.get_mut::(world.query::().next().unwrap().0).unwrap(); + c.0 += 1; + }); + + scheduler.run_all(&mut world); + scheduler.run_all(&mut world); + scheduler.run_all(&mut world); + + assert_eq!(world.get::(e).unwrap().0, 3); + } + + #[test] + fn test_scheduler_add_chaining() { + let mut scheduler = Scheduler::new(); + scheduler + .add(|_: &mut World| {}) + .add(|_: &mut World| {}); + assert_eq!(scheduler.len(), 2); + } +} +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `cargo test --package voltex_ecs -- scheduler::tests -v` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement Scheduler** + +```rust +// crates/voltex_ecs/src/scheduler.rs + +use crate::World; + +/// A system that can be run on the world. +pub trait System { + fn run(&mut self, world: &mut World); +} + +/// Blanket impl: any FnMut(&mut World) is a System. +impl System for F { + fn run(&mut self, world: &mut World) { + (self)(world); + } +} + +/// Runs registered systems in order. +pub struct Scheduler { + systems: Vec>, +} + +impl Scheduler { + pub fn new() -> Self { + Self { systems: Vec::new() } + } + + /// Add a system. Systems run in the order they are added. + pub fn add(&mut self, system: S) -> &mut Self { + self.systems.push(Box::new(system)); + self + } + + /// Run all systems in registration order. + pub fn run_all(&mut self, world: &mut World) { + for system in &mut self.systems { + system.run(world); + } + } + + /// Number of registered systems. + pub fn len(&self) -> usize { + self.systems.len() + } + + pub fn is_empty(&self) -> bool { + self.systems.is_empty() + } +} + +impl Default for Scheduler { + fn default() -> Self { + Self::new() + } +} +``` + +Register in `crates/voltex_ecs/src/lib.rs`: +```rust +pub mod scheduler; +pub use scheduler::{Scheduler, System}; +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test --package voltex_ecs -- scheduler::tests -v` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add crates/voltex_ecs/src/scheduler.rs crates/voltex_ecs/src/lib.rs +git commit -m "feat(ecs): add System trait and ordered Scheduler" +``` + +--- + +### Task 5: Export and Full Build Verification + +**Files:** +- Modify: `crates/voltex_ecs/src/lib.rs` + +- [ ] **Step 1: Verify lib.rs exports are complete** + +Ensure `lib.rs` exports: +```rust +pub use world::World; // existing — now includes query_with, query_without, etc. +pub use scheduler::{Scheduler, System}; +``` + +- [ ] **Step 2: Run all ECS tests** + +Run: `cargo test --package voltex_ecs -v` +Expected: All tests PASS (existing + new) + +- [ ] **Step 3: Run full workspace build** + +Run: `cargo build --workspace` +Expected: BUILD SUCCESS + +- [ ] **Step 4: Run full workspace tests** + +Run: `cargo test --workspace` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add crates/voltex_ecs/src/lib.rs +git commit -m "feat(ecs): complete query filters and scheduler with exports" +```