feat(engine): add card-face SVG generator PoC example

Rasterises one Ace of Spades to /tmp/ace_spades_terminal.png via
the existing usvg + resvg + tiny_skia stack already used by
svg_loader. Proves the per-card grain works before looping over
all 52 faces + 5 backs in step 2 of the migration plan.

Run with: cargo run --example card_face_poc --release

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-08 09:08:13 -07:00
parent 56233687b0
commit 3a4bb63a6f
+121
View File
@@ -0,0 +1,121 @@
//! Card-face migration PoC — generates a Terminal-aesthetic Ace of
//! Spades to `/tmp/ace_spades_terminal.png`.
//!
//! Tracks `docs/ui-mockups/card-face-migration.md`'s recommended
//! generation pipeline (programmatic SVG via the existing
//! `usvg` + `resvg` + `tiny_skia` stack already used by
//! `solitaire_engine::assets::svg_loader`). One card is enough to
//! prove the pipeline; the next step in the migration loops the
//! same code over all 52 faces + 5 backs.
//!
//! Run with:
//!
//! ```sh
//! cargo run --example card_face_poc --release
//! ```
//!
//! The example writes the PNG to `/tmp` rather than `assets/` so a
//! human can eyeball it before committing to a deck-wide rollout.
//! When the migration lands, the generator graduates into a real
//! binary that writes into `assets/cards/`.
//!
//! What this PoC proves:
//!
//! 1. The SVG-to-PNG pipeline produces valid output for a card-shaped
//! SVG (the existing rasteriser is used for full theme atlases
//! today; this confirms it works at the per-card grain).
//! 2. The Terminal palette (`#1a1a1a` background, `#d0d0d0` foreground
//! for spade glyphs) renders correctly.
//! 3. The bundled FiraMono in `svg_loader::shared_fontdb` resolves
//! `font-family="Fira Mono"` and renders both the rank "A" and
//! the Unicode suit glyph `♠` (U+2660).
//! 4. The 8 px corner radius (per `design-system.md`) renders as a
//! rounded rect at the 256×384 output size.
//! 5. SVG `transform="rotate(180 …)"` produces the bottom-right
//! flipped suit glyph called for in the spec.
use bevy::math::UVec2;
use solitaire_engine::assets::rasterize_svg;
use tiny_skia::{IntSize, Pixmap};
fn main() {
let svg = ace_of_spades_svg();
// 256×384 = 2:3 aspect at half the default svg_loader resolution.
// See migration plan § "Output format" for the rationale.
let target = UVec2::new(256, 384);
let image = rasterize_svg(svg.as_bytes(), target)
.expect("rasterising the PoC SVG should succeed");
let bytes = image
.data
.expect("rasterized image must carry RGBA pixel data");
assert_eq!(
bytes.len(),
(target.x * target.y * 4) as usize,
"raw byte count must match width × height × 4 RGBA bytes",
);
// Re-wrap the raw bytes in a `tiny_skia::Pixmap` so we can write
// a PNG via `save_png`. `rasterize_svg` already produced these
// bytes from a Pixmap inside `svg_loader`; this round-trip is
// the cost of going through Bevy's `Image` shape.
let size = IntSize::from_wh(target.x, target.y).expect("target size is non-zero");
let pixmap = Pixmap::from_vec(bytes, size)
.expect("RGBA byte buffer should form a valid Pixmap");
let out = "/tmp/ace_spades_terminal.png";
pixmap.save_png(out).expect("writing the PNG should succeed");
println!(
"Wrote {} ({}×{} RGBA8, {} bytes on disk)",
out,
target.x,
target.y,
std::fs::metadata(out).map(|m| m.len()).unwrap_or(0),
);
}
/// Builds the Ace-of-Spades SVG matching `design-system.md`
/// § Game Cards. The numbers below are the spec's logical sizes
/// scaled by 2× for the 256×384 output target (the spec describes
/// pixel sizes for a 128×192 logical card; doubling preserves the
/// visual proportions).
fn ace_of_spades_svg() -> String {
// Palette literals come straight from the design system. Quoted
// verbatim rather than constructed via the engine's `ui_theme`
// because the SVG renderer expects CSS colour strings, and a
// round-trip through `Color::srgb` → CSS would be both lossier
// and noisier than the inline string.
let bg = "#1a1a1a"; // BG_ELEVATED — card face
let suit = "#d0d0d0"; // TEXT_PRIMARY — spades use the foreground gray
// Corner radius: 8 px logical → 16 px at 2× scale.
// Border: 1 px solid in suit colour → 2 px at 2× scale.
// Rank: JetBrains Mono Bold 18 px → 36 px. The bundled fontdb
// ships FiraMono only; usvg substitutes when JetBrains Mono
// isn't available, so we explicitly request `Fira Mono` to
// skip the substitution lookup.
// Small suit glyph: 10 px → 20 px.
// Large suit glyph: 32 px → 64 px, rotated 180° about its own
// centre so the bottom-right corner reads as the top-left
// when you flip the card.
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="1" y="1" width="254" height="382" rx="16" ry="16"
fill="{bg}" stroke="{suit}" stroke-width="2"/>
<!-- Top-left rank + small suit glyph. -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="{suit}">A</text>
<text x="14" y="68" font-family="Fira Mono" font-size="20"
fill="{suit}">&#x2660;</text>
<!-- Bottom-right large suit glyph, rotated 180° about its own
baseline anchor so the glyph reads upside-down. -->
<text x="242" y="350" font-family="Fira Mono" font-size="64"
fill="{suit}" text-anchor="end"
transform="rotate(180 242 332)">&#x2660;</text>
</svg>"##
)
}