Files
funman300 3a4bb63a6f 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>
2026-05-08 09:08:13 -07:00

122 lines
5.0 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 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>"##
)
}