a14200ac2f
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>
142 lines
5.6 KiB
Rust
142 lines
5.6 KiB
Rust
//! 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")
|
||
}
|