1use std::collections::HashMap;
7
8use crate::color::parse_color;
9use crate::models::{Animation, Composition, PaletteRef, Particle, Sprite, TtpObject, Variant};
10use crate::palettes;
11use crate::tokenizer::tokenize;
12
13#[derive(Debug, Clone)]
15pub struct TokenUsage {
16 pub token: String,
18 pub count: usize,
20 pub percentage: f64,
22 pub color: Option<String>,
24 pub color_name: Option<String>,
26}
27
28#[derive(Debug)]
30pub struct SpriteExplanation {
31 pub name: String,
33 pub width: usize,
35 pub height: usize,
37 pub total_cells: usize,
39 pub palette_ref: String,
41 pub tokens: Vec<TokenUsage>,
43 pub transparent_count: usize,
45 pub transparency_ratio: f64,
47 pub consistent_rows: bool,
49 pub issues: Vec<String>,
51}
52
53#[derive(Debug)]
55pub struct PaletteExplanation {
56 pub name: String,
58 pub color_count: usize,
60 pub colors: Vec<(String, String, Option<String>)>,
62 pub is_builtin: bool,
64}
65
66#[derive(Debug)]
68pub struct AnimationExplanation {
69 pub name: String,
71 pub frames: Vec<String>,
73 pub frame_count: usize,
75 pub duration_ms: u32,
77 pub loops: bool,
79}
80
81#[derive(Debug)]
83pub struct CompositionExplanation {
84 pub name: String,
86 pub base: Option<String>,
88 pub size: Option<[u32; 2]>,
90 pub cell_size: [u32; 2],
92 pub sprite_count: usize,
94 pub layer_count: usize,
96}
97
98#[derive(Debug)]
100pub struct VariantExplanation {
101 pub name: String,
103 pub base: String,
105 pub override_count: usize,
107 pub overrides: Vec<(String, String)>,
109}
110
111#[derive(Debug, Clone)]
113pub struct ParticleExplanation {
114 pub name: String,
116 pub sprite: String,
118 pub rate: f64,
120 pub lifetime: [u32; 2],
122 pub has_gravity: bool,
124 pub has_fade: bool,
126}
127
128#[derive(Debug, Clone)]
130pub struct TransformExplanation {
131 pub name: String,
133 pub is_parameterized: bool,
135 pub params: Vec<String>,
137 pub generates_animation: bool,
139 pub frame_count: Option<u32>,
141 pub transform_type: String,
143}
144
145#[derive(Debug)]
147pub enum Explanation {
148 Sprite(SpriteExplanation),
149 Palette(PaletteExplanation),
150 Transform(TransformExplanation),
151 Animation(AnimationExplanation),
152 Composition(CompositionExplanation),
153 Variant(VariantExplanation),
154 Particle(ParticleExplanation),
155}
156
157pub fn explain_sprite(
159 sprite: &Sprite,
160 palette_colors: Option<&HashMap<String, String>>,
161) -> SpriteExplanation {
162 let mut token_counts: HashMap<String, usize> = HashMap::new();
163 let mut total_cells = 0;
164 let mut first_row_width: Option<usize> = None;
165 let mut consistent_rows = true;
166 let mut issues = Vec::new();
167
168 for (row_idx, row) in sprite.grid.iter().enumerate() {
170 let (tokens, warnings) = tokenize(row);
171 let row_width = tokens.len();
172
173 match first_row_width {
175 None => first_row_width = Some(row_width),
176 Some(expected) if row_width != expected => {
177 consistent_rows = false;
178 issues.push(format!(
179 "Row {} has {} tokens (expected {})",
180 row_idx + 1,
181 row_width,
182 expected
183 ));
184 }
185 _ => {}
186 }
187
188 for token in tokens {
190 *token_counts.entry(token).or_insert(0) += 1;
191 total_cells += 1;
192 }
193
194 for warning in warnings {
196 issues.push(warning.message);
197 }
198 }
199
200 let width = first_row_width.unwrap_or(0);
201 let height = sprite.grid.len();
202
203 let transparent_count = token_counts.get("{_}").copied().unwrap_or(0);
205 let transparency_ratio =
206 if total_cells > 0 { (transparent_count as f64 / total_cells as f64) * 100.0 } else { 0.0 };
207
208 let mut tokens: Vec<TokenUsage> = token_counts
210 .iter()
211 .map(|(token, &count)| {
212 let percentage =
213 if total_cells > 0 { (count as f64 / total_cells as f64) * 100.0 } else { 0.0 };
214
215 let color = palette_colors.and_then(|c| c.get(token).cloned());
216 let color_name = color.as_ref().and_then(|c| describe_color(c));
217
218 TokenUsage { token: token.clone(), count, percentage, color, color_name }
219 })
220 .collect();
221
222 tokens.sort_by(|a, b| b.count.cmp(&a.count));
224
225 let palette_ref = match &sprite.palette {
227 PaletteRef::Named(name) => name.clone(),
228 PaletteRef::Inline(_) => "inline".to_string(),
229 };
230
231 SpriteExplanation {
232 name: sprite.name.clone(),
233 width,
234 height,
235 total_cells,
236 palette_ref,
237 tokens,
238 transparent_count,
239 transparency_ratio,
240 consistent_rows,
241 issues,
242 }
243}
244
245pub fn explain_palette(name: &str, colors: &HashMap<String, String>) -> PaletteExplanation {
247 let mut color_list: Vec<(String, String, Option<String>)> = colors
248 .iter()
249 .map(|(token, color)| {
250 let name = describe_color(color);
251 (token.clone(), color.clone(), name)
252 })
253 .collect();
254
255 color_list.sort_by(|a, b| a.0.cmp(&b.0));
257
258 PaletteExplanation {
259 name: name.to_string(),
260 color_count: colors.len(),
261 colors: color_list,
262 is_builtin: false,
263 }
264}
265
266pub fn explain_animation(animation: &Animation) -> AnimationExplanation {
268 AnimationExplanation {
269 name: animation.name.clone(),
270 frames: animation.frames.clone(),
271 frame_count: animation.frames.len(),
272 duration_ms: animation.duration_ms(),
273 loops: animation.loops(),
274 }
275}
276
277pub fn explain_composition(composition: &Composition) -> CompositionExplanation {
279 CompositionExplanation {
280 name: composition.name.clone(),
281 base: composition.base.clone(),
282 size: composition.size,
283 cell_size: composition.cell_size(),
284 sprite_count: composition.sprites.len(),
285 layer_count: composition.layers.len(),
286 }
287}
288
289pub fn explain_variant(variant: &Variant) -> VariantExplanation {
291 let overrides: Vec<(String, String)> =
292 variant.palette.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
293
294 VariantExplanation {
295 name: variant.name.clone(),
296 base: variant.base.clone(),
297 override_count: variant.palette.len(),
298 overrides,
299 }
300}
301
302pub fn explain_particle(particle: &Particle) -> ParticleExplanation {
304 ParticleExplanation {
305 name: particle.name.clone(),
306 sprite: particle.sprite.clone(),
307 rate: particle.emitter.rate,
308 lifetime: particle.emitter.lifetime,
309 has_gravity: particle.emitter.gravity.is_some(),
310 has_fade: particle.emitter.fade.unwrap_or(false),
311 }
312}
313
314pub fn explain_transform(transform: &crate::models::TransformDef) -> TransformExplanation {
316 let transform_type = if transform.generates_animation() {
317 "keyframe animation".to_string()
318 } else if transform.is_cycling() {
319 "cycling".to_string()
320 } else if transform.is_simple() {
321 "sequence".to_string()
322 } else if transform.compose.is_some() {
323 "parallel composition".to_string()
324 } else {
325 "custom".to_string()
326 };
327
328 TransformExplanation {
329 name: transform.name.clone(),
330 is_parameterized: transform.is_parameterized(),
331 params: transform.params.clone().unwrap_or_default(),
332 generates_animation: transform.generates_animation(),
333 frame_count: transform.frames,
334 transform_type,
335 }
336}
337
338pub fn explain_object(
340 obj: &TtpObject,
341 palette_colors: Option<&HashMap<String, String>>,
342) -> Explanation {
343 match obj {
344 TtpObject::Sprite(sprite) => Explanation::Sprite(explain_sprite(sprite, palette_colors)),
345 TtpObject::Palette(palette) => {
346 Explanation::Palette(explain_palette(&palette.name, &palette.colors))
347 }
348 TtpObject::Animation(anim) => Explanation::Animation(explain_animation(anim)),
349 TtpObject::Composition(comp) => Explanation::Composition(explain_composition(comp)),
350 TtpObject::Variant(variant) => Explanation::Variant(explain_variant(variant)),
351 TtpObject::Particle(particle) => Explanation::Particle(explain_particle(particle)),
352 TtpObject::Transform(transform) => Explanation::Transform(explain_transform(transform)),
353 }
354}
355
356pub fn describe_color(hex: &str) -> Option<String> {
358 let rgba = parse_color(hex).ok()?;
360
361 if rgba[3] == 0 {
363 return Some("transparent".to_string());
364 }
365
366 let r = rgba[0];
367 let g = rgba[1];
368 let b = rgba[2];
369
370 if r == g && g == b {
372 return Some(
373 match r {
374 0 => "black",
375 255 => "white",
376 0..=63 => "dark gray",
377 64..=127 => "gray",
378 128..=191 => "light gray",
379 192..=254 => "very light gray",
380 }
381 .to_string(),
382 );
383 }
384
385 let max = r.max(g).max(b);
387 let min = r.min(g).min(b);
388
389 let hue = if max == min {
391 0.0 } else {
393 let delta = (max - min) as f64;
394 let h = if max == r {
395 ((g as f64 - b as f64) / delta) % 6.0
396 } else if max == g {
397 ((b as f64 - r as f64) / delta) + 2.0
398 } else {
399 ((r as f64 - g as f64) / delta) + 4.0
400 };
401 h * 60.0
402 };
403
404 let hue = if hue < 0.0 { hue + 360.0 } else { hue };
406
407 let lightness = (max as f64 + min as f64) / 510.0; let saturation = if max == min {
410 0.0
411 } else if lightness <= 0.5 {
412 (max - min) as f64 / (max + min) as f64
413 } else {
414 (max - min) as f64 / (510.0 - max as f64 - min as f64)
415 };
416
417 let base_color = match hue as u32 {
419 0..=14 | 346..=360 => "red",
420 15..=44 => "orange",
421 45..=74 => "yellow",
422 75..=154 => "green",
423 155..=184 => "cyan",
424 185..=254 => "blue",
425 255..=284 => "purple",
426 285..=345 => "magenta",
427 _ => "red",
428 };
429
430 let modifier = if saturation < 0.2 {
432 "grayish "
433 } else if lightness < 0.2 {
434 "dark "
435 } else if lightness > 0.8 {
436 "light "
437 } else if saturation > 0.8 && lightness > 0.4 && lightness < 0.6 {
438 "bright "
439 } else {
440 ""
441 };
442
443 Some(format!("{}{}", modifier, base_color))
444}
445
446pub fn format_sprite_explanation(exp: &SpriteExplanation) -> String {
448 let mut output = String::new();
449
450 output.push_str(&format!("Sprite: {}\n", exp.name));
452 output.push_str(&format!(
453 "Size: {}x{} pixels ({} cells)\n",
454 exp.width, exp.height, exp.total_cells
455 ));
456 output.push_str(&format!("Palette: {}\n", exp.palette_ref));
457 output.push('\n');
458
459 output.push_str("TOKENS USED\n");
461 output.push_str("-----------\n");
462
463 for usage in &exp.tokens {
464 let color_desc = match (&usage.color, &usage.color_name) {
465 (Some(hex), Some(name)) => format!(" {} ({})", hex, name),
466 (Some(hex), None) => format!(" {}", hex),
467 _ => String::new(),
468 };
469
470 output.push_str(&format!(
471 " {:12} {:>4}x ({:>5.1}%){}",
472 usage.token, usage.count, usage.percentage, color_desc
473 ));
474 output.push('\n');
475 }
476 output.push('\n');
477
478 output.push_str("STRUCTURE\n");
480 output.push_str("---------\n");
481 output.push_str(&format!(
482 " Transparency: {:.1}% ({} cells)\n",
483 exp.transparency_ratio, exp.transparent_count
484 ));
485 output.push_str(&format!(
486 " Row consistency: {}\n",
487 if exp.consistent_rows { "yes" } else { "no" }
488 ));
489 output.push_str(&format!(" Unique tokens: {}\n", exp.tokens.len()));
490
491 if !exp.issues.is_empty() {
493 output.push('\n');
494 output.push_str("ISSUES\n");
495 output.push_str("------\n");
496 for issue in &exp.issues {
497 output.push_str(&format!(" - {}\n", issue));
498 }
499 }
500
501 output
502}
503
504pub fn format_palette_explanation(exp: &PaletteExplanation) -> String {
506 let mut output = String::new();
507
508 output.push_str(&format!("Palette: {}\n", exp.name));
509 output.push_str(&format!("Colors: {}\n", exp.color_count));
510 if exp.is_builtin {
511 output.push_str("Type: built-in\n");
512 }
513 output.push('\n');
514
515 output.push_str("COLOR MAPPINGS\n");
516 output.push_str("--------------\n");
517
518 for (token, hex, name) in &exp.colors {
519 let desc = name.as_ref().map(|n| format!(" ({})", n)).unwrap_or_default();
520 output.push_str(&format!(" {:12} => {}{}\n", token, hex, desc));
521 }
522
523 output
524}
525
526pub fn format_animation_explanation(exp: &AnimationExplanation) -> String {
528 let mut output = String::new();
529
530 output.push_str(&format!("Animation: {}\n", exp.name));
531 output.push_str(&format!("Frames: {}\n", exp.frame_count));
532 output.push_str(&format!("Duration: {}ms per frame\n", exp.duration_ms));
533 output.push_str(&format!("Loops: {}\n", if exp.loops { "yes" } else { "no" }));
534 output.push('\n');
535
536 output.push_str("FRAME SEQUENCE\n");
537 output.push_str("--------------\n");
538 for (i, frame) in exp.frames.iter().enumerate() {
539 output.push_str(&format!(" {}: {}\n", i + 1, frame));
540 }
541
542 output
543}
544
545pub fn format_composition_explanation(exp: &CompositionExplanation) -> String {
547 let mut output = String::new();
548
549 output.push_str(&format!("Composition: {}\n", exp.name));
550 if let Some(base) = &exp.base {
551 output.push_str(&format!("Base sprite: {}\n", base));
552 }
553 if let Some(size) = exp.size {
554 output.push_str(&format!("Canvas size: {}x{}\n", size[0], size[1]));
555 }
556 output.push_str(&format!("Cell size: {}x{}\n", exp.cell_size[0], exp.cell_size[1]));
557 output.push_str(&format!("Sprite mappings: {}\n", exp.sprite_count));
558 output.push_str(&format!("Layers: {}\n", exp.layer_count));
559
560 output
561}
562
563pub fn format_variant_explanation(exp: &VariantExplanation) -> String {
565 let mut output = String::new();
566
567 output.push_str(&format!("Variant: {}\n", exp.name));
568 output.push_str(&format!("Base sprite: {}\n", exp.base));
569 output.push_str(&format!("Color overrides: {}\n", exp.override_count));
570 output.push('\n');
571
572 if !exp.overrides.is_empty() {
573 output.push_str("PALETTE OVERRIDES\n");
574 output.push_str("-----------------\n");
575 for (token, color) in &exp.overrides {
576 let desc = describe_color(color).map(|n| format!(" ({})", n)).unwrap_or_default();
577 output.push_str(&format!(" {:12} => {}{}\n", token, color, desc));
578 }
579 }
580
581 output
582}
583
584pub fn format_explanation(exp: &Explanation) -> String {
586 match exp {
587 Explanation::Sprite(s) => format_sprite_explanation(s),
588 Explanation::Palette(p) => format_palette_explanation(p),
589 Explanation::Animation(a) => format_animation_explanation(a),
590 Explanation::Composition(c) => format_composition_explanation(c),
591 Explanation::Variant(v) => format_variant_explanation(v),
592 Explanation::Particle(p) => format_particle_explanation(p),
593 Explanation::Transform(t) => format_transform_explanation(t),
594 }
595}
596
597fn format_transform_explanation(t: &TransformExplanation) -> String {
599 let mut lines = vec![format!("Transform: {} ({})", t.name, t.transform_type)];
600 if t.is_parameterized {
601 lines.push(format!(" Parameters: {}", t.params.join(", ")));
602 }
603 if t.generates_animation {
604 if let Some(frames) = t.frame_count {
605 lines.push(format!(" Generates {} animation frames", frames));
606 }
607 }
608 lines.join("\n")
609}
610
611fn format_particle_explanation(p: &ParticleExplanation) -> String {
613 let mut lines = vec![format!("Particle System: {}", p.name)];
614 lines.push(format!(" Sprite: {}", p.sprite));
615 lines.push(format!(" Rate: {} particles/frame", p.rate));
616 lines.push(format!(" Lifetime: {}-{} frames", p.lifetime[0], p.lifetime[1]));
617 if p.has_gravity {
618 lines.push(" Gravity: enabled".to_string());
619 }
620 if p.has_fade {
621 lines.push(" Fade: enabled".to_string());
622 }
623 lines.join("\n")
624}
625
626pub fn resolve_palette_colors(
628 palette_ref: &PaletteRef,
629 known_palettes: &HashMap<String, HashMap<String, String>>,
630) -> Option<HashMap<String, String>> {
631 match palette_ref {
632 PaletteRef::Named(name) => {
633 if name.starts_with('@') {
635 let builtin_name = name.strip_prefix('@').unwrap_or(name);
636 return palettes::get_builtin(builtin_name).map(|p| p.colors.clone());
637 }
638 known_palettes.get(name).cloned()
640 }
641 PaletteRef::Inline(colors) => Some(colors.clone()),
642 }
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648
649 #[test]
650 fn test_describe_color_transparent() {
651 assert_eq!(describe_color("#00000000"), Some("transparent".to_string()));
652 }
653
654 #[test]
655 fn test_describe_color_black_white() {
656 assert_eq!(describe_color("#000000"), Some("black".to_string()));
657 assert_eq!(describe_color("#FFFFFF"), Some("white".to_string()));
658 }
659
660 #[test]
661 fn test_describe_color_primary() {
662 assert!(describe_color("#FF0000").unwrap().contains("red"));
663 assert!(describe_color("#00FF00").unwrap().contains("green"));
664 assert!(describe_color("#0000FF").unwrap().contains("blue"));
665 }
666
667 #[test]
668 fn test_explain_sprite_basic() {
669 let sprite = Sprite {
670 name: "test".to_string(),
671 size: Some([2, 2]),
672 palette: PaletteRef::Inline(HashMap::from([
673 ("{_}".to_string(), "#00000000".to_string()),
674 ("{x}".to_string(), "#FF0000".to_string()),
675 ])),
676 grid: vec!["{_}{x}".to_string(), "{x}{_}".to_string()],
677 metadata: None,
678 ..Default::default()
679 };
680
681 let colors = HashMap::from([
682 ("{_}".to_string(), "#00000000".to_string()),
683 ("{x}".to_string(), "#FF0000".to_string()),
684 ]);
685
686 let exp = explain_sprite(&sprite, Some(&colors));
687
688 assert_eq!(exp.name, "test");
689 assert_eq!(exp.width, 2);
690 assert_eq!(exp.height, 2);
691 assert_eq!(exp.total_cells, 4);
692 assert_eq!(exp.transparent_count, 2);
693 assert!((exp.transparency_ratio - 50.0).abs() < 0.01);
694 assert!(exp.consistent_rows);
695 assert!(exp.issues.is_empty());
696 }
697
698 #[test]
699 fn test_explain_sprite_inconsistent_rows() {
700 let sprite = Sprite {
701 name: "uneven".to_string(),
702 size: None,
703 palette: PaletteRef::Named("test".to_string()),
704 grid: vec!["{a}{b}{c}".to_string(), "{a}{b}".to_string()],
705 metadata: None,
706 ..Default::default()
707 };
708
709 let exp = explain_sprite(&sprite, None);
710
711 assert!(!exp.consistent_rows);
712 assert!(!exp.issues.is_empty());
713 }
714
715 #[test]
716 fn test_explain_palette_basic() {
717 let colors = HashMap::from([
718 ("{_}".to_string(), "#00000000".to_string()),
719 ("{x}".to_string(), "#FF0000".to_string()),
720 ]);
721
722 let exp = explain_palette("test", &colors);
723
724 assert_eq!(exp.name, "test");
725 assert_eq!(exp.color_count, 2);
726 assert!(!exp.is_builtin);
727 }
728
729 #[test]
730 fn test_format_sprite_explanation() {
731 let exp = SpriteExplanation {
732 name: "test".to_string(),
733 width: 8,
734 height: 8,
735 total_cells: 64,
736 palette_ref: "hero".to_string(),
737 tokens: vec![
738 TokenUsage {
739 token: "{_}".to_string(),
740 count: 32,
741 percentage: 50.0,
742 color: Some("#00000000".to_string()),
743 color_name: Some("transparent".to_string()),
744 },
745 TokenUsage {
746 token: "{x}".to_string(),
747 count: 32,
748 percentage: 50.0,
749 color: Some("#FF0000".to_string()),
750 color_name: Some("red".to_string()),
751 },
752 ],
753 transparent_count: 32,
754 transparency_ratio: 50.0,
755 consistent_rows: true,
756 issues: vec![],
757 };
758
759 let output = format_sprite_explanation(&exp);
760
761 assert!(output.contains("Sprite: test"));
762 assert!(output.contains("Size: 8x8"));
763 assert!(output.contains("Palette: hero"));
764 assert!(output.contains("{_}"));
765 assert!(output.contains("{x}"));
766 assert!(output.contains("transparent"));
767 }
768}