Files
funman300 a14200ac2f 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>
2026-05-08 09:40:24 -07:00

142 lines
5.6 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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:
//!
//! ```sh
//! cargo run --example card_face_generator --release
//! ```
//!
//! 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, 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 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");
// 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);
// Path 1 — fallback PNG.
let png_path = faces_dir.join(format!(
"{}{}.png",
rank_filename(rank),
suit_filename(suit)
));
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 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 {png_written} PNGs ({}×{} RGBA8) to {} and {svg_written} SVGs to {}",
TARGET.x,
TARGET.y,
cards_dir.display(),
theme_dir.display(),
);
}
fn rasterize_to_pixmap(svg: &str) -> Pixmap {
let image = rasterize_svg(svg.as_bytes(), TARGET).expect("rasterise card SVG");
let bytes = image.data.expect("rasterised image carries pixel data");
debug_assert_eq!(
bytes.len(),
(TARGET.x * TARGET.y * 4) as usize,
"rasterised buffer must match width × height × 4 RGBA bytes",
);
let size = IntSize::from_wh(TARGET.x, TARGET.y).expect("non-zero target size");
Pixmap::from_vec(bytes, size).expect("RGBA buffer forms a valid Pixmap")
}
/// Resolves the workspace-root `assets/` directory relative to the
/// running example crate (`solitaire_engine/`). `CARGO_MANIFEST_DIR`
/// is the engine crate; its parent is the workspace root.
fn workspace_assets_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.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")
}