From 94c6043b23e88455acd9a95b60e60c7c8309b228 Mon Sep 17 00:00:00 2001 From: Daniel P Date: Wed, 10 Jun 2026 13:49:09 -0400 Subject: [PATCH 01/10] fix: rasterize circles/ellipses round with pixel-art rounding The midpoint-walk implementation produced angular discs (a r=7 circle read as a flattened hexagon) and its region-2 loop could step x past the radius, making the widest rows one pixel too wide (16px for a 15px-diameter circle). Replaced with a per-row span fill using the conventional pixel-art inside test ((dx/(rx+.5))^2+(dy/(ry+.5))^2 <= 1), clamped to the radius. Golden tests pin exact per-row spans for r=2..16 (span, no holes, no overshoot) and the r=7 silhouette (narrow pole, monotone widths, full-diameter equator). Existing ellipse tests and the analyze-side shape detector (which compares against this function) stay green. --- src/shapes.rs | 136 ++++++++++++++++++++++++++++---------------------- 1 file changed, 75 insertions(+), 61 deletions(-) diff --git a/src/shapes.rs b/src/shapes.rs index 62d1b1f..9f0f135 100644 --- a/src/shapes.rs +++ b/src/shapes.rs @@ -141,9 +141,14 @@ pub fn rasterize_stroke(x: i32, y: i32, w: i32, h: i32, thickness: i32) -> HashS pixels } -/// Rasterize a filled ellipse using the midpoint algorithm. +/// Rasterize a filled ellipse with conventional pixel-art rounding. /// -/// Returns all pixels within an ellipse centered at (cx, cy) with radii (rx, ry). +/// Returns all pixels within an ellipse centered at (cx, cy) with radii +/// (rx, ry): a pixel is inside when `(dx/(rx+0.5))² + (dy/(ry+0.5))² <= 1`. +/// The +0.5 treats each radius as extending to the edge of the boundary +/// pixel, which produces the round shapes pixel artists expect (the +/// previous midpoint-walk implementation produced angular discs and could +/// overshoot the radius by one pixel on the widest rows). /// /// # Examples /// @@ -153,6 +158,8 @@ pub fn rasterize_stroke(x: i32, y: i32, w: i32, h: i32, thickness: i32) -> HashS /// let pixels = rasterize_ellipse(5, 5, 3, 2); /// assert!(pixels.contains(&(5, 5))); // Center /// assert!(pixels.len() > 0); +/// // Never wider than the diameter: +/// assert!(pixels.iter().all(|&(x, y)| (x - 5).abs() <= 3 && (y - 5).abs() <= 2)); /// ``` pub fn rasterize_ellipse(cx: i32, cy: i32, rx: i32, ry: i32) -> HashSet<(i32, i32)> { let mut pixels = HashSet::new(); @@ -161,56 +168,14 @@ pub fn rasterize_ellipse(cx: i32, cy: i32, rx: i32, ry: i32) -> HashSet<(i32, i3 return pixels; } - // Convert to i64 to avoid overflow in calculations - let rx = rx as i64; - let ry = ry as i64; - let cx = cx as i64; - let cy = cy as i64; - - // Region 1 - let mut x = 0i64; - let mut y = ry; - - let rx_sq = rx * rx; - let ry_sq = ry * ry; - - let mut p1 = ry_sq - (rx_sq * ry) + (rx_sq / 4); - let mut dx = 2 * ry_sq * x; - let mut dy = 2 * rx_sq * y; - - // Region 1 decision parameter - while dx < dy { - draw_ellipse_points(cx, cy, x, y, &mut pixels); - - if p1 < 0 { - x += 1; - dx += 2 * ry_sq; - p1 += dx + ry_sq; - } else { - x += 1; - y -= 1; - dx += 2 * ry_sq; - dy -= 2 * rx_sq; - p1 += dx - dy + ry_sq; - } - } - - // Region 2 decision parameter - let mut p2 = ry_sq * (x + 1) * (x + 1) / 4 + rx_sq * (y - 1) * (y - 1) - rx_sq * ry_sq; - - while y >= 0 { - draw_ellipse_points(cx, cy, x, y, &mut pixels); - - if p2 > 0 { - y -= 1; - dy -= 2 * rx_sq; - p2 += rx_sq - dy; - } else { - x += 1; - y -= 1; - dx += 2 * ry_sq; - dy -= 2 * rx_sq; - p2 += dx - dy + rx_sq; + let a = rx as f64 + 0.5; + let b = ry as f64 + 0.5; + for dy in -ry..=ry { + let t = 1.0 - (dy as f64 / b).powi(2); + let half_width = (a * t.sqrt()).floor() as i32; + let half_width = half_width.min(rx); // rounding may not exceed the radius + for dx in -half_width..=half_width { + pixels.insert((cx + dx, cy + dy)); } } @@ -507,15 +472,6 @@ fn find_interior_seed( None } -/// Helper function to draw ellipse points with fill. -fn draw_ellipse_points(cx: i64, cy: i64, x: i64, y: i64, pixels: &mut HashSet<(i32, i32)>) { - // Fill horizontal lines for each y-coordinate - for scan_x in -x..=x { - pixels.insert(((cx + scan_x) as i32, (cy + y) as i32)); - pixels.insert(((cx + scan_x) as i32, (cy - y) as i32)); - } -} - /// Rasterize a filled polygon using scanline fill algorithm. /// /// Returns all pixels within a polygon defined by a list of vertices. @@ -732,6 +688,64 @@ mod tests { assert!(!pixels.is_empty()); } + /// Golden test: per-row widths of circles must match the conventional + /// pixel-art circle (hw = floor((r+0.5)·sqrt(1-(dy/(r+0.5))²))), stay + /// symmetric, and never exceed the diameter. The old midpoint walk + /// produced 5,9,11,13,15,16,16,16,16,16,… for r=7 — angular AND one + /// pixel too wide in the middle. + #[test] + fn test_rasterize_circle_golden_row_widths() { + for r in 2..=16 { + let c = 20; // comfortably inside positive space + let pixels = rasterize_ellipse(c, c, r, r); + let rf = r as f64 + 0.5; + for dy in -r..=r { + let expected_hw = ((rf * (1.0 - (dy as f64 / rf).powi(2)).sqrt()).floor() as i32).min(r); + let row: Vec = + pixels.iter().filter(|&&(_, y)| y == c + dy).map(|&(x, _)| x).collect(); + let min_x = *row.iter().min().expect("row present"); + let max_x = *row.iter().max().expect("row present"); + assert_eq!( + (min_x, max_x), + (c - expected_hw, c + expected_hw), + "r={} dy={}: row span wrong", + r, + dy + ); + assert_eq!( + row.len() as i32, + 2 * expected_hw + 1, + "r={} dy={}: row has holes", + r, + dy + ); + } + // No pixel outside the bounding box (the old overshoot bug). + assert!( + pixels.iter().all(|&(x, y)| (x - c).abs() <= r && (y - c).abs() <= r), + "r={}: pixels exceed radius", + r + ); + } + } + + /// The r=7 disc reads round: the top row is narrow, widths increase + /// monotonically to the equator, and the equator is exactly 2r+1 wide. + #[test] + fn test_rasterize_circle_r7_shape() { + let pixels = rasterize_ellipse(7, 7, 7, 7); + let width_at = |dy: i32| pixels.iter().filter(|&&(_, y)| y == 7 + dy).count() as i32; + assert_eq!(width_at(0), 15, "equator is the full diameter"); + assert_eq!(width_at(-7), 5, "polar row is narrow (round, not flat)"); + for dy in 0..7 { + assert!( + width_at(dy) >= width_at(dy + 1), + "widths shrink monotonically toward the pole (dy={})", + dy + ); + } + } + #[test] fn test_rasterize_ellipse_horizontal() { let pixels = rasterize_ellipse(5, 5, 4, 2); From 9996627d73935a055af1cfaf0d2f82e66bfa93a5 Mon Sep 17 00:00:00 2001 From: Daniel P Date: Wed, 10 Jun 2026 13:49:22 -0400 Subject: [PATCH 02/10] feat: validate catches duplicate region keys, OOB pixels, empty regions, unused tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four silent wrong-output paths become warnings (errors under --strict): - Duplicate region key: JSON5 deserialization keeps only the last value, so the earlier region vanishes with no signal — the #1 practical footgun. Caught by a string/comment/depth-aware scan of the raw sprite text, since it is unrecoverable after parse. - Out-of-canvas pixels: rasterized regions whose pixels the renderer would silently clip are reported with a count and an example coordinate. Validation rasterizes exactly the way the renderer does (same two-pass fill/auto-shadow order). - Empty regions: zero-pixel regions (e.g. the unimplemented auto-outline) are reported, with a pointed suggestion for the auto-outline case. - Unused palette tokens: the mirror-image symptom of a misspelled region key. Inline palettes are checked per sprite; named palettes are checked at end of file across all local sprites, and palette-only library files stay quiet (their users may live in other files). Unit tests cover each, plus the nested-objects-not-confused case for the raw scanner and the palette-only-file silence. --- src/validate.rs | 421 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 421 insertions(+) diff --git a/src/validate.rs b/src/validate.rs index 3eb9edb..00fad03 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -85,6 +85,14 @@ pub enum IssueType { UnusedImport, /// Import alias or imported name shadows a locally defined name ShadowedImport, + /// The same region key appears twice in one sprite (earlier one is lost) + DuplicateRegionKey, + /// A region resolves to zero pixels + EmptyRegion, + /// A region has pixels outside the sprite canvas (silently clipped at render) + OutOfCanvas, + /// A palette token is never used by any region of the sprites that use the palette + UnusedToken, } impl std::fmt::Display for IssueType { @@ -110,10 +118,131 @@ impl std::fmt::Display for IssueType { IssueType::UnresolvedImport => write!(f, "unresolved_import"), IssueType::UnusedImport => write!(f, "unused_import"), IssueType::ShadowedImport => write!(f, "shadowed_import"), + IssueType::DuplicateRegionKey => write!(f, "duplicate_region_key"), + IssueType::EmptyRegion => write!(f, "empty_region"), + IssueType::OutOfCanvas => write!(f, "out_of_canvas"), + IssueType::UnusedToken => write!(f, "unused_token"), } } } +/// Scan the raw JSON5 text of a sprite object for duplicate keys inside its +/// top-level `regions` object. Deserialization keeps only the last value for +/// a repeated key, so this is unrecoverable downstream — it must be caught +/// on the raw text. String-, escape- and comment-aware; nested objects +/// (region definitions) are skipped by depth tracking. +fn duplicate_region_keys(content: &str) -> Vec { + #[derive(PartialEq)] + enum Mode { + Seeking, // looking for the `regions` key at sprite depth + InRegions, // collecting keys at regions depth + Done, + } + + let mut mode = Mode::Seeking; + let mut depth: i32 = 0; + let mut regions_depth: i32 = 0; + let mut in_string = false; + let mut string_quote = '"'; + let mut escaped = false; + let mut in_line_comment = false; + let mut in_block_comment = false; + let mut prev: Option = None; + let mut token = String::new(); // last bare-word or string literal seen + let mut expecting_key = false; + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + let mut duplicates: Vec = Vec::new(); + + for ch in content.chars() { + if in_line_comment { + if ch == '\n' { + in_line_comment = false; + } + prev = Some(ch); + continue; + } + if in_block_comment { + if prev == Some('*') && ch == '/' { + in_block_comment = false; + } + prev = Some(ch); + continue; + } + if in_string { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == string_quote { + in_string = false; + } else { + token.push(ch); + } + prev = Some(ch); + continue; + } + match ch { + '/' if prev == Some('/') => { + in_line_comment = true; + token.clear(); + } + '*' if prev == Some('/') => in_block_comment = true, + '"' | '\'' => { + in_string = true; + string_quote = ch; + token.clear(); + } + '{' => { + depth += 1; + if mode == Mode::InRegions && depth == regions_depth { + expecting_key = true; + } + token.clear(); + } + '}' => { + depth -= 1; + if mode == Mode::InRegions && depth < regions_depth { + mode = Mode::Done; // the regions object itself closed + } + token.clear(); + } + ':' => { + match mode { + Mode::Seeking if token == "regions" && depth == 1 => { + mode = Mode::InRegions; + regions_depth = depth + 1; // keys live one level in + } + Mode::InRegions if expecting_key && depth == regions_depth && !token.is_empty() => { + let count = seen.entry(token.clone()).or_insert(0); + *count += 1; + if *count == 2 { + duplicates.push(token.clone()); + } + expecting_key = false; + } + _ => {} + } + token.clear(); + } + ',' => { + if mode == Mode::InRegions && depth == regions_depth { + expecting_key = true; + } + token.clear(); + } + c if c.is_alphanumeric() || c == '_' || c == '-' || c == '$' => token.push(c), + c if c.is_whitespace() => {} + _ => token.clear(), + } + prev = Some(ch); + if matches!(mode, Mode::Done) { + break; + } + } + + duplicates +} + /// A validation issue found in the input #[derive(Debug, Clone)] pub struct ValidationIssue { @@ -204,6 +333,10 @@ pub struct Validator { local_names: HashSet, /// Names imported via import declarations (import alias → items) imported_names: HashSet, + /// Line where each local palette was declared (for unused-token reporting) + palette_lines: HashMap, + /// Tokens actually referenced by sprites, per named palette + used_palette_tokens: HashMap>, } impl Default for Validator { @@ -231,6 +364,8 @@ impl Validator { tracked_imports: Vec::new(), local_names: HashSet::new(), imported_names: HashSet::new(), + palette_lines: HashMap::new(), + used_palette_tokens: HashMap::new(), } } @@ -335,6 +470,24 @@ impl Validator { self.validate_palette(line_number, &palette); } TtpObject::Sprite(sprite) => { + // Duplicate region keys are unrecoverable after JSON5 + // deserialization (last key silently wins), so scan the raw + // text — losing a region to a duplicate key is the most + // common silent data-loss in practice. + for dup in duplicate_region_keys(content) { + self.issues.push( + ValidationIssue::warning( + line_number, + IssueType::DuplicateRegionKey, + format!( + "Region key {:?} appears more than once — earlier definitions are silently discarded", + dup + ), + ) + .with_context(format!("sprite \"{}\"", sprite.name)) + .with_suggestion("give each visual component a unique token (e.g. wing, wing_hi)".to_string()), + ); + } self.validate_sprite(line_number, &sprite); } TtpObject::Animation(animation) => { @@ -480,6 +633,7 @@ impl Validator { let colors = &palette.colors; // Track as a local name self.local_names.insert(name.to_string()); + self.palette_lines.insert(name.to_string(), line_number); // Check for duplicate name if !self.palette_names.insert(name.to_string()) { self.issues.push( @@ -711,6 +865,155 @@ impl Validator { } } } + + // Record token usage for end-of-file unused-token reporting (named + // palettes are shared between sprites, so per-sprite reporting would + // be noisy); inline palettes are self-contained — check immediately. + match &sprite.palette { + PaletteRef::Named(palette_name) => { + let used = self.used_palette_tokens.entry(palette_name.clone()).or_default(); + for token in &all_tokens_used { + used.insert(token.clone()); + } + } + PaletteRef::Inline(colors) => { + let mut unused: Vec<&String> = colors + .keys() + .filter(|t| *t != "_" && !all_tokens_used.contains(*t)) + .collect(); + unused.sort(); + if !unused.is_empty() { + self.issues.push( + ValidationIssue::warning( + line_number, + IssueType::UnusedToken, + format!( + "Palette tokens never used by any region: {} — often the mirror-image of a misspelled region key", + unused.iter().map(|t| t.as_str()).collect::>().join(", ") + ), + ) + .with_context(format!("sprite \"{}\"", name)), + ); + } + } + } + + // Rasterize the regions the way the renderer will (same two-pass + // order for fill/auto-shadow references) and warn on what the + // renderer would silently swallow: zero-pixel regions and pixels + // clipped outside the canvas. + if let (Some(regions), Some([w, h])) = (&sprite.regions, sprite.size) { + let width = w as i32; + let height = h as i32; + if width > 0 && height > 0 { + let mut raster_warnings = Vec::new(); + let mut rasterized: HashMap> = HashMap::new(); + let mut pending: Vec<(&String, &crate::models::RegionDef)> = Vec::new(); + for (token, region) in regions { + if region.fill.is_some() || region.auto_shadow.is_some() { + pending.push((token, region)); + } else { + let pixels = crate::structured::rasterize_region( + region, + &rasterized, + width, + height, + &mut raster_warnings, + ); + rasterized.insert(token.clone(), pixels); + } + } + for (token, region) in pending { + let pixels = crate::structured::rasterize_region( + region, + &rasterized, + width, + height, + &mut raster_warnings, + ); + rasterized.insert(token.clone(), pixels); + } + + let mut tokens: Vec<&String> = rasterized.keys().collect(); + tokens.sort(); + for token in tokens { + let pixels = &rasterized[token]; + if pixels.is_empty() { + let mut issue = ValidationIssue::warning( + line_number, + IssueType::EmptyRegion, + format!("Region {} resolves to zero pixels", token), + ) + .with_context(format!("sprite \"{}\"", name)); + if regions.get(token).is_some_and(|r| r.auto_outline.is_some()) { + issue = issue.with_suggestion( + "auto-outline does not render; author the outline as a silhouette region".to_string(), + ); + } + self.issues.push(issue); + } else { + let mut oob: Vec<&(i32, i32)> = pixels + .iter() + .filter(|(x, y)| *x < 0 || *y < 0 || *x >= width || *y >= height) + .collect(); + if !oob.is_empty() { + oob.sort(); + self.issues.push( + ValidationIssue::warning( + line_number, + IssueType::OutOfCanvas, + format!( + "Region {} has {} pixel(s) outside the {}x{} canvas (e.g. ({}, {})) — they are silently clipped at render", + token, + oob.len(), + width, + height, + oob[0].0, + oob[0].1 + ), + ) + .with_context(format!("sprite \"{}\"", name)), + ); + } + } + } + } + } + } + + /// Report palette tokens that no sprite in the file ever used. Called at + /// the end of a file pass; only palettes referenced by at least one + /// local sprite are checked, so palette-only library files stay quiet. + pub fn report_unused_palette_tokens(&mut self) { + let mut reports: Vec<(usize, String, Vec)> = Vec::new(); + for (palette_name, used) in &self.used_palette_tokens { + if let Some(defined) = self.palettes.get(palette_name) { + let mut unused: Vec = defined + .iter() + .filter(|t| *t != "_" && !used.contains(*t)) + .cloned() + .collect(); + unused.sort(); + if !unused.is_empty() { + let line = self.palette_lines.get(palette_name).copied().unwrap_or(0); + reports.push((line, palette_name.clone(), unused)); + } + } + } + reports.sort(); + for (line, palette_name, unused) in reports { + self.issues.push( + ValidationIssue::warning( + line, + IssueType::UnusedToken, + format!( + "Palette tokens never used by any region: {} — often the mirror-image of a misspelled region key", + unused.join(", ") + ), + ) + .with_context(format!("palette \"{}\"", palette_name)), + ); + } } /// Get tokens defined in a palette reference @@ -1114,6 +1417,8 @@ impl Validator { self.validate_line(start_line, &accumulator); } + self.report_unused_palette_tokens(); + Ok(()) } @@ -1680,6 +1985,122 @@ mod tests { assert_eq!(unused[0].import.from, "./palettes"); } + #[test] + fn test_duplicate_region_key_detected() { + let mut validator = Validator::new(); + validator.validate_line( + 1, + r##"{"type": "sprite", "name": "dup", "size": [16, 16], "palette": {"_": "transparent", "b": "#FF0000"}, + "regions": { b: { rect: [0, 0, 4, 4] }, b: { rect: [10, 10, 4, 4] } }}"##, + ); + assert!( + validator.issues().iter().any(|i| matches!(i.issue_type, IssueType::DuplicateRegionKey)), + "duplicate region key must be reported: {:?}", + validator.issues() + ); + } + + #[test] + fn test_duplicate_region_keys_nested_objects_not_confused() { + let mut validator = Validator::new(); + // Same key names inside DIFFERENT region values (e.g. union members) + // and in non-region nested objects must not count as duplicates. + validator.validate_line( + 1, + r##"{"type": "sprite", "name": "ok", "size": [16, 16], "palette": {"_": "transparent", "a": "#FF0000", "b": "#00FF00"}, + "regions": {"a": {"union": [{"rect": [0, 0, 2, 2]}, {"rect": [4, 4, 2, 2]}]}, "b": {"points": [[1, 1]]}}}"##, + ); + assert!( + !validator.issues().iter().any(|i| matches!(i.issue_type, IssueType::DuplicateRegionKey)), + "no duplicates here: {:?}", + validator.issues() + ); + } + + #[test] + fn test_out_of_canvas_pixels_warned() { + let mut validator = Validator::new(); + validator.validate_line( + 1, + r##"{"type": "sprite", "name": "oob", "size": [16, 16], "palette": {"_": "transparent", "c": "#FF0000"}, + "regions": {"c": {"points": [[30, 30], [2, 2]]}}}"##, + ); + assert!( + validator.issues().iter().any(|i| matches!(i.issue_type, IssueType::OutOfCanvas)), + "out-of-canvas pixel must be reported: {:?}", + validator.issues() + ); + } + + #[test] + fn test_empty_region_warned() { + let mut validator = Validator::new(); + // auto-outline renders nothing — the canonical zero-pixel region. + validator.validate_line( + 1, + r##"{"type": "sprite", "name": "hollow", "size": [16, 16], "palette": {"_": "transparent", "b": "#FF0000", "d": "#000000"}, + "regions": {"b": {"rect": [5, 5, 6, 6]}, "d": {"auto-outline": "b"}}}"##, + ); + assert!( + validator.issues().iter().any(|i| matches!(i.issue_type, IssueType::EmptyRegion)), + "zero-pixel region must be reported: {:?}", + validator.issues() + ); + } + + #[test] + fn test_unused_inline_palette_token_warned() { + let mut validator = Validator::new(); + validator.validate_line( + 1, + r##"{"type": "sprite", "name": "s", "size": [4, 4], "palette": {"_": "transparent", "a": "#FF0000", "ghost": "#00FF00"}, + "regions": {"a": {"rect": [0, 0, 4, 4]}}}"##, + ); + assert!( + validator.issues().iter().any(|i| matches!(i.issue_type, IssueType::UnusedToken)), + "unused inline palette token must be reported: {:?}", + validator.issues() + ); + } + + #[test] + fn test_unused_named_palette_token_reported_at_file_end() { + let mut validator = Validator::new(); + validator.validate_line( + 1, + r##"{"type": "palette", "name": "p", "colors": {"_": "transparent", "a": "#FF0000", "ghost": "#00FF00"}}"##, + ); + validator.validate_line( + 2, + r##"{"type": "sprite", "name": "s", "size": [4, 4], "palette": "p", "regions": {"a": {"rect": [0, 0, 4, 4]}}}"##, + ); + validator.report_unused_palette_tokens(); + let unused: Vec<_> = validator + .issues() + .iter() + .filter(|i| matches!(i.issue_type, IssueType::UnusedToken)) + .collect(); + assert_eq!(unused.len(), 1, "exactly the ghost token: {:?}", validator.issues()); + assert!(unused[0].message.contains("ghost")); + } + + #[test] + fn test_palette_only_file_stays_quiet() { + let mut validator = Validator::new(); + // A palette library file: no local sprites use it — unused-token + // reporting must not fire (sprites in other files may use it). + validator.validate_line( + 1, + r##"{"type": "palette", "name": "lib", "colors": {"_": "transparent", "a": "#FF0000"}}"##, + ); + validator.report_unused_palette_tokens(); + assert!( + !validator.issues().iter().any(|i| matches!(i.issue_type, IssueType::UnusedToken)), + "palette-only files must stay quiet: {:?}", + validator.issues() + ); + } + #[test] fn test_validate_used_import_tracked() { let mut validator = Validator::new(); From 5b87328904a4b87a9977f11d30c7e59a67a7cdff Mon Sep 17 00:00:00 2001 From: Daniel P Date: Wed, 10 Jun 2026 13:49:32 -0400 Subject: [PATCH 03/10] feat: diff compares region geometry and reports presence honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pxl diff compared only dimensions and palettes: two same-size, same-palette sprites with completely different pixels printed 'No differences found', and a sprite missing from one file did too (the removed/added summary was built but format_diff's is_empty() check ignored it). For a verification tool, a silent pass is worse than no tool. SpriteDiff gains region_changes and presence_change, both honored by is_empty(), text output, and --json. Regions are rasterized the way the renderer rasterizes them and compared per token: added, removed, shifted (exact (dx,dy) when the shape translated as a unit — walk-cycle bobs and frame tears show up as exactly this), or changed with the differing-pixel count. Tests: shift detection, shape change, added/removed tokens, and a presence diff that must never print 'No differences'. --- src/cli/explain.rs | 26 +++++ src/diff.rs | 231 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 249 insertions(+), 8 deletions(-) diff --git a/src/cli/explain.rs b/src/cli/explain.rs index c351026..28918dc 100644 --- a/src/cli/explain.rs +++ b/src/cli/explain.rs @@ -274,6 +274,32 @@ pub fn run_diff(file_a: &PathBuf, file_b: &PathBuf, sprite: Option<&str>, json: }); } + if let Some(ref note) = diff.presence_change { + obj["presence_change"] = serde_json::json!(note); + } + + if !diff.region_changes.is_empty() { + let region_changes: Vec<_> = diff + .region_changes + .iter() + .map(|c| match c { + crate::diff::RegionChange::Added { token } => { + serde_json::json!({"type": "added", "token": token}) + } + crate::diff::RegionChange::Removed { token } => { + serde_json::json!({"type": "removed", "token": token}) + } + crate::diff::RegionChange::Shifted { token, dx, dy } => { + serde_json::json!({"type": "shifted", "token": token, "dx": dx, "dy": dy}) + } + crate::diff::RegionChange::Changed { token, differing } => { + serde_json::json!({"type": "changed", "token": token, "differing": differing}) + } + }) + .collect(); + obj["region_changes"] = serde_json::json!(region_changes); + } + if !diff.palette_changes.is_empty() { let palette_changes: Vec<_> = diff .palette_changes diff --git a/src/diff.rs b/src/diff.rs index 9504198..82c904f 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -3,8 +3,10 @@ //! Provides tools for comparing sprites and detecting differences in: //! - Dimensions (width, height) //! - Palette colors (added, removed, changed tokens) +//! - Region geometry (added, removed, shifted, or changed pixels per token) +//! - Presence (a sprite existing in only one of the two files) -use crate::models::{PaletteRef, Sprite, TtpObject}; +use crate::models::{PaletteRef, RegionDef, Sprite, TtpObject}; use crate::parser::parse_stream; use std::collections::{HashMap, HashSet}; use std::fs::File; @@ -18,6 +20,10 @@ pub struct SpriteDiff { pub dimension_change: Option, /// Changes to palette colors pub palette_changes: Vec, + /// Changes to region geometry + pub region_changes: Vec, + /// Set when the sprite exists in only one of the files + pub presence_change: Option, /// Human-readable summary of the diff pub summary: String, } @@ -25,10 +31,26 @@ pub struct SpriteDiff { impl SpriteDiff { /// Returns true if there are no differences pub fn is_empty(&self) -> bool { - self.dimension_change.is_none() && self.palette_changes.is_empty() + self.dimension_change.is_none() + && self.palette_changes.is_empty() + && self.region_changes.is_empty() + && self.presence_change.is_none() } } +/// A geometric change to one region (token) between two sprites +#[derive(Debug, Clone, PartialEq)] +pub enum RegionChange { + /// Region exists only in the second sprite + Added { token: String }, + /// Region exists only in the first sprite + Removed { token: String }, + /// Same shape, translated as a unit (a walk-cycle bob shows up here) + Shifted { token: String, dx: i32, dy: i32 }, + /// Shape changed: `differing` pixels in the symmetric difference + Changed { token: String, differing: usize }, +} + /// Change in sprite dimensions #[derive(Debug, Clone, PartialEq)] pub struct DimensionChange { @@ -137,10 +159,104 @@ pub fn diff_sprites( token_a.cmp(token_b) }); + // Compare region geometry (the part "No differences found" used to skip + // entirely: two same-size, same-palette sprites with completely + // different pixels diffed as identical). + let region_changes = diff_regions(a, b); + // Generate summary - let summary = generate_summary(&dimension_change, &palette_changes); + let summary = generate_summary(&dimension_change, &palette_changes, ®ion_changes); - SpriteDiff { dimension_change, palette_changes, summary } + SpriteDiff { dimension_change, palette_changes, region_changes, presence_change: None, summary } +} + +/// Rasterize both sprites' regions (renderer's two-pass order) and compare +/// them token by token. Sprites without regions+size (e.g. derived sprites) +/// are not compared — geometry comparison needs concrete regions. +fn diff_regions(a: &Sprite, b: &Sprite) -> Vec { + let (Some(regions_a), Some(regions_b)) = (&a.regions, &b.regions) else { + return Vec::new(); + }; + let (Some([wa, ha]), Some([wb, hb])) = (a.size, b.size) else { + return Vec::new(); + }; + + let raster_a = rasterize_all(regions_a, wa as i32, ha as i32); + let raster_b = rasterize_all(regions_b, wb as i32, hb as i32); + + let mut tokens: Vec<&String> = + raster_a.keys().chain(raster_b.keys()).collect::>().into_iter().collect(); + tokens.sort(); + + let mut changes = Vec::new(); + for token in tokens { + match (raster_a.get(token), raster_b.get(token)) { + (Some(_), None) => changes.push(RegionChange::Removed { token: token.clone() }), + (None, Some(_)) => changes.push(RegionChange::Added { token: token.clone() }), + (Some(pa), Some(pb)) => { + if pa == pb { + continue; + } + if let Some((dx, dy)) = detect_shift(pa, pb) { + changes.push(RegionChange::Shifted { token: token.clone(), dx, dy }); + } else { + let differing = pa.symmetric_difference(pb).count(); + changes.push(RegionChange::Changed { token: token.clone(), differing }); + } + } + (None, None) => unreachable!(), + } + } + changes +} + +fn rasterize_all( + regions: &HashMap, + width: i32, + height: i32, +) -> HashMap> { + let mut warnings = Vec::new(); + let mut rasterized: HashMap> = HashMap::new(); + let mut pending: Vec<(&String, &RegionDef)> = Vec::new(); + for (token, region) in regions { + if region.fill.is_some() || region.auto_shadow.is_some() { + pending.push((token, region)); + } else { + let pixels = + crate::structured::rasterize_region(region, &rasterized, width, height, &mut warnings); + rasterized.insert(token.clone(), pixels); + } + } + for (token, region) in pending { + let pixels = + crate::structured::rasterize_region(region, &rasterized, width, height, &mut warnings); + rasterized.insert(token.clone(), pixels); + } + rasterized +} + +/// If `b` is exactly `a` translated by a constant offset, return it. +fn detect_shift(a: &HashSet<(i32, i32)>, b: &HashSet<(i32, i32)>) -> Option<(i32, i32)> { + if a.len() != b.len() || a.is_empty() { + return None; + } + let min_a = ( + a.iter().map(|p| p.0).min().expect("non-empty"), + a.iter().map(|p| p.1).min().expect("non-empty"), + ); + let min_b = ( + b.iter().map(|p| p.0).min().expect("non-empty"), + b.iter().map(|p| p.1).min().expect("non-empty"), + ); + let (dx, dy) = (min_b.0 - min_a.0, min_b.1 - min_a.1); + if (dx, dy) == (0, 0) { + return None; // equal sets are handled before this + } + if a.iter().all(|(x, y)| b.contains(&(x + dx, y + dy))) { + Some((dx, dy)) + } else { + None + } } /// Get sprite dimensions from size field @@ -157,6 +273,7 @@ fn get_sprite_dimensions(sprite: &Sprite) -> (u32, u32) { fn generate_summary( dimension_change: &Option, palette_changes: &[PaletteChange], + region_changes: &[RegionChange], ) -> String { let mut parts = Vec::new(); @@ -165,6 +282,10 @@ fn generate_summary( .push(format!("Dimensions: {}x{} → {}x{}", dim.old.0, dim.old.1, dim.new.0, dim.new.1)); } + if !region_changes.is_empty() { + parts.push(format!("Regions: {} changed", region_changes.len())); + } + let added_count = palette_changes.iter().filter(|c| matches!(c, PaletteChange::Added { .. })).count(); let removed_count = @@ -259,25 +380,29 @@ pub fn diff_files(path_a: &Path, path_b: &Path) -> Result { // Sprite only in file A (removed) + let note = format!("Sprite '{}' removed in second file", name); diffs.push(( name.clone(), SpriteDiff { dimension_change: None, palette_changes: Vec::new(), - - summary: format!("Sprite '{}' removed in second file", name), + region_changes: Vec::new(), + presence_change: Some(note.clone()), + summary: note, }, )); } (None, Some(_)) => { // Sprite only in file B (added) + let note = format!("Sprite '{}' added in second file", name); diffs.push(( name.clone(), SpriteDiff { dimension_change: None, palette_changes: Vec::new(), - - summary: format!("Sprite '{}' added in second file", name), + region_changes: Vec::new(), + presence_change: Some(note.clone()), + summary: note, }, )); } @@ -295,6 +420,13 @@ pub fn format_diff(name: &str, diff: &SpriteDiff, file_a: &str, file_b: &str) -> output.push(format!("Comparing sprite \"{}\" ({}) vs ({}):", name, file_a, file_b)); output.push(String::new()); + // Presence: the sprite exists in only one file — say that, never + // "No differences found" (which is what this case used to print). + if let Some(note) = &diff.presence_change { + output.push(note.clone()); + return output.join("\n"); + } + // Dimensions if let Some(dim) = &diff.dimension_change { output @@ -306,6 +438,28 @@ pub fn format_diff(name: &str, diff: &SpriteDiff, file_a: &str, file_b: &str) -> output.push("Dimensions: Same".to_string()); } + // Region geometry changes + if !diff.region_changes.is_empty() { + output.push(String::new()); + output.push("Region changes:".to_string()); + for change in &diff.region_changes { + match change { + RegionChange::Added { token } => { + output.push(format!(" + {} (new region)", token)); + } + RegionChange::Removed { token } => { + output.push(format!(" - {} (region gone)", token)); + } + RegionChange::Shifted { token, dx, dy } => { + output.push(format!(" → {} shifted ({}, {})", token, dx, dy)); + } + RegionChange::Changed { token, differing } => { + output.push(format!(" ~ {} shape changed ({} pixel(s) differ)", token, differing)); + } + } + } + } + // Palette changes if !diff.palette_changes.is_empty() { output.push(String::new()); @@ -336,6 +490,67 @@ pub fn format_diff(name: &str, diff: &SpriteDiff, file_a: &str, file_b: &str) -> mod tests { use super::*; + fn sprite_with_regions(name: &str, json_regions: &str) -> Sprite { + let json = format!( + r##"{{"type": "sprite", "name": "{}", "size": [16, 16], + "palette": {{"_": "transparent", "a": "#FF0000", "b": "#00FF00"}}, + "regions": {} }}"##, + name, json_regions + ); + json5::from_str::(&json).expect("test sprite parses") + } + + #[test] + fn test_region_shift_detected() { + // The walk-cycle bob: same shape, one region moved up a pixel. + let a = sprite_with_regions("s", r#"{"a": {"rect": [4, 4, 3, 3]}, "b": {"rect": [0, 0, 2, 2]}}"#); + let b = sprite_with_regions("s", r#"{"a": {"rect": [4, 3, 3, 3]}, "b": {"rect": [0, 0, 2, 2]}}"#); + let palette = HashMap::new(); + let diff = diff_sprites(&a, &b, &palette, &palette); + assert_eq!( + diff.region_changes, + vec![RegionChange::Shifted { token: "a".to_string(), dx: 0, dy: -1 }] + ); + assert!(!diff.is_empty(), "a shifted region is a difference"); + } + + #[test] + fn test_region_shape_change_detected() { + let a = sprite_with_regions("s", r#"{"a": {"rect": [4, 4, 3, 3]}}"#); + let b = sprite_with_regions("s", r#"{"a": {"rect": [4, 4, 3, 2]}}"#); + let palette = HashMap::new(); + let diff = diff_sprites(&a, &b, &palette, &palette); + assert_eq!( + diff.region_changes, + vec![RegionChange::Changed { token: "a".to_string(), differing: 3 }] + ); + } + + #[test] + fn test_region_added_and_removed_detected() { + let a = sprite_with_regions("s", r#"{"a": {"rect": [4, 4, 3, 3]}}"#); + let b = sprite_with_regions("s", r#"{"b": {"rect": [4, 4, 3, 3]}}"#); + let palette = HashMap::new(); + let diff = diff_sprites(&a, &b, &palette, &palette); + assert!(diff.region_changes.contains(&RegionChange::Added { token: "b".to_string() })); + assert!(diff.region_changes.contains(&RegionChange::Removed { token: "a".to_string() })); + } + + #[test] + fn test_presence_diff_never_says_no_differences() { + let diff = SpriteDiff { + dimension_change: None, + palette_changes: Vec::new(), + region_changes: Vec::new(), + presence_change: Some("Sprite 'x' removed in second file".to_string()), + summary: "Sprite 'x' removed in second file".to_string(), + }; + assert!(!diff.is_empty()); + let text = format_diff("x", &diff, "a.pxl", "b.pxl"); + assert!(text.contains("removed in second file"), "got: {}", text); + assert!(!text.contains("No differences"), "got: {}", text); + } + fn make_sprite(name: &str, palette: HashMap, grid: Vec<&str>) -> Sprite { // Compute dimensions from grid (for backwards compatibility in tests) let height = grid.len() as u32; From 23a3afb66549ce9671a7413c16fa49145d82892c Mon Sep 17 00:00:00 2001 From: Daniel P Date: Wed, 10 Jun 2026 18:19:39 -0400 Subject: [PATCH 04/10] feat: sprite `extends` inherits and patches regions at the key level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Animation frame-sets share most of their regions and differ in only a few — yet today every frame is a full copy, which is verbose and the source of frame-tear bugs (one copy drifts a pixel and the sprite rips). The live case: hearthfire's four distill frames quadruplicate an identical stone ring around a flame that is all that actually moves. `extends: "base"` resolves the base sprite's region map, then patches it: each key in this sprite's `regions` replaces the inherited region of that name (or adds a new one), and `remove: [..]` deletes inherited keys. An override carries its own z/role, so it is free to re-stack itself without disturbing the other regions. The base palette and size are inherited; `palette` may be omitted on an extending sprite (it is still required on every other sprite — the validator enforces that) and, when present, overrides inherited tokens key-by-key like a variant. Chains resolve base-first. This is distinct from `source`, which copies the rendered image and can only apply whole-image transforms — `extends` merges at the region level. Resolution lives in SpriteRegistry; the single-sprite render path and the `mask` inspector both resolve inheritance so `pxl render`/`pxl mask` show the merged sprite. Validator: omitted-palette-without-extends and remove-without-extends are flagged; `missing_palette` moves from a parse error to a validation error (an omitted palette now parses). Docs: region-inheritance section in format/sprite.md with the extends-vs-source table. Tests: registry resolution (override/add/remove, chain, missing-base, remove-missing), a render golden, a mask-resolution test, and two validator cases. Suite: lib 1857 passed / 1 pre-existing unrelated fail (install::tests::test_install_skips_cached); aggregate 2618 passed. Co-Authored-By: Claude Fable 5 --- docs/book/src/format/sprite.md | 71 ++++++++++++++++ src/cli/mask.rs | 5 +- src/cli/render.rs | 12 +-- src/mask.rs | 85 +++++++++++++++++++ src/models/sprite.rs | 18 ++++ src/parser.rs | 3 + src/registry/mod.rs | 147 +++++++++++++++++++++++++++++++++ src/registry/sprite.rs | 116 ++++++++++++++++++++++---- src/structured.rs | 31 +++++++ src/validate.rs | 76 ++++++++++++++++- 10 files changed, 536 insertions(+), 28 deletions(-) diff --git a/docs/book/src/format/sprite.md b/docs/book/src/format/sprite.md index 1a9c2fe..cb1b5ff 100644 --- a/docs/book/src/format/sprite.md +++ b/docs/book/src/format/sprite.md @@ -243,6 +243,77 @@ See [Transforms](transforms.md#op-style-transforms-derived-sprites) for the full > **Note:** For animated transforms (in keyframes), use CSS transform strings instead. See [Animation](animation.md). +## Region Inheritance (`extends`) + +`extends` builds a sprite from another sprite's regions, then patches them +key-by-key. It is the right tool for **animation frame-sets**, where most +regions are identical across frames and only a few change — instead of copying +every region into every frame (and risking a frame-tear when one copy drifts), +each frame inherits the shared regions and overrides only what moves. + +```json5 +// Base frame: defines the full sprite. +{ + type: "sprite", + name: "fire_a", + size: [16, 32], + palette: "fire", + regions: { + ring: { rect: [3, 26, 10, 4], z: 0 }, // stone ring — shared by every frame + flame: { rect: [7, 14, 2, 12], z: 1 }, + }, +} + +// Next frame: inherits `ring` and the palette; overrides only `flame`. +{ + type: "sprite", + name: "fire_b", + extends: "fire_a", + regions: { + flame: { rect: [8, 12, 2, 14], z: 1 }, + }, +} +``` + +### Fields + +| Field | Description | +|-------|-------------| +| `extends` | Name of the base sprite to inherit regions, palette, and size from | +| `regions` | Region patches: each key **replaces** the inherited region of that name (or **adds** a new one if the key is not inherited) | +| `remove` | List of inherited region keys to **delete** | + +### Semantics + +- **Override** — a key in `regions` that the base also defines replaces the + whole region definition. Its `z` and `role` come from the override, not the + inherited region, so an override is free to change its own z-order. (Other + regions keep theirs; nothing else re-stacks.) +- **Add** — a key not present in the base is simply inserted at its declared `z`. +- **Remove** — `remove: ["key"]` deletes an inherited region. Removing a key the + base doesn't define is a warning, not an error. +- **Palette** — the base's resolved palette is inherited. You may omit `palette` + entirely (it is required on every *non-extending* sprite); if you do declare + one, its colors override inherited tokens key-by-key, exactly like a + [variant](variant.md). +- **Size** — inherited from the base unless the extending sprite declares its own. +- **Chaining** — `extends` may point at another extending sprite; the chain + resolves base-first, so the deepest override of a key wins. + +### `extends` vs. `source` + +Both inherit from another sprite, but they operate at different levels: + +| | `source` | `extends` | +|---|----------|-----------| +| Inherits | The base sprite's **rendered image** | The base sprite's **region map** | +| Can change individual regions? | No — only whole-image `transform`s | Yes — override/add/remove by key | +| Use for | Mirrored / rotated / filtered variants | Animation frames that share most regions | + +Inspect the resolved result of an extending sprite the same way as any sprite — +`pxl mask --sprite fire_b` shows the merged token grid, and +`pxl render --sprite fire_b` renders the inherited + overridden regions together. + ## Complete Example ```json5 diff --git a/src/cli/mask.rs b/src/cli/mask.rs index 42afe68..3b17579 100644 --- a/src/cli/mask.rs +++ b/src/cli/mask.rs @@ -96,10 +96,11 @@ pub fn run_mask( } }; - let sprite = pipeline.sprite().unwrap(); + // Resolve `extends`/`source` inheritance so queries see the rendered sprite. + let sprite = pipeline.resolved_sprite().unwrap(); // Extract token grid - let grid = match TokenGrid::from_sprite(sprite) { + let grid = match TokenGrid::from_sprite(&sprite) { Ok(g) => g, Err(e) => { eprintln!("Error: {}", e); diff --git a/src/cli/render.rs b/src/cli/render.rs index d322226..2655c35 100644 --- a/src/cli/render.rs +++ b/src/cli/render.rs @@ -397,9 +397,11 @@ pub fn run_render( resolved.palette }; - // Get regions from resolved source if sprite has a source reference - // This is critical for derived sprites that reference a regions-based source - let resolved_regions = if sprite.source.is_some() { + // Get regions from the resolved sprite when it inherits from another + // (via `source` wholesale-copy or `extends` region-merge); the + // palette was already extracted above. + let inherits = sprite.source.is_some() || sprite.extends.is_some(); + let resolved_regions = if inherits { // Need to re-resolve to get the regions (palette was already extracted above) match sprite_registry.resolve(&sprite.name, registry, false) { Ok(r) => r.regions, @@ -413,8 +415,8 @@ pub fn run_render( let render_sprite_data = crate::registry::ResolvedSprite { name: sprite.name.clone(), size: sprite.size.or_else(|| { - // For derived sprites, get size from resolved source - if sprite.source.is_some() { + // For inheriting sprites, get size from the resolved base + if inherits { sprite_registry .resolve(&sprite.name, registry, false) .ok() diff --git a/src/mask.rs b/src/mask.rs index 87cd958..3d41cc4 100644 --- a/src/mask.rs +++ b/src/mask.rs @@ -64,6 +64,29 @@ impl MaskPipeline { }) } + /// Get the target sprite with inherited `extends`/`source` regions and size + /// resolved, so queries see what the renderer renders. Returns the raw + /// sprite unchanged when it inherits nothing. + pub fn resolved_sprite(&self) -> Option { + let target = self.sprite()?; + if target.extends.is_none() && target.source.is_none() { + return Some(target.clone()); + } + let by_name: HashMap<&str, &Sprite> = self + .objects + .iter() + .filter_map(|obj| match obj { + TtpObject::Sprite(s) => Some((s.name.as_str(), s)), + _ => None, + }) + .collect(); + let (regions, size) = resolve_inherited(target, &by_name, &mut Vec::new()); + let mut resolved = target.clone(); + resolved.regions = regions; + resolved.size = target.size.or(size); + Some(resolved) + } + /// Get all sprite names in the file. pub fn sprite_names(&self) -> Vec<&str> { self.objects @@ -81,6 +104,49 @@ impl MaskPipeline { } } +/// Resolve a sprite's effective regions and size by walking its `extends` +/// (region-level merge) or `source` (wholesale copy) chain. Mirrors the +/// SpriteRegistry resolution but without palettes, for read-only inspection. +/// Cycles are broken silently (returns what was resolved so far). +fn resolve_inherited( + sprite: &Sprite, + by_name: &HashMap<&str, &Sprite>, + visited: &mut Vec, +) -> (Option>, Option<[u32; 2]>) { + if visited.contains(&sprite.name) { + return (sprite.regions.clone(), sprite.size); + } + visited.push(sprite.name.clone()); + + if let Some(base_name) = &sprite.extends { + if let Some(base) = by_name.get(base_name.as_str()) { + let (base_regions, base_size) = resolve_inherited(base, by_name, visited); + let mut merged = base_regions.unwrap_or_default(); + if let Some(remove_keys) = &sprite.remove { + for key in remove_keys { + merged.remove(key); + } + } + if let Some(own) = &sprite.regions { + for (key, def) in own { + merged.insert(key.clone(), def.clone()); + } + } + return (Some(merged), sprite.size.or(base_size)); + } + return (sprite.regions.clone(), sprite.size); + } + + if let Some(source_name) = &sprite.source { + if let Some(source) = by_name.get(source_name.as_str()) { + let (source_regions, source_size) = resolve_inherited(source, by_name, visited); + return (source_regions, sprite.size.or(source_size)); + } + } + + (sprite.regions.clone(), sprite.size) +} + /// A 2D token grid extracted from a sprite's regions. /// /// Each cell contains the token name (e.g. "skin", "eye", "_") at that position. @@ -398,6 +464,25 @@ mod tests { assert!(result.is_err()); } + const EXTENDS_FRAMES: &str = r##"{"type": "palette", "name": "p", "colors": {"_": "#0000", "ring": "#888", "flame": "#E25822"}} +{"type": "sprite", "name": "frame_a", "size": [8, 8], "palette": "p", "regions": {"ring": {"rect": [1, 5, 6, 2], "z": 0}, "flame": {"rect": [3, 1, 2, 4], "z": 1}}} +{"type": "sprite", "name": "frame_b", "extends": "frame_a", "regions": {"flame": {"rect": [4, 0, 2, 5], "z": 1}}}"##; + + #[test] + fn test_mask_resolves_extends() { + // An `extends` frame inherits its size and unoverridden regions; the + // token grid must reflect the resolved sprite, not the sparse override. + let pipeline = MaskPipeline::load_from_string(EXTENDS_FRAMES, Some("frame_b")).unwrap(); + let sprite = pipeline.resolved_sprite().unwrap(); + assert_eq!(sprite.size, Some([8, 8])); // inherited + let grid = TokenGrid::from_sprite(&sprite).unwrap(); + // Inherited ring still present. + assert_eq!(grid.grid[5][1], "ring"); + // Overridden flame at its new position; old column cleared. + assert_eq!(grid.grid[0][4], "flame"); + assert_eq!(grid.grid[1][3], "_"); + } + #[test] fn test_mask_pipeline_sprite_names() { let pipeline = MaskPipeline::load_from_string(TWO_SPRITES, None).unwrap(); diff --git a/src/models/sprite.rs b/src/models/sprite.rs index 07ba9e2..140ba47 100644 --- a/src/models/sprite.rs +++ b/src/models/sprite.rs @@ -94,10 +94,28 @@ pub struct Sprite { pub name: String, #[serde(skip_serializing_if = "Option::is_none", default)] pub size: Option<[u32; 2]>, + /// The sprite's palette. May be omitted (defaults to an empty Named ref) + /// only when `extends` is set, in which case the base sprite's palette is + /// inherited; the validator flags an omitted palette on any other sprite. + #[serde(default)] pub palette: PaletteRef, /// Reference to another sprite by name (mutually exclusive with `regions`) #[serde(skip_serializing_if = "Option::is_none", default)] pub source: Option, + /// Inherit a base sprite's regions, palette, and size, then override/add + /// individual regions via `regions` and delete inherited ones via `remove`. + /// + /// Unlike `source` (which copies the base wholesale and can only apply + /// whole-image `transform`s), `extends` merges at the region level: the + /// base's regions are the starting map, `regions` here patch it key-by-key + /// (replacing the whole `RegionDef` — including its `z`/`role`), and + /// `remove` deletes inherited keys. Built for animation frame-sets where + /// most regions are shared and only a few differ per frame. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub extends: Option, + /// Inherited region keys to delete (only meaningful with `extends`). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub remove: Option>, /// Structured regions for rendering #[serde(skip_serializing_if = "Option::is_none", default)] pub regions: Option>, diff --git a/src/parser.rs b/src/parser.rs index 01fc93f..e437458 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -455,6 +455,9 @@ mod tests { "invalid_color.jsonl", "validate_errors.jsonl", "validate_typo.jsonl", + // An omitted `palette` now parses (it is legal on `extends` sprites); + // a non-extending sprite without one is caught by the validator. + "missing_palette.jsonl", ]; for entry in fs::read_dir(fixtures_dir).unwrap() { diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 167932f..9893b9b 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -1479,4 +1479,151 @@ mod tests { assert!(stored.colors.contains_key("{hair_1}")); assert!(stored.colors.contains_key("{hair+1}")); } + + // --- Sprite `extends` (region-level inheritance) --- + + /// A base frame: a stone `ring` and a `flame`, with an inline palette. + fn extends_base() -> Sprite { + Sprite { + name: "frame_a".to_string(), + size: Some([8, 8]), + palette: PaletteRef::Inline(HashMap::from([ + ("_".to_string(), "#00000000".to_string()), + ("ring".to_string(), "#888888".to_string()), + ("flame".to_string(), "#E25822".to_string()), + ])), + regions: Some(HashMap::from([ + ("ring".to_string(), RegionDef { rect: Some([1, 5, 6, 2]), z: Some(0), ..Default::default() }), + ("flame".to_string(), RegionDef { rect: Some([3, 1, 2, 4]), z: Some(1), ..Default::default() }), + ])), + ..Default::default() + } + } + + #[test] + fn test_extends_inherits_regions_and_palette() { + let mut sprite_registry = SpriteRegistry::new(); + sprite_registry.register_sprite(extends_base()); + // frame_b overrides only `flame`, omits its palette (inherits the base's). + sprite_registry.register_sprite(Sprite { + name: "frame_b".to_string(), + extends: Some("frame_a".to_string()), + regions: Some(HashMap::from([( + "flame".to_string(), + RegionDef { rect: Some([4, 0, 2, 5]), z: Some(1), ..Default::default() }, + )])), + ..Default::default() + }); + + let palette_registry = PaletteRegistry::new(); + let r = sprite_registry.resolve("frame_b", &palette_registry, true).unwrap(); + + // Size inherited from base. + assert_eq!(r.size, Some([8, 8])); + // Palette inherited wholesale. + assert_eq!(r.palette.get("ring"), Some(&"#888888".to_string())); + assert_eq!(r.palette.get("flame"), Some(&"#E25822".to_string())); + + let regions = r.regions.unwrap(); + // `ring` inherited unchanged. + assert_eq!(regions.get("ring").unwrap().rect, Some([1, 5, 6, 2])); + // `flame` replaced by the override. + assert_eq!(regions.get("flame").unwrap().rect, Some([4, 0, 2, 5])); + assert!(r.warnings.is_empty()); + } + + #[test] + fn test_extends_remove_and_add() { + let mut sprite_registry = SpriteRegistry::new(); + sprite_registry.register_sprite(extends_base()); + // Drop `flame`, add a new `spark` region. + sprite_registry.register_sprite(Sprite { + name: "frame_dim".to_string(), + extends: Some("frame_a".to_string()), + remove: Some(vec!["flame".to_string()]), + regions: Some(HashMap::from([( + "spark".to_string(), + RegionDef { points: Some(vec![[2, 2]]), z: Some(2), ..Default::default() }, + )])), + palette: PaletteRef::Inline(HashMap::from([( + "spark".to_string(), + "#FFD700".to_string(), + )])), + ..Default::default() + }); + + let palette_registry = PaletteRegistry::new(); + let r = sprite_registry.resolve("frame_dim", &palette_registry, true).unwrap(); + let regions = r.regions.unwrap(); + assert!(regions.contains_key("ring")); + assert!(!regions.contains_key("flame")); // removed + assert!(regions.contains_key("spark")); // added + // Own palette token merged over the inherited base. + assert_eq!(r.palette.get("spark"), Some(&"#FFD700".to_string())); + assert_eq!(r.palette.get("ring"), Some(&"#888888".to_string())); + } + + #[test] + fn test_extends_unknown_base_strict() { + let mut sprite_registry = SpriteRegistry::new(); + sprite_registry.register_sprite(Sprite { + name: "orphan".to_string(), + extends: Some("ghost".to_string()), + ..Default::default() + }); + let palette_registry = PaletteRegistry::new(); + let result = sprite_registry.resolve("orphan", &palette_registry, true); + match result.unwrap_err() { + SpriteError::ExtendsNotFound { sprite, base } => { + assert_eq!(sprite, "orphan"); + assert_eq!(base, "ghost"); + } + e => panic!("Expected ExtendsNotFound, got {:?}", e), + } + } + + #[test] + fn test_extends_remove_missing_warns_lenient() { + let mut sprite_registry = SpriteRegistry::new(); + sprite_registry.register_sprite(extends_base()); + sprite_registry.register_sprite(Sprite { + name: "frame_c".to_string(), + extends: Some("frame_a".to_string()), + remove: Some(vec!["nonexistent".to_string()]), + ..Default::default() + }); + let palette_registry = PaletteRegistry::new(); + let r = sprite_registry.resolve("frame_c", &palette_registry, false).unwrap(); + assert!(r.warnings.iter().any(|w| w.message.contains("nonexistent"))); + } + + #[test] + fn test_extends_chain_two_levels() { + // a -> b (override flame) -> c (override ring). c sees both overrides. + let mut sprite_registry = SpriteRegistry::new(); + sprite_registry.register_sprite(extends_base()); + sprite_registry.register_sprite(Sprite { + name: "b".to_string(), + extends: Some("frame_a".to_string()), + regions: Some(HashMap::from([( + "flame".to_string(), + RegionDef { rect: Some([4, 0, 2, 5]), z: Some(1), ..Default::default() }, + )])), + ..Default::default() + }); + sprite_registry.register_sprite(Sprite { + name: "c".to_string(), + extends: Some("b".to_string()), + regions: Some(HashMap::from([( + "ring".to_string(), + RegionDef { rect: Some([0, 6, 8, 1]), z: Some(0), ..Default::default() }, + )])), + ..Default::default() + }); + let palette_registry = PaletteRegistry::new(); + let r = sprite_registry.resolve("c", &palette_registry, true).unwrap(); + let regions = r.regions.unwrap(); + assert_eq!(regions.get("flame").unwrap().rect, Some([4, 0, 2, 5])); // from b + assert_eq!(regions.get("ring").unwrap().rect, Some([0, 6, 8, 1])); // from c + } } diff --git a/src/registry/sprite.rs b/src/registry/sprite.rs index e9584c6..7471535 100644 --- a/src/registry/sprite.rs +++ b/src/registry/sprite.rs @@ -21,6 +21,9 @@ pub enum SpriteError { /// Sprite references a source sprite that doesn't exist #[error("Sprite '{sprite}' references unknown source sprite '{source_name}'")] SourceNotFound { sprite: String, source_name: String }, + /// Sprite extends a base sprite that doesn't exist + #[error("Sprite '{sprite}' extends unknown base sprite '{base}'")] + ExtendsNotFound { sprite: String, base: String }, /// Circular reference detected during source resolution #[error("Circular reference detected for sprite '{sprite}': {}", chain.join(" -> "))] CircularReference { sprite: String, chain: Vec }, @@ -50,6 +53,19 @@ impl SpriteWarning { } } + pub fn extends_not_found(sprite: &str, base: &str) -> Self { + Self { message: format!("Sprite '{}' extends unknown base sprite '{}'", sprite, base) } + } + + pub fn remove_missing(sprite: &str, region: &str) -> Self { + Self { + message: format!( + "Sprite '{}' removes region '{}' that the base sprite does not define", + sprite, region + ), + } + } + pub fn transform_error(sprite: &str, message: &str) -> Self { Self { message: format!("Transform error for sprite '{}': {}", sprite, message) } } @@ -201,8 +217,57 @@ impl SpriteRegistry { // Mark as visited visited.push(sprite.name.clone()); - // Resolve source sprite's regions and size if this sprite references another - let (base_regions, base_size) = if let Some(source_name) = &sprite.source { + // Resolve inherited regions, size, and palette. + // + // `extends` merges at the region level (inherit base map, patch by key, + // delete via `remove`) and inherits the base palette; `source` copies + // the base regions wholesale (region patching is not available there). + // The two are mutually exclusive — `extends` wins if both are set. + let mut inherited_palette: Option> = None; + let (base_regions, base_size) = if let Some(base_name) = &sprite.extends { + match self.sprites.get(base_name) { + Some(base_sprite) => { + let base_resolved = self.resolve_sprite_internal( + base_sprite, + palette_registry, + strict, + visited, + )?; + warnings.extend(base_resolved.warnings); + + // Start from the base's regions, then apply this sprite's patches. + let mut merged = base_resolved.regions.clone().unwrap_or_default(); + if let Some(remove_keys) = &sprite.remove { + for key in remove_keys { + if merged.remove(key).is_none() { + warnings.push(SpriteWarning::remove_missing(&sprite.name, key)); + } + } + } + if let Some(own) = &sprite.regions { + for (key, def) in own { + // Overriding a key replaces the whole RegionDef, so + // its own z/role determines z-order; a brand-new key + // is simply added at its declared z. + merged.insert(key.clone(), def.clone()); + } + } + inherited_palette = Some(base_resolved.palette); + (Some(merged), base_resolved.size) + } + None => { + if strict { + return Err(SpriteError::ExtendsNotFound { + sprite: sprite.name.clone(), + base: base_name.clone(), + }); + } else { + warnings.push(SpriteWarning::extends_not_found(&sprite.name, base_name)); + (sprite.regions.clone(), None) + } + } + } + } else if let Some(source_name) = &sprite.source { match self.sprites.get(source_name) { Some(source_sprite) => { let source_resolved = self.resolve_sprite_internal( @@ -230,28 +295,45 @@ impl SpriteRegistry { (sprite.regions.clone(), None) }; - // Resolve the sprite's palette - let palette = match palette_registry.resolve(sprite, strict) { - Ok(result) => { - if let Some(warning) = result.warning { - warnings.push(SpriteWarning { message: warning.message }); + // Resolve the sprite's own palette. An `extends` sprite may omit its + // `palette` entirely (an empty Named ref), inheriting the base's; if it + // declares one, those colors override the inherited tokens key-by-key. + let palette_omitted = + matches!(&sprite.palette, crate::models::PaletteRef::Named(n) if n.is_empty()); + let own_palette = if inherited_palette.is_some() && palette_omitted { + HashMap::new() + } else { + match palette_registry.resolve(sprite, strict) { + Ok(result) => { + if let Some(warning) = result.warning { + warnings.push(SpriteWarning { message: warning.message }); + } + result.palette.colors } - result.palette.colors - } - Err(e) => { - // In strict mode, this would have returned an error from resolve() - // In lenient mode, we got a fallback. Map the error for strict. - if strict { - // The resolve() function already handles strict vs lenient - return Err(SpriteError::NotFound(format!("palette error: {}", e))); + Err(e) => { + // In strict mode, this would have returned an error from resolve() + // In lenient mode, we got a fallback. Map the error for strict. + if strict { + // The resolve() function already handles strict vs lenient + return Err(SpriteError::NotFound(format!("palette error: {}", e))); + } + HashMap::new() } - HashMap::new() } }; + let palette = if let Some(mut base_palette) = inherited_palette { + for (token, color) in own_palette { + base_palette.insert(token, color); + } + base_palette + } else { + own_palette + }; + Ok(ResolvedSprite { name: sprite.name.clone(), - // Use sprite's explicit size, or fall back to source sprite's size + // Use sprite's explicit size, or fall back to the inherited size size: sprite.size.or(base_size), palette, warnings, diff --git a/src/structured.rs b/src/structured.rs index 28ec1f1..50227ca 100644 --- a/src/structured.rs +++ b/src/structured.rs @@ -674,6 +674,37 @@ mod tests { assert_eq!(*image.get_pixel(5, 5), Rgba([0, 0, 0, 255])); } + #[test] + fn test_extends_render_golden() { + // A base frame with a `ring` and a `flame`; an extending frame that + // overrides only `flame`. Rendering the extender must show the + // inherited ring AND the moved flame, proving region-level inheritance + // reaches the pixels. + let base = "{type: \"sprite\", name: \"frame_a\", size: [8, 8], palette: {_: \"#0000\", ring: \"#888888\", flame: \"#E25822\"}, regions: { ring: { rect: [1, 5, 6, 2], z: 0 }, flame: { rect: [3, 1, 2, 4], z: 1 } }}"; + let ext = "{type: \"sprite\", name: \"frame_b\", extends: \"frame_a\", regions: { flame: { rect: [4, 0, 2, 5], z: 1 } }}"; + + let mut sprite_registry = SpriteRegistry::new(); + let palette_registry = PaletteRegistry::new(); + for line in [base, ext] { + match parse_line(line, 0).unwrap() { + crate::models::TtpObject::Sprite(s) => sprite_registry.register_sprite(s), + _ => panic!("expected sprite"), + } + } + + let resolved = sprite_registry.resolve("frame_b", &palette_registry, true).unwrap(); + let (image, warnings) = crate::renderer::render_resolved(&resolved); + assert!(warnings.is_empty(), "Unexpected warnings: {:?}", warnings); + + // Inherited ring at row 5 (e.g. (1,5)) is grey. + assert_eq!(*image.get_pixel(1, 5), Rgba([0x88, 0x88, 0x88, 255])); + // Overridden flame now sits at column 4-5, rows 0-4. + assert_eq!(*image.get_pixel(4, 0), Rgba([0xE2, 0x58, 0x22, 255])); + assert_eq!(*image.get_pixel(5, 4), Rgba([0xE2, 0x58, 0x22, 255])); + // The base flame's old column 3 is now empty (override replaced it). + assert_eq!(*image.get_pixel(3, 1), Rgba([0, 0, 0, 0])); + } + #[test] fn test_default_z_for_role() { // Anchor should be highest diff --git a/src/validate.rs b/src/validate.rs index 00fad03..3b7e738 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -820,11 +820,48 @@ impl Validator { ); } - // Get palette tokens for validation - let palette_tokens = self.get_palette_tokens(&sprite.palette, line_number, name); + // An omitted palette (empty Named ref) is only legal when `extends` is + // set — the base palette is inherited. On any other sprite it's a + // missing-palette error. Skip token resolution in the inherited case so + // we don't spuriously flag inherited tokens as undefined. + let palette_omitted = + matches!(&sprite.palette, PaletteRef::Named(n) if n.is_empty()); + if palette_omitted && sprite.extends.is_none() { + self.issues.push( + ValidationIssue::warning( + line_number, + IssueType::MissingPalette, + format!("Sprite \"{}\" has no palette", name), + ) + .with_context(format!("sprite \"{}\"", name)) + .with_suggestion( + "add a `palette` reference, or `extends` a sprite to inherit one".to_string(), + ), + ); + } + + // `remove` only means something alongside `extends`. + if sprite.remove.is_some() && sprite.extends.is_none() { + self.issues.push( + ValidationIssue::warning( + line_number, + IssueType::EmptyGrid, + format!("Sprite \"{}\" uses `remove` without `extends`; it has no effect", name), + ) + .with_context(format!("sprite \"{}\"", name)), + ); + } + + // Get palette tokens for validation (skipped when inheriting a palette) + let palette_tokens = if palette_omitted { + None + } else { + self.get_palette_tokens(&sprite.palette, line_number, name) + }; - // Validate sprites have regions defined (unless they reference a source sprite) - if sprite.regions.is_none() && sprite.source.is_none() { + // Validate sprites have regions defined (unless they inherit them from + // another sprite via `source` or `extends`) + if sprite.regions.is_none() && sprite.source.is_none() && sprite.extends.is_none() { self.issues.push( ValidationIssue::warning( line_number, @@ -1668,6 +1705,37 @@ mod tests { assert_eq!(missing_palette_issues.len(), 1); } + #[test] + fn test_validate_omitted_palette_without_extends_warns() { + let mut validator = Validator::new(); + validator.validate_line( + 1, + r#"{"type": "sprite", "name": "test", "size": [4, 4], "regions": {"a": {"rect": [0, 0, 4, 4]}}}"#, + ); + let missing: Vec<_> = validator + .issues() + .iter() + .filter(|i| i.issue_type == IssueType::MissingPalette) + .collect(); + assert_eq!(missing.len(), 1); + } + + #[test] + fn test_validate_omitted_palette_with_extends_ok() { + // An `extends` sprite may omit its palette — it inherits the base's. + let mut validator = Validator::new(); + validator.validate_line( + 1, + r#"{"type": "sprite", "name": "frame_b", "extends": "frame_a", "regions": {"flame": {"rect": [0, 0, 2, 2]}}}"#, + ); + let missing: Vec<_> = validator + .issues() + .iter() + .filter(|i| i.issue_type == IssueType::MissingPalette) + .collect(); + assert!(missing.is_empty(), "extends sprite should not warn on omitted palette"); + } + #[test] fn test_validate_inline_palette() { let mut validator = Validator::new(); From dd92f8323316a99e67e698455e202110d13e29ef Mon Sep 17 00:00:00 2001 From: Daniel P Date: Wed, 10 Jun 2026 18:28:38 -0400 Subject: [PATCH 05/10] feat: bake particle systems to frames with `pxl render --frames` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ParticleEngine::generate_frames` has always been real, seeded, and deterministic, but the CLI dropped `type: "particle"` on the floor as a "runtime construct" — so authoring an ambient effect meant hand-tracking motes across frames by eye (the embers in the hearthfire rework were placed by hand for want of this). `pxl render --frames N` now simulates the emitter and writes a frame-set: a numbered PNG per frame (`{name}_NN.png`, the validator's frame-set convention) or, with `--gif`, one animated GIF. `--canvas WxH` sizes the output (default 32x32), `--origin X,Y` places the emitter (default bottom-center), `--fps` sets the GIF rate, and `--sprite` picks one system when a file declares several. The emitted sprite is resolved through the normal registry path, so it can use `extends`, transforms, or any palette. Because the engine is seeded, re-baking reproduces byte-identical frames — safe to commit and diff. A missing/unknown particle sprite, a zero-frame request, or a particle-less file all error clearly instead of producing nothing. Docs: new format/particle.md documents the (previously undocumented) particle format and the baking flags; linked in SUMMARY. Tests: a CLI integration suite covering PNG frames, GIF, determinism, and the no-particle error. Suite: aggregate 2622 passed / 1 pre-existing unrelated fail (install::tests::test_install_skips_cached). Co-Authored-By: Claude Fable 5 --- docs/book/src/SUMMARY.md | 1 + docs/book/src/format/particle.md | 79 +++++++++++ src/cli/mod.rs | 25 ++++ src/cli/render.rs | 228 ++++++++++++++++++++++++++++++- tests/cli_particle_bake.rs | 136 ++++++++++++++++++ 5 files changed, 467 insertions(+), 2 deletions(-) create mode 100644 docs/book/src/format/particle.md create mode 100644 tests/cli_particle_bake.rs diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index 0869c48..0e2d686 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -35,6 +35,7 @@ - [Animation](format/animation.md) - [Variant](format/variant.md) - [Composition](format/composition.md) +- [Particle Systems](format/particle.md) - [Transforms](format/transforms.md) - [Includes](format/includes.md) diff --git a/docs/book/src/format/particle.md b/docs/book/src/format/particle.md new file mode 100644 index 0000000..9bccf5e --- /dev/null +++ b/docs/book/src/format/particle.md @@ -0,0 +1,79 @@ +# Particle Systems + +A particle system emits copies of a sprite with randomized, physically-simulated +motion — sparks, embers, dust, rain, snow. The simulation is **seeded and +deterministic**: the same definition always produces the same frames, so a +particle effect can be *baked* to a fixed frame-set and shipped like any other +animation. + +## Basic Syntax + +```json5 +{ + type: "particle", + name: "embers", + sprite: "ember", // the sprite emitted as each particle + emitter: { + rate: 1.5, // particles spawned per frame (fractional accumulates) + lifetime: [6, 10], // frames each particle lives, [min, max] + velocity: { x: [-0.4, 0.4], y: [-1.2, -0.6] }, // initial velocity range + gravity: -0.02, // per-frame acceleration on vy (negative = rises) + fade: true, // fade alpha to zero over the particle's lifetime + rotation: [0, 0], // rotation range in degrees (optional) + seed: 7, // RNG seed — fix it for reproducible bakes + }, +} +``` + +## Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `type` | Yes | Must be `"particle"` | +| `name` | Yes | Unique identifier (and the base name of the baked frame-set) | +| `sprite` | Yes | Name of the sprite to emit as each particle | +| `emitter` | Yes | Emitter configuration (below) | + +### Emitter + +| Field | Default | Description | +|-------|---------|-------------| +| `rate` | `1.0` | Particles emitted per frame; fractional rates accumulate across frames | +| `lifetime` | `[10, 20]` | Per-particle lifetime in frames, `[min, max]` | +| `velocity` | none | Initial velocity range `{ x: [min, max], y: [min, max] }` | +| `gravity` | `0` | Acceleration added to `vy` each frame (negative rises, positive falls) | +| `fade` | `false` | If true, alpha fades linearly to zero over the lifetime | +| `rotation` | `[0, 0]` | Rotation range in degrees | +| `seed` | `42` | RNG seed — **set this** so bakes are reproducible | + +## Baking to Frames + +Particle systems are runtime constructs, so `pxl render` does **not** draw them +statically by default. Pass `--frames N` to simulate the emitter and bake `N` +frames: + +```bash +# A numbered PNG per frame: embers_00.png … embers_11.png +pxl render embers.pxl --frames 12 --canvas 16x32 --origin 8,31 -o frames/ + +# A single animated GIF instead +pxl render embers.pxl --frames 12 --canvas 16x32 --gif --fps 12 -o embers.gif +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--frames N` | — | Bake `N` frames (required to render a particle system) | +| `--canvas WxH` | `32x32` | Output canvas size | +| `--origin X,Y` | bottom-center | Emitter origin on the canvas | +| `--gif` | off | Emit one animated GIF instead of numbered PNGs | +| `--fps N` | `10` | GIF playback rate | +| `--sprite NAME` | all | Bake only the named particle system | + +The PNG output follows the frame-set naming convention `{name}_NN.png`, so a +baked particle drops straight into an [animation](animation.md) `frames` array or +any pipeline that consumes numbered frames. Because the simulation is seeded, +re-baking the same file reproduces byte-identical frames — safe to commit and +diff. + +> **Tip:** the emitted `sprite` is a normal sprite, so it can itself use +> [`extends`](sprite.md#region-inheritance-extends), transforms, or any palette. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 58cbaa8..dbb0f47 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -158,6 +158,23 @@ pub enum Commands { /// Disable project context detection (skip pxl.toml lookup) #[arg(long)] no_project: bool, + + /// Bake a particle system to this many frames (PNG per frame, or a GIF + /// with --gif). Selects the particle by --sprite, or bakes all. + #[arg(long)] + frames: Option, + + /// Canvas size for particle baking, "WxH" (default: 32x32) + #[arg(long)] + canvas: Option, + + /// Emitter origin for particle baking, "X,Y" (default: bottom-center) + #[arg(long)] + origin: Option, + + /// Frames per second for a baked particle GIF (default: 10) + #[arg(long)] + fps: Option, }, /// Import a PNG image and convert to Pixelsrc format Import { @@ -605,6 +622,10 @@ pub fn run() -> ExitCode { no_semantic_aa, gradient_shadows, no_project, + frames, + canvas, + origin, + fps, } => render::run_render( &input, output.as_deref(), @@ -627,6 +648,10 @@ pub fn run() -> ExitCode { no_semantic_aa, gradient_shadows, no_project, + frames, + canvas.as_deref(), + origin.as_deref(), + fps, ), Commands::Import { input, diff --git a/src/cli/render.rs b/src/cli/render.rs index 2655c35..b7961b2 100644 --- a/src/cli/render.rs +++ b/src/cli/render.rs @@ -48,6 +48,10 @@ pub fn run_render( _no_semantic_aa: bool, _gradient_shadows: bool, no_project: bool, + frames: Option, + canvas: Option<&str>, + origin: Option<&str>, + fps: Option, ) -> ExitCode { // Parse nine-slice target size if provided let nine_slice_size = if let Some(size_str) = nine_slice_arg { @@ -167,6 +171,7 @@ pub fn run_render( let mut sprites_by_name: HashMap = HashMap::new(); let mut animations_by_name: HashMap = HashMap::new(); let mut compositions_by_name: HashMap = HashMap::new(); + let mut particles_by_name: HashMap = HashMap::new(); for obj in parse_result.objects { match obj { @@ -220,8 +225,10 @@ pub fn run_render( // Register variant with sprite registry for transform resolution local_sprite_registry.register_variant(variant); } - TtpObject::Particle(_) => { - // Particle systems are runtime constructs, not rendered statically + TtpObject::Particle(particle) => { + // Particles bake to frames on demand (`--frames`); otherwise + // they are runtime constructs and not rendered statically. + particles_by_name.insert(particle.name.clone(), particle); } TtpObject::Transform(_) => { // User-defined transforms are stored in transform registry @@ -248,6 +255,29 @@ pub fn run_render( // Track visited files for circular include detection let mut include_visited: HashSet = HashSet::new(); + // Handle particle baking (`--frames N`): simulate the deterministic + // particle engine and write a frame-set (PNG per frame, or a GIF). + if let Some(frame_count) = frames { + return run_particle_bake( + input, + output, + frame_count, + canvas, + origin, + fps, + &particles_by_name, + &sprites_by_name, + sprite_registry, + registry, + input_dir, + &mut include_visited, + sprite_filter, + strict, + scale, + gif_output, + ); + } + // Handle animation rendering (--gif or --spritesheet) if gif_output || spritesheet_output { return run_animation_render( @@ -585,6 +615,200 @@ pub fn run_render( ExitCode::from(EXIT_SUCCESS) } +/// Bake a particle system to a frame-set. +/// +/// The particle engine (`ParticleEngine::generate_frames`) is seeded and +/// deterministic, so the same input always yields the same frames. Writes a +/// numbered PNG per frame (`{name}_NN.png`, the validator's frame-set +/// convention) or, with `--gif`, a single animated GIF. +#[allow(clippy::too_many_arguments)] +fn run_particle_bake( + input: &std::path::Path, + output: Option<&std::path::Path>, + frame_count: u32, + canvas: Option<&str>, + origin: Option<&str>, + fps: Option, + particles: &HashMap, + sprites: &HashMap, + sprite_registry: &SpriteRegistry, + palette_registry: &PaletteRegistry, + _input_dir: &std::path::Path, + _include_visited: &mut HashSet, + particle_filter: Option<&str>, + strict: bool, + scale: u8, + gif_output: bool, +) -> ExitCode { + if particles.is_empty() { + eprintln!("Error: --frames given but no particle systems found in input file"); + return ExitCode::from(EXIT_ERROR); + } + if frame_count == 0 { + eprintln!("Error: --frames must be at least 1"); + return ExitCode::from(EXIT_INVALID_ARGS); + } + + // Canvas size (default 32x32) and emitter origin (default bottom-center). + let (cw, ch) = match canvas { + Some(s) => match parse_wxh(s) { + Ok(v) => v, + Err(e) => { + eprintln!("Error: --canvas {}", e); + return ExitCode::from(EXIT_INVALID_ARGS); + } + }, + None => (32, 32), + }; + let (ox, oy) = match origin { + Some(s) => match parse_xy(s) { + Ok(v) => v, + Err(e) => { + eprintln!("Error: --origin {}", e); + return ExitCode::from(EXIT_INVALID_ARGS); + } + }, + None => (cw as i32 / 2, ch as i32 - 1), + }; + + let fps = fps.unwrap_or(10).max(1); + let duration_ms = (1000.0 / fps as f64).round() as u32; + + // Select which particle systems to bake. + let selected: Vec<&crate::models::Particle> = match particle_filter { + Some(name) => match particles.get(name) { + Some(p) => vec![p], + None => { + eprintln!("Error: no particle system named '{}' in input", name); + let names: Vec<&str> = particles.keys().map(|s| s.as_str()).collect(); + if let Some(suggestion) = format_suggestion(&suggest(name, &names, 3)) { + eprintln!("{}", suggestion); + } + return ExitCode::from(EXIT_ERROR); + } + }, + None => particles.values().collect(), + }; + + for particle in selected { + // Render the particle's referenced sprite to an image to emit. + if !sprites.contains_key(&particle.sprite) && !sprite_registry.contains(&particle.sprite) { + eprintln!( + "Error: particle '{}' references unknown sprite '{}'", + particle.name, particle.sprite + ); + return ExitCode::from(EXIT_ERROR); + } + let resolved = match sprite_registry.resolve(&particle.sprite, palette_registry, strict) { + Ok(r) => { + for w in &r.warnings { + eprintln!("Warning: particle '{}': {}", particle.name, w.message); + } + r + } + Err(e) => { + eprintln!("Error: particle '{}': {}", particle.name, e); + return ExitCode::from(EXIT_ERROR); + } + }; + let (sprite_image, render_warnings) = render_resolved(&resolved); + if strict && !render_warnings.is_empty() { + for w in &render_warnings { + eprintln!("Error: particle '{}': {}", particle.name, w.message); + } + return ExitCode::from(EXIT_ERROR); + } + + let mut engine = crate::particle::ParticleEngine::new(particle, &sprite_image); + let raw_frames = engine.generate_frames(frame_count, [cw, ch], [ox, oy]); + + if gif_output { + let gif_frames: Vec<_> = + raw_frames.into_iter().map(|f| scale_image(f, scale)).collect(); + let path = particle_output_path(input, output, &particle.name, None, "gif"); + if let Err(e) = render_gif(&gif_frames, duration_ms, true, &path) { + eprintln!("Error: Failed to save '{}': {}", path.display(), e); + return ExitCode::from(EXIT_ERROR); + } + println!("Saved: {} ({} frames @ {} fps)", path.display(), frame_count, fps); + } else { + for (i, frame) in raw_frames.into_iter().enumerate() { + let img = scale_image(frame, scale); + let path = particle_output_path(input, output, &particle.name, Some(i as u32), "png"); + if let Err(e) = save_png(&img, &path) { + eprintln!("Error: Failed to save '{}': {}", path.display(), e); + return ExitCode::from(EXIT_ERROR); + } + println!("Saved: {}", path.display()); + } + } + } + + ExitCode::from(EXIT_SUCCESS) +} + +/// Parse a "WxH" string into a (width, height) pair. +fn parse_wxh(s: &str) -> Result<(u32, u32), String> { + let parts: Vec<&str> = s.split('x').collect(); + if parts.len() != 2 { + return Err(format!("invalid size '{}'; use WxH (e.g. 16x32)", s)); + } + match (parts[0].trim().parse::(), parts[1].trim().parse::()) { + (Ok(w), Ok(h)) if w > 0 && h > 0 => Ok((w, h)), + _ => Err(format!("invalid size '{}'; width and height must be positive integers", s)), + } +} + +/// Parse an "X,Y" string into an (x, y) coordinate pair. +fn parse_xy(s: &str) -> Result<(i32, i32), String> { + let parts: Vec<&str> = s.split(',').collect(); + if parts.len() != 2 { + return Err(format!("invalid coordinate '{}'; use X,Y (e.g. 8,31)", s)); + } + match (parts[0].trim().parse::(), parts[1].trim().parse::()) { + (Ok(x), Ok(y)) => Ok((x, y)), + _ => Err(format!("invalid coordinate '{}'; X and Y must be integers", s)), + } +} + +/// Build the output path for a baked particle frame or gif. +/// +/// `frame` is `Some(n)` for a numbered PNG frame (`{name}_NN.png`) or `None` +/// for a single file (`{name}.gif`). Honors an `-o` directory or file stem, +/// else writes alongside the input file. +fn particle_output_path( + input: &std::path::Path, + output: Option<&std::path::Path>, + particle_name: &str, + frame: Option, + ext: &str, +) -> PathBuf { + let file_name = match frame { + Some(n) => format!("{}_{:02}.{}", particle_name, n, ext), + None => format!("{}.{}", particle_name, ext), + }; + match output { + Some(out) => { + let is_dir = out.as_os_str().to_string_lossy().ends_with('/') || out.is_dir(); + if is_dir { + out.join(file_name) + } else if frame.is_none() { + // Single gif: use the given path verbatim. + out.to_path_buf() + } else { + // Numbered frames: use the path's stem as the base name. + let stem = out.file_stem().and_then(|s| s.to_str()).unwrap_or(particle_name); + let parent = out.parent().unwrap_or(std::path::Path::new("")); + parent.join(format!("{}_{:02}.{}", stem, frame.unwrap(), ext)) + } + } + None => { + let parent = input.parent().unwrap_or(std::path::Path::new("")); + parent.join(file_name) + } + } +} + /// Render a specific composition /// TRF-9: Now uses SpriteRegistry for transform support #[allow(clippy::too_many_arguments)] diff --git a/tests/cli_particle_bake.rs b/tests/cli_particle_bake.rs new file mode 100644 index 0000000..e6ea8de --- /dev/null +++ b/tests/cli_particle_bake.rs @@ -0,0 +1,136 @@ +//! CLI integration tests for particle baking (`pxl render --frames`). +//! +//! The particle engine is seeded and deterministic; these tests exercise the +//! CLI plumbing that bakes a particle system to a PNG frame-set or a GIF, and +//! confirm the output is reproducible run-to-run. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn pxl_binary() -> PathBuf { + let release = Path::new("target/release/pxl"); + if release.exists() { + return release.to_path_buf(); + } + let debug = Path::new("target/debug/pxl"); + if debug.exists() { + return debug.to_path_buf(); + } + panic!("pxl binary not found. Run 'cargo build' first."); +} + +const PARTICLE_PXL: &str = r##"{ type: "palette", name: "ember_pal", colors: { _: "transparent", ember: "#FFB23E" } } +{ type: "sprite", name: "ember", size: [2, 2], palette: "ember_pal", regions: { ember: { rect: [0, 0, 2, 2] } } } +{ type: "particle", name: "embers", sprite: "ember", emitter: { rate: 1.5, lifetime: [6, 10], velocity: { x: [-0.4, 0.4], y: [-1.2, -0.6] }, gravity: -0.02, fade: true, seed: 7 } } +"##; + +fn write_input(dir: &Path) -> PathBuf { + let path = dir.join("embers.pxl"); + fs::write(&path, PARTICLE_PXL).unwrap(); + path +} + +fn run_render(args: &[&str]) -> (String, String, bool) { + let output = Command::new(pxl_binary()) + .arg("render") + .args(args) + .output() + .expect("Failed to execute pxl"); + ( + String::from_utf8_lossy(&output.stdout).to_string(), + String::from_utf8_lossy(&output.stderr).to_string(), + output.status.success(), + ) +} + +#[test] +fn test_particle_bake_png_frames() { + let dir = tempfile::tempdir().unwrap(); + let input = write_input(dir.path()); + let out = dir.path().join("frames/"); + fs::create_dir_all(&out).unwrap(); + + let (_stdout, stderr, ok) = run_render(&[ + input.to_str().unwrap(), + "--frames", + "6", + "--canvas", + "16x16", + "--origin", + "8,15", + "-o", + out.to_str().unwrap(), + ]); + assert!(ok, "particle bake should succeed; stderr: {}", stderr); + + // Six numbered PNGs, zero-padded to the frame-set convention. + for n in 0..6 { + let frame = out.join(format!("embers_{:02}.png", n)); + assert!(frame.exists(), "missing frame {}", frame.display()); + } +} + +#[test] +fn test_particle_bake_is_deterministic() { + let dir = tempfile::tempdir().unwrap(); + let input = write_input(dir.path()); + + let bake = |sub: &str| -> Vec { + let out = dir.path().join(sub); + fs::create_dir_all(&out).unwrap(); + let (_o, stderr, ok) = run_render(&[ + input.to_str().unwrap(), + "--frames", + "5", + "--canvas", + "16x16", + "--origin", + "8,15", + "-o", + out.to_str().unwrap(), + ]); + assert!(ok, "bake failed; stderr: {}", stderr); + fs::read(out.join("embers_03.png")).unwrap() + }; + + assert_eq!(bake("a/"), bake("b/"), "same seed must produce identical frames"); +} + +#[test] +fn test_particle_bake_gif() { + let dir = tempfile::tempdir().unwrap(); + let input = write_input(dir.path()); + let gif = dir.path().join("embers.gif"); + + let (stdout, stderr, ok) = run_render(&[ + input.to_str().unwrap(), + "--frames", + "12", + "--canvas", + "16x16", + "--gif", + "-o", + gif.to_str().unwrap(), + ]); + assert!(ok, "gif bake should succeed; stderr: {}", stderr); + assert!(gif.exists(), "gif not written"); + assert!(stdout.contains("12 frames"), "should report frame count"); +} + +#[test] +fn test_particle_bake_no_particle_errors() { + let dir = tempfile::tempdir().unwrap(); + // A sprite-only file: --frames has nothing to bake. + let input = dir.path().join("plain.pxl"); + fs::write( + &input, + r##"{ type: "sprite", name: "dot", size: [2, 2], palette: {_: "#0000", x: "#FFF"}, regions: { x: { rect: [0,0,2,2] } } }"##, + ) + .unwrap(); + + let (_stdout, stderr, ok) = + run_render(&[input.to_str().unwrap(), "--frames", "4"]); + assert!(!ok, "should fail when no particle is present"); + assert!(stderr.contains("no particle systems"), "stderr: {}", stderr); +} From f1497c638b12de310764a7c8086771b4a07aca7a Mon Sep 17 00:00:00 2001 From: Daniel P Date: Wed, 10 Jun 2026 18:36:01 -0400 Subject: [PATCH 06/10] feat: per-region and sprite-level group `translate` (one-line walk bob) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Many animation cycles — a walk bob, a shiver, a float — are only a small offset of a base frame, yet authoring one meant re-deriving every member region's coordinates by hand, which is both verbose and the source of frame-tear bugs (shift fifteen regions, miss one, the sprite rips). Two composable pieces: - A region-level `translate: [dx, dy]` modifier shifts a region's rasterized pixels, applied last (after every shape op, modifier, and `symmetric`). Because it lives in the rasterizer, `render`, `mask`, and the validator all honor it for free. - A sprite-level `translate: { by: [dx, dy], regions: [..] }` shifts a named group (or all regions, if `regions` is omitted) in one line. It expands into per-region `translate` offsets at resolution, composes additively with any a region already carries, and runs after `extends` inheritance — so a passing frame is `extends` the contact frame plus one `translate` line, no coordinate math. Resolution applies the group expansion in SpriteRegistry; the single-sprite render path routes `translate` sprites through resolution, and the `mask` inspector and validator apply it too, so every surface agrees. Docs: group-translation section in format/sprite.md. Tests: rasterizer pixel-shift, group expansion (named subset and all-regions), and mask-resolution. Suite: aggregate 2626 passed / 1 pre-existing unrelated fail (install::tests::test_install_skips_cached). Co-Authored-By: Claude Fable 5 --- docs/book/src/format/sprite.md | 29 +++++++++++++++++++ src/cli/render.rs | 8 ++++-- src/mask.rs | 23 +++++++++++++-- src/models/mod.rs | 5 +++- src/models/region.rs | 7 +++++ src/models/sprite.rs | 31 +++++++++++++++++++++ src/registry/mod.rs | 51 ++++++++++++++++++++++++++++++++++ src/registry/sprite.rs | 11 +++++++- src/structured.rs | 23 +++++++++++++++ src/validate.rs | 11 ++++++++ 10 files changed, 192 insertions(+), 7 deletions(-) diff --git a/docs/book/src/format/sprite.md b/docs/book/src/format/sprite.md index cb1b5ff..9cc7b98 100644 --- a/docs/book/src/format/sprite.md +++ b/docs/book/src/format/sprite.md @@ -314,6 +314,35 @@ Inspect the resolved result of an extending sprite the same way as any sprite `pxl mask --sprite fire_b` shows the merged token grid, and `pxl render --sprite fire_b` renders the inherited + overridden regions together. +## Group Translation (`translate`) + +Many animation cycles — a walk bob, a shiver, a float — are *only* a small +offset of a base frame. `translate` shifts a named set of regions by one offset, +so the whole move is a single line instead of re-deriving every member region's +coordinates (and risking a frame-tear when one is off by a pixel). + +```json5 +// Passing frame of a walk cycle: inherit the contact frame, bob the upper +// body up one pixel. The legs stay planted. +{ + type: "sprite", + name: "walk_pass", + extends: "walk_contact", + translate: { by: [0, -1], regions: ["torso", "head", "arm_l", "arm_r"] }, +} +``` + +| Field | Description | +|-------|-------------| +| `by` | Offset `[dx, dy]` in pixels (negative `dy` moves up) | +| `regions` | Region keys to shift. **Omit to shift every region** in the sprite. | + +`translate` is applied after `extends` inheritance and region merging, and +composes with it: a frame can inherit a base, override a region, *and* shift a +group, all at once. It expands into a per-region `translate` offset (a region +may also carry its own `translate` to shift just itself); the two compose +additively. + ## Complete Example ```json5 diff --git a/src/cli/render.rs b/src/cli/render.rs index b7961b2..263c5c1 100644 --- a/src/cli/render.rs +++ b/src/cli/render.rs @@ -428,9 +428,11 @@ pub fn run_render( }; // Get regions from the resolved sprite when it inherits from another - // (via `source` wholesale-copy or `extends` region-merge); the - // palette was already extracted above. - let inherits = sprite.source.is_some() || sprite.extends.is_some(); + // (via `source` wholesale-copy or `extends` region-merge) or carries + // a sprite-level `translate` group shift; the palette was already + // extracted above. + let inherits = + sprite.source.is_some() || sprite.extends.is_some() || sprite.translate.is_some(); let resolved_regions = if inherits { // Need to re-resolve to get the regions (palette was already extracted above) match sprite_registry.resolve(&sprite.name, registry, false) { diff --git a/src/mask.rs b/src/mask.rs index 3d41cc4..e747861 100644 --- a/src/mask.rs +++ b/src/mask.rs @@ -69,7 +69,7 @@ impl MaskPipeline { /// sprite unchanged when it inherits nothing. pub fn resolved_sprite(&self) -> Option { let target = self.sprite()?; - if target.extends.is_none() && target.source.is_none() { + if target.extends.is_none() && target.source.is_none() && target.translate.is_none() { return Some(target.clone()); } let by_name: HashMap<&str, &Sprite> = self @@ -80,7 +80,12 @@ impl MaskPipeline { _ => None, }) .collect(); - let (regions, size) = resolve_inherited(target, &by_name, &mut Vec::new()); + let (mut regions, size) = resolve_inherited(target, &by_name, &mut Vec::new()); + if let Some(group) = &target.translate { + if let Some(r) = regions.as_mut() { + crate::models::apply_group_translate(r, group); + } + } let mut resolved = target.clone(); resolved.regions = regions; resolved.size = target.size.or(size); @@ -483,6 +488,20 @@ mod tests { assert_eq!(grid.grid[1][3], "_"); } + const TRANSLATE_SPRITE: &str = r##"{"type": "sprite", "name": "bob", "size": [8, 8], "palette": {"a": "#FFF"}, "regions": {"a": {"rect": [2, 2, 2, 2], "z": 0}}, "translate": {"by": [0, -1], "regions": ["a"]}}"##; + + #[test] + fn test_mask_resolves_group_translate() { + // A sprite-level group `translate` shifts the region up one row; the + // token grid must reflect the shift. + let pipeline = MaskPipeline::load_from_string(TRANSLATE_SPRITE, Some("bob")).unwrap(); + let sprite = pipeline.resolved_sprite().unwrap(); + let grid = TokenGrid::from_sprite(&sprite).unwrap(); + // Rect was at rows 2-3; after [0,-1] it sits at rows 1-2. + assert_eq!(grid.grid[1][2], "a"); + assert_eq!(grid.grid[3][2], "_"); + } + #[test] fn test_mask_pipeline_sprite_names() { let pipeline = MaskPipeline::load_from_string(TWO_SPRITES, None).unwrap(); diff --git a/src/models/mod.rs b/src/models/mod.rs index 71683b7..fe7c56c 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -23,7 +23,10 @@ pub use palette::{ }; pub use particle::{Particle, ParticleEmitter, VelocityRange}; pub use region::{JitterSpec, RegionDef}; -pub use sprite::{CollisionBox, FrameMetadata, FrameTag, NineSlice, Sprite, SpriteMetadata}; +pub use sprite::{ + apply_group_translate, CollisionBox, FrameMetadata, FrameTag, GroupTranslate, NineSlice, + Sprite, SpriteMetadata, +}; pub use transform::{ Easing, Keyframe, KeyframeSpec, PropertyKeyframes, TransformDef, TransformSpec, }; diff --git a/src/models/region.rs b/src/models/region.rs index f5c53a5..01d33ca 100644 --- a/src/models/region.rs +++ b/src/models/region.rs @@ -157,6 +157,13 @@ pub struct RegionDef { #[serde(skip_serializing_if = "Option::is_none", default)] pub transform: Option, + /// Shift this region's rasterized pixels by `[dx, dy]`, applied last (after + /// every shape op, modifier, and `symmetric`). Used directly for a region + /// that offsets itself, and as the expansion target of a sprite-level + /// `translate` group shift — the one-line walk-cycle bob. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub translate: Option<[i32; 2]>, + /// Controlled randomness for jitter #[serde(skip_serializing_if = "Option::is_none", default)] pub jitter: Option, diff --git a/src/models/sprite.rs b/src/models/sprite.rs index 140ba47..d698661 100644 --- a/src/models/sprite.rs +++ b/src/models/sprite.rs @@ -85,6 +85,33 @@ pub struct FrameTag { pub fps: Option, } +/// A sprite-level group translation: shift a set of regions by one offset. +/// +/// Expands at resolution into a per-region `translate`, so a walk-cycle bob — +/// "move the whole upper body up one pixel" — is a single line instead of +/// re-deriving every member region's coordinates. Composes with `extends`: a +/// frame can inherit a base and shift part of it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct GroupTranslate { + /// Pixel offset `[dx, dy]` applied to each targeted region. + pub by: [i32; 2], + /// Region keys to shift. Omit to shift every region in the sprite. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub regions: Option>, +} + +/// Apply a group translation to a region map in place, composing with any +/// translation a region already carries. +pub fn apply_group_translate(regions: &mut HashMap, gt: &GroupTranslate) { + for (key, def) in regions.iter_mut() { + let targeted = gt.regions.as_ref().is_none_or(|rs| rs.contains(key)); + if targeted { + let cur = def.translate.unwrap_or([0, 0]); + def.translate = Some([cur[0] + gt.by[0], cur[1] + gt.by[1]]); + } + } +} + /// A sprite definition. /// /// A sprite uses `regions` for structured rendering, or can reference another sprite via `source` @@ -116,6 +143,10 @@ pub struct Sprite { /// Inherited region keys to delete (only meaningful with `extends`). #[serde(skip_serializing_if = "Option::is_none", default)] pub remove: Option>, + /// Shift a group of regions by one offset (expands to per-region + /// `translate`). Applied after `extends` inheritance and region merging. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub translate: Option, /// Structured regions for rendering #[serde(skip_serializing_if = "Option::is_none", default)] pub regions: Option>, diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 9893b9b..2686a89 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -1597,6 +1597,57 @@ mod tests { assert!(r.warnings.iter().any(|w| w.message.contains("nonexistent"))); } + #[test] + fn test_group_translate_shifts_named_regions() { + use crate::models::GroupTranslate; + // walk_pass inherits walk_contact and bobs only `flame`-like `ring` + // up by one pixel via a sprite-level group translate. + let mut sprite_registry = SpriteRegistry::new(); + sprite_registry.register_sprite(extends_base()); + sprite_registry.register_sprite(Sprite { + name: "bobbed".to_string(), + extends: Some("frame_a".to_string()), + translate: Some(GroupTranslate { + by: [0, -1], + regions: Some(vec!["flame".to_string()]), + }), + ..Default::default() + }); + + let palette_registry = PaletteRegistry::new(); + let r = sprite_registry.resolve("bobbed", &palette_registry, true).unwrap(); + let regions = r.regions.unwrap(); + // The targeted region carries the offset; the untargeted one does not. + assert_eq!(regions.get("flame").unwrap().translate, Some([0, -1])); + assert_eq!(regions.get("ring").unwrap().translate, None); + } + + #[test] + fn test_group_translate_all_regions_when_unspecified() { + use crate::models::GroupTranslate; + let mut sprite_registry = SpriteRegistry::new(); + sprite_registry.register_sprite(Sprite { + name: "shift_all".to_string(), + size: Some([8, 8]), + palette: PaletteRef::Inline(HashMap::from([ + ("a".to_string(), "#FFF".to_string()), + ("b".to_string(), "#000".to_string()), + ])), + regions: Some(HashMap::from([ + ("a".to_string(), RegionDef { rect: Some([0, 0, 2, 2]), ..Default::default() }), + ("b".to_string(), RegionDef { rect: Some([4, 4, 2, 2]), ..Default::default() }), + ])), + translate: Some(GroupTranslate { by: [1, 1], regions: None }), + ..Default::default() + }); + let palette_registry = PaletteRegistry::new(); + let r = sprite_registry.resolve("shift_all", &palette_registry, true).unwrap(); + let regions = r.regions.unwrap(); + // No `regions` list → every region shifts. + assert_eq!(regions.get("a").unwrap().translate, Some([1, 1])); + assert_eq!(regions.get("b").unwrap().translate, Some([1, 1])); + } + #[test] fn test_extends_chain_two_levels() { // a -> b (override flame) -> c (override ring). c sees both overrides. diff --git a/src/registry/sprite.rs b/src/registry/sprite.rs index 7471535..acc4c3d 100644 --- a/src/registry/sprite.rs +++ b/src/registry/sprite.rs @@ -331,6 +331,15 @@ impl SpriteRegistry { own_palette }; + // Apply a sprite-level group translation last, after inheritance and + // region merging, by expanding it into per-region `translate` offsets. + let mut final_regions = base_regions; + if let Some(group) = &sprite.translate { + if let Some(regions) = final_regions.as_mut() { + crate::models::apply_group_translate(regions, group); + } + } + Ok(ResolvedSprite { name: sprite.name.clone(), // Use sprite's explicit size, or fall back to the inherited size @@ -338,7 +347,7 @@ impl SpriteRegistry { palette, warnings, nine_slice: sprite.nine_slice.clone(), - regions: base_regions, + regions: final_regions, }) } diff --git a/src/structured.rs b/src/structured.rs index 50227ca..7204708 100644 --- a/src/structured.rs +++ b/src/structured.rs @@ -196,6 +196,13 @@ pub fn rasterize_region( pixels = apply_symmetric(&pixels, symmetric, canvas_width, canvas_height, warnings); } + // Apply translation last, so it shifts the fully-resolved shape. + if let Some([dx, dy]) = region.translate { + if dx != 0 || dy != 0 { + pixels = pixels.into_iter().map(|(x, y)| (x + dx, y + dy)).collect(); + } + } + pixels } @@ -674,6 +681,22 @@ mod tests { assert_eq!(*image.get_pixel(5, 5), Rgba([0, 0, 0, 255])); } + #[test] + fn test_region_translate_shifts_pixels() { + // A region with `translate` shifts its rasterized pixels by [dx, dy]. + let region = + RegionDef { rect: Some([0, 0, 2, 2]), translate: Some([2, 3]), ..Default::default() }; + let all_regions = HashMap::new(); + let mut warnings = Vec::new(); + let pixels = rasterize_region(®ion, &all_regions, 16, 16, &mut warnings); + // Original (0,0) moves to (2,3); (1,1) to (3,4). + assert!(pixels.contains(&(2, 3))); + assert!(pixels.contains(&(3, 4))); + assert!(!pixels.contains(&(0, 0))); + assert_eq!(pixels.len(), 4); + assert!(warnings.is_empty()); + } + #[test] fn test_extends_render_golden() { // A base frame with a `ring` and a `flame`; an extending frame that diff --git a/src/validate.rs b/src/validate.rs index 3b7e738..642ca9a 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -942,6 +942,17 @@ impl Validator { if let (Some(regions), Some([w, h])) = (&sprite.regions, sprite.size) { let width = w as i32; let height = h as i32; + // Honor a sprite-level group `translate` so off-canvas warnings + // reflect what the renderer draws. + let translated_owned; + let regions = if let Some(group) = &sprite.translate { + let mut cloned = regions.clone(); + crate::models::apply_group_translate(&mut cloned, group); + translated_owned = cloned; + &translated_owned + } else { + regions + }; if width > 0 && height > 0 { let mut raster_warnings = Vec::new(); let mut rasterized: HashMap> = HashMap::new(); From 1c7d8252b10e185ef9761c086ef852166896a40f Mon Sep 17 00:00:00 2001 From: Daniel P Date: Wed, 10 Jun 2026 18:49:34 -0400 Subject: [PATCH 07/10] feat: auto-outline renders a real silhouette outline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `auto-outline` validated but drew nothing — the canonical silent no-op that motivated the empty-region error. Outlining is the most common pixel-art operation, so the standing workaround was the "silhouette trick": author the shape twice, once as a dark silhouette and once inset by a pixel as the fill, keeping both copies in sync by hand. Now it works: the source region is dilated outward (new `shapes::dilate`, 8-connected so convex corners fill) and the source subtracted, leaving a band `thickness` pixels wide around it (default 1). Like `auto-shadow`, it references another region, so it is deferred to the second rasterization pass everywhere regions are rasterized — renderer, `mask`, and validator. A missing source resolves to zero pixels and warns, exactly as a typo'd reference should. The validator's empty-region check no longer special-cases auto-outline as "does not render"; that suggestion now points at the real cause (a missing/empty source). Docs: regions.md auto-outline section rewritten with the z-order note. Tests: dilate doctest, rasterizer outline + missing-source, and a validator test that auto-outline no longer trips the empty-region warning (the old empty-region test now uses an empty point list as its zero-pixel case). Suite: aggregate 2630 passed / 1 pre-existing unrelated fail (install::tests::test_install_skips_cached). Co-Authored-By: Claude Fable 5 --- docs/book/src/format/regions.md | 14 ++++++--- src/shapes.rs | 43 ++++++++++++++++++++++++++ src/structured.rs | 55 ++++++++++++++++++++++++++++++--- src/validate.rs | 25 ++++++++++++--- 4 files changed, 125 insertions(+), 12 deletions(-) diff --git a/docs/book/src/format/regions.md b/docs/book/src/format/regions.md index 5f5db01..5de6bbd 100644 --- a/docs/book/src/format/regions.md +++ b/docs/book/src/format/regions.md @@ -289,15 +289,21 @@ grass: { ### Auto-Outline -Generate outline around a region. +Generate a silhouette outline around another region. The source region is +dilated outward (8-connected, so convex corners are filled) and the source is +subtracted, leaving a band `thickness` pixels wide that surrounds it. Give the +outline a lower `z` than its source so the source draws on top. ```json5 -outline: { - "auto-outline": "body", - thickness: 1 +regions: { + outline: { "auto-outline": "body", thickness: 1, z: 0 }, + body: { rect: [3, 3, 4, 4], z: 1 }, } ``` +`thickness` defaults to `1`. The source must be defined and non-empty; if it is +missing the region resolves to zero pixels and the validator flags it. + ### Auto-Shadow Generate drop shadow. diff --git a/src/shapes.rs b/src/shapes.rs index 9f0f135..fb870c8 100644 --- a/src/shapes.rs +++ b/src/shapes.rs @@ -245,6 +245,49 @@ pub fn subtract( result } +/// Grow a region outward by `thickness` pixels (morphological dilation). +/// +/// Each iteration adds every empty neighbor of a set pixel. With +/// `diagonal = true` the 8-connected (Moore) neighborhood is used, so convex +/// corners are filled and the result fully surrounds the shape; with `false` +/// only the 4-connected (von Neumann) neighbors are added. `thickness` of 0 +/// returns a copy unchanged. +/// +/// # Examples +/// +/// ``` +/// use pixelsrc::shapes::dilate; +/// use std::collections::HashSet; +/// +/// let dot: HashSet<(i32, i32)> = [(2, 2)].into_iter().collect(); +/// // 8-connected dilation of a single pixel yields a 3x3 block (9 pixels). +/// assert_eq!(dilate(&dot, 1, true).len(), 9); +/// // 4-connected yields a plus shape (5 pixels). +/// assert_eq!(dilate(&dot, 1, false).len(), 5); +/// ``` +pub fn dilate( + pixels: &HashSet<(i32, i32)>, + thickness: i32, + diagonal: bool, +) -> HashSet<(i32, i32)> { + let neighbors: &[(i32, i32)] = if diagonal { + &[(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] + } else { + &[(0, -1), (-1, 0), (1, 0), (0, 1)] + }; + let mut current = pixels.clone(); + for _ in 0..thickness.max(0) { + let mut next = current.clone(); + for &(x, y) in ¤t { + for (dx, dy) in neighbors { + next.insert((x + dx, y + dy)); + } + } + current = next; + } + current +} + /// Find pixels that appear in all input regions. /// /// Returns the intersection of all input regions. If no regions are provided, diff --git a/src/structured.rs b/src/structured.rs index 7204708..b0bcadd 100644 --- a/src/structured.rs +++ b/src/structured.rs @@ -9,8 +9,8 @@ use crate::models::{RegionDef, Role}; use crate::path::parse_path; use crate::renderer::Warning; use crate::shapes::{ - flood_fill, intersect, rasterize_ellipse, rasterize_line, rasterize_points, rasterize_polygon, - rasterize_rect, rasterize_stroke, subtract, union, + dilate, flood_fill, intersect, rasterize_ellipse, rasterize_line, rasterize_points, + rasterize_polygon, rasterize_rect, rasterize_stroke, subtract, union, }; use image::{Rgba, RgbaImage}; use std::collections::{HashMap, HashSet}; @@ -134,6 +134,20 @@ pub fn rasterize_region( source_name ))); } + } else if let Some(source_name) = ®ion.auto_outline { + // Generate a silhouette outline: dilate the source region outward and + // subtract the source, leaving a band `thickness` pixels wide that + // surrounds it. 8-connected so convex corners are filled. + if let Some(source_pixels) = all_regions.get(source_name) { + let thickness = region.thickness.unwrap_or(1) as i32; + let grown = dilate(source_pixels, thickness, true); + pixels = subtract(&grown, std::slice::from_ref(source_pixels)); + } else { + warnings.push(Warning::new(format!( + "Unknown token '{}' in auto-outline reference", + source_name + ))); + } } // Handle compound operations else if let Some(union_regions) = ®ion.union { @@ -343,7 +357,7 @@ pub fn render_structured( let mut pending_regions: Vec<(String, RegionDef)> = Vec::new(); for (token, region) in regions { - if region.fill.is_some() || region.auto_shadow.is_some() { + if region.fill.is_some() || region.auto_shadow.is_some() || region.auto_outline.is_some() { // Defer regions with fill or auto-shadow references pending_regions.push((token.clone(), region.clone())); } else { @@ -427,7 +441,7 @@ pub fn extract_anchor_bounds( let mut pending_regions: Vec<(String, RegionDef)> = Vec::new(); for (token, region) in regions { - if region.fill.is_some() || region.auto_shadow.is_some() { + if region.fill.is_some() || region.auto_shadow.is_some() || region.auto_outline.is_some() { pending_regions.push((token.clone(), region.clone())); } else { let pixels = rasterize_region( @@ -681,6 +695,39 @@ mod tests { assert_eq!(*image.get_pixel(5, 5), Rgba([0, 0, 0, 255])); } + #[test] + fn test_auto_outline_surrounds_source() { + // auto-outline dilates the source 8-connected and subtracts it, leaving + // a 1px band around the shape. + let mut all_regions = HashMap::new(); + let body = RegionDef { rect: Some([3, 3, 2, 2]), ..Default::default() }; + let mut warnings = Vec::new(); + let body_pixels = rasterize_region(&body, &all_regions, 10, 10, &mut warnings); + all_regions.insert("body".to_string(), body_pixels); + + let outline = RegionDef { auto_outline: Some("body".to_string()), ..Default::default() }; + let pixels = rasterize_region(&outline, &all_regions, 10, 10, &mut warnings); + + // Source rect is (3,3)-(4,4). The outline rings it: corners and edges. + assert!(pixels.contains(&(2, 2))); // diagonal corner (8-connected) + assert!(pixels.contains(&(3, 2))); // top edge + assert!(pixels.contains(&(5, 5))); // far diagonal corner + // The source pixels themselves are NOT part of the outline. + assert!(!pixels.contains(&(3, 3))); + assert!(!pixels.contains(&(4, 4))); + assert!(warnings.is_empty()); + } + + #[test] + fn test_auto_outline_missing_source_warns() { + let all_regions = HashMap::new(); + let outline = RegionDef { auto_outline: Some("ghost".to_string()), ..Default::default() }; + let mut warnings = Vec::new(); + let pixels = rasterize_region(&outline, &all_regions, 10, 10, &mut warnings); + assert!(pixels.is_empty()); + assert!(warnings.iter().any(|w| w.message.contains("ghost"))); + } + #[test] fn test_region_translate_shifts_pixels() { // A region with `translate` shifts its rasterized pixels by [dx, dy]. diff --git a/src/validate.rs b/src/validate.rs index 642ca9a..04a4ba5 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -958,7 +958,7 @@ impl Validator { let mut rasterized: HashMap> = HashMap::new(); let mut pending: Vec<(&String, &crate::models::RegionDef)> = Vec::new(); for (token, region) in regions { - if region.fill.is_some() || region.auto_shadow.is_some() { + if region.fill.is_some() || region.auto_shadow.is_some() || region.auto_outline.is_some() { pending.push((token, region)); } else { let pixels = crate::structured::rasterize_region( @@ -995,7 +995,7 @@ impl Validator { .with_context(format!("sprite \"{}\"", name)); if regions.get(token).is_some_and(|r| r.auto_outline.is_some()) { issue = issue.with_suggestion( - "auto-outline does not render; author the outline as a silhouette region".to_string(), + "auto-outline produced no pixels; check that its source region exists and is non-empty".to_string(), ); } self.issues.push(issue); @@ -2114,11 +2114,11 @@ mod tests { #[test] fn test_empty_region_warned() { let mut validator = Validator::new(); - // auto-outline renders nothing — the canonical zero-pixel region. + // An empty point list is a canonical zero-pixel region. validator.validate_line( 1, r##"{"type": "sprite", "name": "hollow", "size": [16, 16], "palette": {"_": "transparent", "b": "#FF0000", "d": "#000000"}, - "regions": {"b": {"rect": [5, 5, 6, 6]}, "d": {"auto-outline": "b"}}}"##, + "regions": {"b": {"rect": [5, 5, 6, 6]}, "d": {"points": []}}}"##, ); assert!( validator.issues().iter().any(|i| matches!(i.issue_type, IssueType::EmptyRegion)), @@ -2127,6 +2127,23 @@ mod tests { ); } + #[test] + fn test_auto_outline_renders_and_does_not_warn() { + let mut validator = Validator::new(); + // auto-outline now emits a real silhouette band, so it must NOT be + // flagged as a zero-pixel region when its source exists. + validator.validate_line( + 1, + r##"{"type": "sprite", "name": "blob", "size": [16, 16], "palette": {"_": "transparent", "b": "#FF0000", "d": "#000000"}, + "regions": {"b": {"rect": [5, 5, 6, 6], "z": 1}, "d": {"auto-outline": "b", "z": 0}}}"##, + ); + assert!( + !validator.issues().iter().any(|i| matches!(i.issue_type, IssueType::EmptyRegion)), + "auto-outline should render pixels now: {:?}", + validator.issues() + ); + } + #[test] fn test_unused_inline_palette_token_warned() { let mut validator = Validator::new(); From a365904076f47965e7d8a464e5cc1dcc7b71cf35 Mon Sep 17 00:00:00 2001 From: Daniel P Date: Wed, 10 Jun 2026 18:49:34 -0400 Subject: [PATCH 08/10] feat: `pxl show` renders structured sprites as an ANSI color preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pxl show` errored on the structured-regions format with "grid format is deprecated" — dead for the only format the project uses, while its own docs advertised an in-terminal preview. The onion-skin animation path already rendered sprites fine; only the standard single-sprite path was stubbed out. It now resolves the target through the sprite registry (so `source`, `extends`, and `translate` compose exactly as in `pxl render`) and prints an ANSI half-block true-color preview — the visual complement to `pxl mask`'s text token grid. An empty/region-less sprite reports a clear error instead of color codes. Docs: show.md gains a `mask` cross-reference; its pre-existing (previously aspirational) "basic preview" examples are now accurate. Tests: a CLI suite covering a structured sprite and an extends+translate frame. Suite: aggregate 2632 passed / 1 pre-existing unrelated fail (install::tests::test_install_skips_cached). Co-Authored-By: Claude Fable 5 --- docs/book/src/cli/show.md | 2 ++ src/cli/show.rs | 44 +++++++++++++++++++-------- src/mask.rs | 2 +- tests/cli_show.rs | 63 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 13 deletions(-) create mode 100644 tests/cli_show.rs diff --git a/docs/book/src/cli/show.md b/docs/book/src/cli/show.md index bbffc43..940989e 100644 --- a/docs/book/src/cli/show.md +++ b/docs/book/src/cli/show.md @@ -96,4 +96,6 @@ Most modern terminals support this, including: ## See Also - [render](render.md) - Render to image files +- [mask](../format/regions.md) - Inspect a sprite as a text token grid (the + non-visual complement to `show`'s color preview) - [explain](explain.md) - Explain objects in human-readable format diff --git a/src/cli/show.rs b/src/cli/show.rs index 1afd6f4..cced807 100644 --- a/src/cli/show.rs +++ b/src/cli/show.rs @@ -213,18 +213,38 @@ pub fn run_show( } }; - // Grid-based terminal rendering is deprecated - // Use render command to generate PNG output instead - eprintln!("Error: Terminal view requires grid format, which is deprecated."); - eprintln!( - "Use 'pxl render' to generate PNG output, or use sprites with structured regions format." - ); - - // Show basic sprite info - let (width, height) = - if let Some(size) = sprite.size { (size[0] as usize, size[1] as usize) } else { (0, 0) }; + // Render the structured sprite to an ANSI half-block color preview. This + // resolves `source`/`extends`/`translate` through the sprite registry so + // the preview matches what `pxl render` would produce — a visual check that + // complements `pxl mask`'s text token grid. + let mut sprite_registry = crate::registry::SpriteRegistry::new(); + for s in sprites_by_name.values() { + sprite_registry.register_sprite(s.clone()); + } + + let resolved = match sprite_registry.resolve(&sprite.name, ®istry, false) { + Ok(r) => r, + Err(e) => { + eprintln!("Error: sprite '{}': {}", sprite.name, e); + return ExitCode::from(EXIT_ERROR); + } + }; + for w in &resolved.warnings { + eprintln!("Warning: sprite '{}': {}", sprite.name, w.message); + } + + if resolved.regions.as_ref().is_none_or(|r| r.is_empty()) { + eprintln!("Error: sprite '{}' has no regions to display", sprite.name); + return ExitCode::from(EXIT_ERROR); + } + + let (image, _warnings) = crate::renderer::render_resolved(&resolved); + + let (width, height) = if let Some(size) = resolved.size { (size[0], size[1]) } else { (0, 0) }; println!("Sprite: {} ({}x{})", sprite.name, width, height); - println!("Has regions: {}", sprite.regions.is_some()); + println!(); + use crate::terminal::render_image_ansi; + print!("{}", render_image_ansi(&image)); - ExitCode::from(EXIT_ERROR) + ExitCode::from(EXIT_SUCCESS) } diff --git a/src/mask.rs b/src/mask.rs index e747861..5c2f8a8 100644 --- a/src/mask.rs +++ b/src/mask.rs @@ -293,7 +293,7 @@ impl TokenGrid { let mut warnings: Vec = Vec::new(); for (token, region) in regions { - if region.fill.is_some() || region.auto_shadow.is_some() { + if region.fill.is_some() || region.auto_shadow.is_some() || region.auto_outline.is_some() { pending.push((token.clone(), region)); } else { let pixels = rasterize_region( diff --git a/tests/cli_show.rs b/tests/cli_show.rs new file mode 100644 index 0000000..e606d8d --- /dev/null +++ b/tests/cli_show.rs @@ -0,0 +1,63 @@ +//! CLI integration tests for `pxl show` on structured-region sprites. +//! +//! `show` used to reject the structured format with a "grid format is +//! deprecated" error; it now renders an ANSI half-block color preview that +//! resolves `extends`/`source`/`translate` like `pxl render`. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn pxl_binary() -> PathBuf { + let release = Path::new("target/release/pxl"); + if release.exists() { + return release.to_path_buf(); + } + let debug = Path::new("target/debug/pxl"); + if debug.exists() { + return debug.to_path_buf(); + } + panic!("pxl binary not found. Run 'cargo build' first."); +} + +const SPRITES: &str = r##"{ type: "palette", name: "p", colors: { _: "transparent", body: "#CC4444" } } +{ type: "sprite", name: "blob", size: [8, 8], palette: "p", regions: { body: { rect: [2, 2, 4, 4], z: 0 } } } +{ type: "sprite", name: "blob_bob", extends: "blob", translate: { by: [0, -1], regions: ["body"] } } +"##; + +fn write_input(dir: &Path) -> PathBuf { + let path = dir.join("blob.pxl"); + fs::write(&path, SPRITES).unwrap(); + path +} + +fn run_show(args: &[&str]) -> (String, String, bool) { + let output = + Command::new(pxl_binary()).arg("show").args(args).output().expect("Failed to execute pxl"); + ( + String::from_utf8_lossy(&output.stdout).to_string(), + String::from_utf8_lossy(&output.stderr).to_string(), + output.status.success(), + ) +} + +#[test] +fn test_show_structured_sprite_renders() { + let dir = tempfile::tempdir().unwrap(); + let input = write_input(dir.path()); + let (stdout, stderr, ok) = run_show(&[input.to_str().unwrap(), "--sprite", "blob"]); + assert!(ok, "show should render a structured sprite; stderr: {}", stderr); + assert!(stdout.contains("Sprite: blob (8x8)"), "should print the sprite header: {}", stdout); + // ANSI half-block output uses escape sequences. + assert!(stdout.contains('\u{1b}'), "should emit ANSI color output"); + assert!(!stderr.contains("deprecated"), "must not report the old deprecation error"); +} + +#[test] +fn test_show_resolves_extends_and_translate() { + let dir = tempfile::tempdir().unwrap(); + let input = write_input(dir.path()); + let (stdout, stderr, ok) = run_show(&[input.to_str().unwrap(), "--sprite", "blob_bob"]); + assert!(ok, "show should resolve an extends+translate frame; stderr: {}", stderr); + assert!(stdout.contains("Sprite: blob_bob (8x8)"), "size inherited from base: {}", stdout); +} From 209611e7d55f7f36729c7f49c65c8a47f0953959 Mon Sep 17 00:00:00 2001 From: Daniel P Date: Wed, 10 Jun 2026 19:01:04 -0400 Subject: [PATCH 09/10] fix: credit extends-frame tokens to the inherited palette (no false unused warning) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A palette token used only by an `extends` frame that omits its palette was recorded under the empty palette name, so the inherited named palette saw it as never-used and `validate --strict` failed with a spurious "tokens never used" warning — exactly the case a frame-set hits when the shared palette carries a color only a later frame paints with. The validator now remembers each sprite's (own named palette, extends target) and defers the token usage of palette-omitting extends frames, crediting it at end-of-file to the first palette up the extends chain (cycle-guarded). Regression test: a token painted only by an extending frame no longer trips the unused-token warning. Found while re-verifying the A3 features against target/release/pxl for the pixel-art skill refresh. Suite: aggregate 2633 passed / 1 pre-existing unrelated fail (install::tests::test_install_skips_cached). Co-Authored-By: Claude Fable 5 --- src/validate.rs | 81 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/validate.rs b/src/validate.rs index 04a4ba5..3b93cdc 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -337,6 +337,14 @@ pub struct Validator { palette_lines: HashMap, /// Tokens actually referenced by sprites, per named palette used_palette_tokens: HashMap>, + /// Each sprite's (own named palette, `extends` target) for resolving the + /// effective palette of a sprite that omits its palette via `extends`. + sprite_palette_chain: HashMap, Option)>, + /// Token usage from sprites that omit their palette (inheriting via + /// `extends`); resolved to the inherited palette at end-of-file so its + /// tokens are not falsely flagged unused. Each entry is (extends-target, + /// tokens used). + deferred_token_usage: Vec<(String, HashSet)>, } impl Default for Validator { @@ -366,6 +374,8 @@ impl Validator { imported_names: HashSet::new(), palette_lines: HashMap::new(), used_palette_tokens: HashMap::new(), + sprite_palette_chain: HashMap::new(), + deferred_token_usage: Vec::new(), } } @@ -903,10 +913,27 @@ impl Validator { } } + // Remember this sprite's (own named palette, extends target) so an + // extending sprite that omits its palette can be resolved to the + // inherited palette at end-of-file. + let own_named_palette = match &sprite.palette { + PaletteRef::Named(n) if !n.is_empty() => Some(n.clone()), + _ => None, + }; + self.sprite_palette_chain + .insert(name.to_string(), (own_named_palette, sprite.extends.clone())); + // Record token usage for end-of-file unused-token reporting (named // palettes are shared between sprites, so per-sprite reporting would // be noisy); inline palettes are self-contained — check immediately. match &sprite.palette { + // An extending sprite that omits its palette: defer its token usage + // and credit it to the inherited palette once the file is parsed. + PaletteRef::Named(palette_name) if palette_name.is_empty() => { + if let Some(base) = &sprite.extends { + self.deferred_token_usage.push((base.clone(), all_tokens_used.clone())); + } + } PaletteRef::Named(palette_name) => { let used = self.used_palette_tokens.entry(palette_name.clone()).or_default(); for token in &all_tokens_used { @@ -1033,6 +1060,34 @@ impl Validator { /// the end of a file pass; only palettes referenced by at least one /// local sprite are checked, so palette-only library files stay quiet. pub fn report_unused_palette_tokens(&mut self) { + // Credit tokens used by palette-omitting `extends` sprites to the + // palette they inherit, walking the extends chain to the first sprite + // that declares a named palette. This prevents a token used only by an + // extending frame from being reported as unused. + let deferred = std::mem::take(&mut self.deferred_token_usage); + for (base, tokens) in deferred { + let mut cursor = Some(base); + let mut hops = 0; + while let Some(name) = cursor { + hops += 1; + if hops > 64 { + break; // guard against a cycle in malformed input + } + match self.sprite_palette_chain.get(&name) { + Some((Some(palette_name), _)) => { + let used = + self.used_palette_tokens.entry(palette_name.clone()).or_default(); + for token in &tokens { + used.insert(token.clone()); + } + break; + } + Some((None, Some(next))) => cursor = Some(next.clone()), + _ => break, + } + } + } + let mut reports: Vec<(usize, String, Vec)> = Vec::new(); for (palette_name, used) in &self.used_palette_tokens { if let Some(defined) = self.palettes.get(palette_name) { @@ -1731,6 +1786,32 @@ mod tests { assert_eq!(missing.len(), 1); } + #[test] + fn test_extends_token_usage_credits_inherited_palette() { + // A token used only by a palette-omitting `extends` frame must not be + // reported as unused on the inherited named palette. + let mut validator = Validator::new(); + validator.validate_line( + 1, + r##"{"type": "palette", "name": "p", "colors": {"_": "transparent", "ring": "#888888", "spark": "#FFD700"}}"##, + ); + validator.validate_line( + 2, + r##"{"type": "sprite", "name": "base", "size": [8, 8], "palette": "p", "regions": {"ring": {"rect": [1, 5, 6, 2]}}}"##, + ); + // Only this extending frame (which omits its palette) uses `spark`. + validator.validate_line( + 3, + r##"{"type": "sprite", "name": "frame", "extends": "base", "regions": {"spark": {"points": [[2, 2]]}}}"##, + ); + validator.report_unused_palette_tokens(); + assert!( + !validator.issues().iter().any(|i| matches!(i.issue_type, IssueType::UnusedToken)), + "spark is used by an extending frame; must not be flagged unused: {:?}", + validator.issues() + ); + } + #[test] fn test_validate_omitted_palette_with_extends_ok() { // An `extends` sprite may omit its palette — it inherits the base's. From f5a92ddfb7140b6ef350ea2f78836578d6889bff Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Sat, 26 Sep 2026 00:57:09 -0400 Subject: [PATCH 10/10] style: cargo fmt over the integrated fork commits Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXBCFZMofE9EGKzo8J2YKJ --- src/cli/render.rs | 3 ++- src/diff.rs | 24 ++++++++++++++++----- src/mask.rs | 5 ++++- src/registry/mod.rs | 12 ++++++++--- src/shapes.rs | 9 +++----- src/structured.rs | 2 +- src/validate.rs | 44 ++++++++++++++++++++++---------------- tests/cli_particle_bake.rs | 3 +-- 8 files changed, 65 insertions(+), 37 deletions(-) diff --git a/src/cli/render.rs b/src/cli/render.rs index 263c5c1..f0a9f72 100644 --- a/src/cli/render.rs +++ b/src/cli/render.rs @@ -736,7 +736,8 @@ fn run_particle_bake( } else { for (i, frame) in raw_frames.into_iter().enumerate() { let img = scale_image(frame, scale); - let path = particle_output_path(input, output, &particle.name, Some(i as u32), "png"); + let path = + particle_output_path(input, output, &particle.name, Some(i as u32), "png"); if let Err(e) = save_png(&img, &path) { eprintln!("Error: Failed to save '{}': {}", path.display(), e); return ExitCode::from(EXIT_ERROR); diff --git a/src/diff.rs b/src/diff.rs index 82c904f..e542520 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -222,8 +222,13 @@ fn rasterize_all( if region.fill.is_some() || region.auto_shadow.is_some() { pending.push((token, region)); } else { - let pixels = - crate::structured::rasterize_region(region, &rasterized, width, height, &mut warnings); + let pixels = crate::structured::rasterize_region( + region, + &rasterized, + width, + height, + &mut warnings, + ); rasterized.insert(token.clone(), pixels); } } @@ -454,7 +459,10 @@ pub fn format_diff(name: &str, diff: &SpriteDiff, file_a: &str, file_b: &str) -> output.push(format!(" → {} shifted ({}, {})", token, dx, dy)); } RegionChange::Changed { token, differing } => { - output.push(format!(" ~ {} shape changed ({} pixel(s) differ)", token, differing)); + output.push(format!( + " ~ {} shape changed ({} pixel(s) differ)", + token, differing + )); } } } @@ -503,8 +511,14 @@ mod tests { #[test] fn test_region_shift_detected() { // The walk-cycle bob: same shape, one region moved up a pixel. - let a = sprite_with_regions("s", r#"{"a": {"rect": [4, 4, 3, 3]}, "b": {"rect": [0, 0, 2, 2]}}"#); - let b = sprite_with_regions("s", r#"{"a": {"rect": [4, 3, 3, 3]}, "b": {"rect": [0, 0, 2, 2]}}"#); + let a = sprite_with_regions( + "s", + r#"{"a": {"rect": [4, 4, 3, 3]}, "b": {"rect": [0, 0, 2, 2]}}"#, + ); + let b = sprite_with_regions( + "s", + r#"{"a": {"rect": [4, 3, 3, 3]}, "b": {"rect": [0, 0, 2, 2]}}"#, + ); let palette = HashMap::new(); let diff = diff_sprites(&a, &b, &palette, &palette); assert_eq!( diff --git a/src/mask.rs b/src/mask.rs index 5c2f8a8..a062959 100644 --- a/src/mask.rs +++ b/src/mask.rs @@ -293,7 +293,10 @@ impl TokenGrid { let mut warnings: Vec = Vec::new(); for (token, region) in regions { - if region.fill.is_some() || region.auto_shadow.is_some() || region.auto_outline.is_some() { + if region.fill.is_some() + || region.auto_shadow.is_some() + || region.auto_outline.is_some() + { pending.push((token.clone(), region)); } else { let pixels = rasterize_region( diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 2686a89..12d1b97 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -1493,8 +1493,14 @@ mod tests { ("flame".to_string(), "#E25822".to_string()), ])), regions: Some(HashMap::from([ - ("ring".to_string(), RegionDef { rect: Some([1, 5, 6, 2]), z: Some(0), ..Default::default() }), - ("flame".to_string(), RegionDef { rect: Some([3, 1, 2, 4]), z: Some(1), ..Default::default() }), + ( + "ring".to_string(), + RegionDef { rect: Some([1, 5, 6, 2]), z: Some(0), ..Default::default() }, + ), + ( + "flame".to_string(), + RegionDef { rect: Some([3, 1, 2, 4]), z: Some(1), ..Default::default() }, + ), ])), ..Default::default() } @@ -1558,7 +1564,7 @@ mod tests { assert!(regions.contains_key("ring")); assert!(!regions.contains_key("flame")); // removed assert!(regions.contains_key("spark")); // added - // Own palette token merged over the inherited base. + // Own palette token merged over the inherited base. assert_eq!(r.palette.get("spark"), Some(&"#FFD700".to_string())); assert_eq!(r.palette.get("ring"), Some(&"#888888".to_string())); } diff --git a/src/shapes.rs b/src/shapes.rs index fb870c8..b24d855 100644 --- a/src/shapes.rs +++ b/src/shapes.rs @@ -265,11 +265,7 @@ pub fn subtract( /// // 4-connected yields a plus shape (5 pixels). /// assert_eq!(dilate(&dot, 1, false).len(), 5); /// ``` -pub fn dilate( - pixels: &HashSet<(i32, i32)>, - thickness: i32, - diagonal: bool, -) -> HashSet<(i32, i32)> { +pub fn dilate(pixels: &HashSet<(i32, i32)>, thickness: i32, diagonal: bool) -> HashSet<(i32, i32)> { let neighbors: &[(i32, i32)] = if diagonal { &[(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] } else { @@ -743,7 +739,8 @@ mod tests { let pixels = rasterize_ellipse(c, c, r, r); let rf = r as f64 + 0.5; for dy in -r..=r { - let expected_hw = ((rf * (1.0 - (dy as f64 / rf).powi(2)).sqrt()).floor() as i32).min(r); + let expected_hw = + ((rf * (1.0 - (dy as f64 / rf).powi(2)).sqrt()).floor() as i32).min(r); let row: Vec = pixels.iter().filter(|&&(_, y)| y == c + dy).map(|&(x, _)| x).collect(); let min_x = *row.iter().min().expect("row present"); diff --git a/src/structured.rs b/src/structured.rs index b0bcadd..ec0ab48 100644 --- a/src/structured.rs +++ b/src/structured.rs @@ -712,7 +712,7 @@ mod tests { assert!(pixels.contains(&(2, 2))); // diagonal corner (8-connected) assert!(pixels.contains(&(3, 2))); // top edge assert!(pixels.contains(&(5, 5))); // far diagonal corner - // The source pixels themselves are NOT part of the outline. + // The source pixels themselves are NOT part of the outline. assert!(!pixels.contains(&(3, 3))); assert!(!pixels.contains(&(4, 4))); assert!(warnings.is_empty()); diff --git a/src/validate.rs b/src/validate.rs index 3b93cdc..83212a8 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -134,8 +134,8 @@ impl std::fmt::Display for IssueType { fn duplicate_region_keys(content: &str) -> Vec { #[derive(PartialEq)] enum Mode { - Seeking, // looking for the `regions` key at sprite depth - InRegions, // collecting keys at regions depth + Seeking, // looking for the `regions` key at sprite depth + InRegions, // collecting keys at regions depth Done, } @@ -212,7 +212,9 @@ fn duplicate_region_keys(content: &str) -> Vec { mode = Mode::InRegions; regions_depth = depth + 1; // keys live one level in } - Mode::InRegions if expecting_key && depth == regions_depth && !token.is_empty() => { + Mode::InRegions + if expecting_key && depth == regions_depth && !token.is_empty() => + { let count = seen.entry(token.clone()).or_insert(0); *count += 1; if *count == 2 { @@ -834,8 +836,7 @@ impl Validator { // set — the base palette is inherited. On any other sprite it's a // missing-palette error. Skip token resolution in the inherited case so // we don't spuriously flag inherited tokens as undefined. - let palette_omitted = - matches!(&sprite.palette, PaletteRef::Named(n) if n.is_empty()); + let palette_omitted = matches!(&sprite.palette, PaletteRef::Named(n) if n.is_empty()); if palette_omitted && sprite.extends.is_none() { self.issues.push( ValidationIssue::warning( @@ -856,7 +857,10 @@ impl Validator { ValidationIssue::warning( line_number, IssueType::EmptyGrid, - format!("Sprite \"{}\" uses `remove` without `extends`; it has no effect", name), + format!( + "Sprite \"{}\" uses `remove` without `extends`; it has no effect", + name + ), ) .with_context(format!("sprite \"{}\"", name)), ); @@ -941,10 +945,8 @@ impl Validator { } } PaletteRef::Inline(colors) => { - let mut unused: Vec<&String> = colors - .keys() - .filter(|t| *t != "_" && !all_tokens_used.contains(*t)) - .collect(); + let mut unused: Vec<&String> = + colors.keys().filter(|t| *t != "_" && !all_tokens_used.contains(*t)).collect(); unused.sort(); if !unused.is_empty() { self.issues.push( @@ -985,7 +987,10 @@ impl Validator { let mut rasterized: HashMap> = HashMap::new(); let mut pending: Vec<(&String, &crate::models::RegionDef)> = Vec::new(); for (token, region) in regions { - if region.fill.is_some() || region.auto_shadow.is_some() || region.auto_outline.is_some() { + if region.fill.is_some() + || region.auto_shadow.is_some() + || region.auto_outline.is_some() + { pending.push((token, region)); } else { let pixels = crate::structured::rasterize_region( @@ -1091,11 +1096,8 @@ impl Validator { let mut reports: Vec<(usize, String, Vec)> = Vec::new(); for (palette_name, used) in &self.used_palette_tokens { if let Some(defined) = self.palettes.get(palette_name) { - let mut unused: Vec = defined - .iter() - .filter(|t| *t != "_" && !used.contains(*t)) - .cloned() - .collect(); + let mut unused: Vec = + defined.iter().filter(|t| *t != "_" && !used.contains(*t)).cloned().collect(); unused.sort(); if !unused.is_empty() { let line = self.palette_lines.get(palette_name).copied().unwrap_or(0); @@ -2154,7 +2156,10 @@ mod tests { "regions": { b: { rect: [0, 0, 4, 4] }, b: { rect: [10, 10, 4, 4] } }}"##, ); assert!( - validator.issues().iter().any(|i| matches!(i.issue_type, IssueType::DuplicateRegionKey)), + validator + .issues() + .iter() + .any(|i| matches!(i.issue_type, IssueType::DuplicateRegionKey)), "duplicate region key must be reported: {:?}", validator.issues() ); @@ -2171,7 +2176,10 @@ mod tests { "regions": {"a": {"union": [{"rect": [0, 0, 2, 2]}, {"rect": [4, 4, 2, 2]}]}, "b": {"points": [[1, 1]]}}}"##, ); assert!( - !validator.issues().iter().any(|i| matches!(i.issue_type, IssueType::DuplicateRegionKey)), + !validator + .issues() + .iter() + .any(|i| matches!(i.issue_type, IssueType::DuplicateRegionKey)), "no duplicates here: {:?}", validator.issues() ); diff --git a/tests/cli_particle_bake.rs b/tests/cli_particle_bake.rs index e6ea8de..971bd90 100644 --- a/tests/cli_particle_bake.rs +++ b/tests/cli_particle_bake.rs @@ -129,8 +129,7 @@ fn test_particle_bake_no_particle_errors() { ) .unwrap(); - let (_stdout, stderr, ok) = - run_render(&[input.to_str().unwrap(), "--frames", "4"]); + let (_stdout, stderr, ok) = run_render(&[input.to_str().unwrap(), "--frames", "4"]); assert!(!ok, "should fail when no particle is present"); assert!(stderr.contains("no particle systems"), "stderr: {}", stderr); }