fix(engine): regenerate default theme SVGs to Terminal aesthetic

Step 4's PNG regeneration left the cards looking unchanged at
runtime because the PNGs at assets/cards/ are only the *fallback*
art — production renders the bundled-default theme's SVGs, which
get include_bytes!()-embedded into the binary by
solitaire_engine::assets::sources and applied to CardImageSet at
startup by theme::plugin::apply_theme_to_card_image_set. Those
SVGs were still the legacy vector-playing-cards art.

Extends card_face_generator to write SVGs into both runtime
paths in lockstep:

1. assets/cards/{faces,backs}/*.png — fallback art (unchanged
   from step 4).
2. solitaire_engine/assets/themes/default/*.svg — what production
   actually renders. 52 face SVGs + 1 back SVG, generated from
   the same face_svg / back_svg builders as the PNGs so the two
   paths can never visually diverge.

Adds two helper functions to card_face_svg:

- theme_suit_token (clubs/diamonds/hearts/spades — lowercase
  full word, matching CardKey::manifest_name)
- theme_rank_token (ace/2..10/jack/queen/king — same)

The theme back uses BACK_ACCENTS[0] (canonical Terminal cyan).
The other four accents only live as PNG fallbacks because the
theme system carries one back per theme.

Net SVG diff: -14884 / +940 lines — the legacy vector-playing-
cards SVGs were ~300 lines each of Inkscape-authored paths;
the Terminal SVGs are ~10 lines of programmatic output.

Workspace clippy + cargo test --workspace clean. Pin test
unaffected (the SVG builders themselves did not change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-08 09:40:24 -07:00
parent e8bf9d79da
commit a14200ac2f
55 changed files with 854 additions and 14798 deletions
@@ -1,5 +1,19 @@
//! Card-face generator — writes 52 Terminal-aesthetic face PNGs +
//! 5 back PNGs into `assets/cards/`.
//! Card-face generator — writes Terminal-aesthetic artwork into both
//! rendering paths the engine consults at runtime:
//!
//! 1. **Asset PNGs at `assets/cards/`** — 52 face + 5 back PNGs loaded
//! by `card_plugin::load_card_images` as the *fallback* art.
//! 2. **Default-theme SVGs at `solitaire_engine/assets/themes/default/`**
//! — 52 face + 1 back SVGs that get `include_bytes!()`-embedded into
//! the binary by `solitaire_engine::assets::sources` and applied to
//! `CardImageSet` at startup by `theme::plugin::apply_theme_to_card_image_set`.
//! These *override* the asset PNGs in production; the PNGs only show
//! if the active theme fails to provide a face.
//!
//! Both paths share the same SVG builders in
//! `solitaire_engine::assets::card_face_svg`, so the artwork stays
//! identical at the source level — running this generator keeps both
//! paths in lockstep.
//!
//! Run with:
//!
@@ -7,63 +21,90 @@
//! cargo run --example card_face_generator --release
//! ```
//!
//! This is **step 2** of the lockstep migration outlined in
//! `docs/ui-mockups/card-face-migration.md`. Running it overwrites
//! the legacy PNG artwork in-place; the resulting bytes are what
//! step 4 commits alongside the `card_plugin` constant migration.
//!
//! The SVG builders live in
//! `solitaire_engine::assets::card_face_svg` so the integration
//! test at `tests/card_face_svg_pin.rs` can pin their output
//! against `usvg`/`resvg` rendering drift.
//! Step 2 of the lockstep migration outlined in
//! `docs/ui-mockups/card-face-migration.md`. Running it overwrites the
//! legacy artwork in-place; the resulting bytes are what step 4 commits
//! alongside the `card_plugin` constant migration.
use solitaire_engine::assets::card_face_svg::{
back_svg, face_svg, rank_filename, suit_filename, ALL_RANKS, ALL_SUITS, BACK_ACCENTS, TARGET,
back_svg, face_svg, rank_filename, suit_filename, theme_rank_token, theme_suit_token,
ALL_RANKS, ALL_SUITS, BACK_ACCENTS, TARGET,
};
use solitaire_engine::assets::rasterize_svg;
use std::path::PathBuf;
use tiny_skia::{IntSize, Pixmap};
fn main() {
let cards_dir = workspace_assets_dir().join("cards");
let workspace_assets = workspace_assets_dir();
let cards_dir = workspace_assets.join("cards");
let faces_dir = cards_dir.join("faces");
let backs_dir = cards_dir.join("backs");
std::fs::create_dir_all(&faces_dir).expect("create faces dir");
std::fs::create_dir_all(&backs_dir).expect("create backs dir");
let mut written = 0usize;
// The default theme lives inside the engine crate (so its SVGs can
// be `include_bytes!()`-embedded relative to the `assets/sources.rs`
// file path). Workspace-level `assets/cards/` is the fallback path;
// engine-level `assets/themes/default/` is what production renders.
let theme_dir = engine_default_theme_dir();
std::fs::create_dir_all(&theme_dir).expect("create default-theme dir");
let mut png_written = 0usize;
let mut svg_written = 0usize;
for suit in ALL_SUITS {
for rank in ALL_RANKS {
let svg = face_svg(rank, suit);
let pixmap = rasterize_to_pixmap(&svg);
let path = faces_dir.join(format!(
// Path 1 — fallback PNG.
let png_path = faces_dir.join(format!(
"{}{}.png",
rank_filename(rank),
suit_filename(suit)
));
pixmap
.save_png(&path)
.unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
written += 1;
rasterize_to_pixmap(&svg)
.save_png(&png_path)
.unwrap_or_else(|e| panic!("write {}: {e}", png_path.display()));
png_written += 1;
// Path 2 — bundled-default-theme SVG. Same SVG bytes; the
// theme system rasterises them at runtime.
let svg_path = theme_dir.join(format!(
"{}_{}.svg",
theme_suit_token(suit),
theme_rank_token(rank),
));
std::fs::write(&svg_path, &svg)
.unwrap_or_else(|e| panic!("write {}: {e}", svg_path.display()));
svg_written += 1;
}
}
// Fallback backs — 5 PNGs, one per `Settings::selected_card_back`.
for (idx, accent) in BACK_ACCENTS.iter().enumerate() {
let svg = back_svg(accent);
let pixmap = rasterize_to_pixmap(&svg);
let path = backs_dir.join(format!("back_{idx}.png"));
pixmap
.save_png(&path)
.unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
written += 1;
let png_path = backs_dir.join(format!("back_{idx}.png"));
rasterize_to_pixmap(&svg)
.save_png(&png_path)
.unwrap_or_else(|e| panic!("write {}: {e}", png_path.display()));
png_written += 1;
}
// Theme back — single SVG. Use the canonical Terminal accent
// (`BACK_ACCENTS[0]` cyan) — the theme system only carries one back
// per theme, and the canonical Terminal back is the design-system
// default. The other four accents only live as PNG fallbacks.
let theme_back_path = theme_dir.join("back.svg");
std::fs::write(&theme_back_path, back_svg(BACK_ACCENTS[0]))
.unwrap_or_else(|e| panic!("write {}: {e}", theme_back_path.display()));
svg_written += 1;
println!(
"Wrote {written} PNGs ({}×{} RGBA8) to {}",
"Wrote {png_written} PNGs ({}×{} RGBA8) to {} and {svg_written} SVGs to {}",
TARGET.x,
TARGET.y,
cards_dir.display(),
theme_dir.display(),
);
}
@@ -88,3 +129,13 @@ fn workspace_assets_dir() -> PathBuf {
.expect("solitaire_engine crate has a workspace-root parent")
.join("assets")
}
/// Resolves `solitaire_engine/assets/themes/default/` relative to the
/// example crate. Matches `DEFAULT_THEME_MANIFEST_PATH` in
/// `solitaire_engine::assets::sources`.
fn engine_default_theme_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("assets")
.join("themes")
.join("default")
}