test(engine): pin card-face SVG output against rasteriser drift
Step 3 of the migration plan in docs/ui-mockups/card-face-migration.md. Extracts face_svg / back_svg + palette constants from the card_face_generator example into a new solitaire_engine::assets::card_face_svg module so an integration test can call them. The example becomes a thin wrapper. The new tests/card_face_svg_pin.rs hashes the raw RGBA8 pixel bytes from rasterising every face × suit + every back accent and compares each FNV-1a fingerprint against an embedded constant. Catches silent rendering drift if usvg / resvg / tiny_skia / the bundled FiraMono ever change in a way that perturbs pixels. Hashing is FNV-1a inline (~5 lines) rather than adding sha2 or blake3 — cryptographic strength isn't load-bearing here, just stable byte fingerprints. When the SVG builders intentionally change, empty EXPECTED to `&[]` and re-run the test once; it panics with the new hashes formatted as Rust source ready to paste back in. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -12,51 +12,18 @@
|
|||||||
//! the legacy PNG artwork in-place; the resulting bytes are what
|
//! the legacy PNG artwork in-place; the resulting bytes are what
|
||||||
//! step 4 commits alongside the `card_plugin` constant migration.
|
//! step 4 commits alongside the `card_plugin` constant migration.
|
||||||
//!
|
//!
|
||||||
//! Output paths (matching the filenames `card_plugin::load_card_images`
|
//! The SVG builders live in
|
||||||
//! already loads):
|
//! `solitaire_engine::assets::card_face_svg` so the integration
|
||||||
//!
|
//! test at `tests/card_face_svg_pin.rs` can pin their output
|
||||||
//! - Faces: `assets/cards/faces/<RANK><SUIT>.png`
|
//! against `usvg`/`resvg` rendering drift.
|
||||||
//! where `RANK` ∈ `{A, 2..10, J, Q, K}` and `SUIT` ∈ `{C, D, H, S}`.
|
|
||||||
//! - Backs: `assets/cards/backs/back_{0..4}.png`.
|
|
||||||
//!
|
|
||||||
//! All output is 256 × 384 RGBA8 sRGB — half the default
|
|
||||||
//! `SvgLoaderSettings` resolution, sufficient for ~250 px-wide
|
|
||||||
//! desktop card sprites and ≈⅓ the disk weight of the legacy 512 ×
|
|
||||||
//! 768 art (per the migration plan's "Output format" rationale).
|
|
||||||
|
|
||||||
use bevy::math::UVec2;
|
use solitaire_engine::assets::card_face_svg::{
|
||||||
use solitaire_core::card::{Rank, Suit};
|
back_svg, face_svg, rank_filename, suit_filename, ALL_RANKS, ALL_SUITS, BACK_ACCENTS, TARGET,
|
||||||
|
};
|
||||||
use solitaire_engine::assets::rasterize_svg;
|
use solitaire_engine::assets::rasterize_svg;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tiny_skia::{IntSize, Pixmap};
|
use tiny_skia::{IntSize, Pixmap};
|
||||||
|
|
||||||
// 2× the design-system logical sizes — the spec describes a 128 ×
|
|
||||||
// 192 logical card, this generator targets 256 × 384.
|
|
||||||
const TARGET: UVec2 = UVec2::new(256, 384);
|
|
||||||
|
|
||||||
// Palette literals — base16-eighties, mirroring `design-system.md`.
|
|
||||||
const BG_FACE: &str = "#1a1a1a"; // BG_ELEVATED
|
|
||||||
const SUIT_RED: &str = "#fb9fb1"; // hearts + diamonds
|
|
||||||
const SUIT_DARK: &str = "#d0d0d0"; // spades + clubs (also TEXT_PRIMARY)
|
|
||||||
|
|
||||||
// Card-back palette.
|
|
||||||
const BACK_BG: &str = "#151515";
|
|
||||||
const BACK_SCANLINE: &str = "#1a1a1a";
|
|
||||||
const BACK_BORDER: &str = "#353535";
|
|
||||||
const BACK_MONOGRAM: &str = "#505050";
|
|
||||||
|
|
||||||
// Five back-theme accent colours. Slot 0 is the canonical "Terminal"
|
|
||||||
// back from the design system; the other four cycle through the
|
|
||||||
// remaining base16-eighties accents so all 5 slots stay visually
|
|
||||||
// distinct without leaving the palette.
|
|
||||||
const BACK_ACCENTS: [&str; 5] = [
|
|
||||||
"#6fc2ef", // 0 — cyan (Terminal canonical)
|
|
||||||
"#acc267", // 1 — lime
|
|
||||||
"#e1a3ee", // 2 — lavender
|
|
||||||
"#fb9fb1", // 3 — pink
|
|
||||||
"#ddb26f", // 4 — gold
|
|
||||||
];
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let cards_dir = workspace_assets_dir().join("cards");
|
let cards_dir = workspace_assets_dir().join("cards");
|
||||||
let faces_dir = cards_dir.join("faces");
|
let faces_dir = cards_dir.join("faces");
|
||||||
@@ -66,11 +33,15 @@ fn main() {
|
|||||||
|
|
||||||
let mut written = 0usize;
|
let mut written = 0usize;
|
||||||
|
|
||||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
for suit in ALL_SUITS {
|
||||||
for rank in ALL_RANKS {
|
for rank in ALL_RANKS {
|
||||||
let svg = face_svg(rank, suit);
|
let svg = face_svg(rank, suit);
|
||||||
let pixmap = rasterize_to_pixmap(&svg);
|
let pixmap = rasterize_to_pixmap(&svg);
|
||||||
let path = faces_dir.join(format!("{}{}.png", rank_str(rank), suit_char(suit)));
|
let path = faces_dir.join(format!(
|
||||||
|
"{}{}.png",
|
||||||
|
rank_filename(rank),
|
||||||
|
suit_filename(suit)
|
||||||
|
));
|
||||||
pixmap
|
pixmap
|
||||||
.save_png(&path)
|
.save_png(&path)
|
||||||
.unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
|
.unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
|
||||||
@@ -96,163 +67,6 @@ fn main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_RANKS: [Rank; 13] = [
|
|
||||||
Rank::Ace,
|
|
||||||
Rank::Two,
|
|
||||||
Rank::Three,
|
|
||||||
Rank::Four,
|
|
||||||
Rank::Five,
|
|
||||||
Rank::Six,
|
|
||||||
Rank::Seven,
|
|
||||||
Rank::Eight,
|
|
||||||
Rank::Nine,
|
|
||||||
Rank::Ten,
|
|
||||||
Rank::Jack,
|
|
||||||
Rank::Queen,
|
|
||||||
Rank::King,
|
|
||||||
];
|
|
||||||
|
|
||||||
fn rank_str(rank: Rank) -> &'static str {
|
|
||||||
match rank {
|
|
||||||
Rank::Ace => "A",
|
|
||||||
Rank::Two => "2",
|
|
||||||
Rank::Three => "3",
|
|
||||||
Rank::Four => "4",
|
|
||||||
Rank::Five => "5",
|
|
||||||
Rank::Six => "6",
|
|
||||||
Rank::Seven => "7",
|
|
||||||
Rank::Eight => "8",
|
|
||||||
Rank::Nine => "9",
|
|
||||||
Rank::Ten => "10",
|
|
||||||
Rank::Jack => "J",
|
|
||||||
Rank::Queen => "Q",
|
|
||||||
Rank::King => "K",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn suit_char(suit: Suit) -> &'static str {
|
|
||||||
match suit {
|
|
||||||
Suit::Clubs => "C",
|
|
||||||
Suit::Diamonds => "D",
|
|
||||||
Suit::Hearts => "H",
|
|
||||||
Suit::Spades => "S",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the suit colour and the `<text>` paint attributes for the
|
|
||||||
/// glyph (filled vs outlined). Hearts + spades are filled; diamonds +
|
|
||||||
/// clubs are outlined — the "always-on" color-blind glyph
|
|
||||||
/// differentiation from the design system.
|
|
||||||
fn suit_paint(suit: Suit) -> (&'static str, GlyphPaint) {
|
|
||||||
match suit {
|
|
||||||
Suit::Hearts => (SUIT_RED, GlyphPaint::Filled),
|
|
||||||
Suit::Diamonds => (SUIT_RED, GlyphPaint::Outlined),
|
|
||||||
Suit::Spades => (SUIT_DARK, GlyphPaint::Filled),
|
|
||||||
Suit::Clubs => (SUIT_DARK, GlyphPaint::Outlined),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone)]
|
|
||||||
enum GlyphPaint {
|
|
||||||
Filled,
|
|
||||||
/// 1.5 px stroke at logical scale → 3 px at 2× output.
|
|
||||||
Outlined,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn glyph_paint_attrs(colour: &str, paint: GlyphPaint) -> String {
|
|
||||||
match paint {
|
|
||||||
GlyphPaint::Filled => format!(r#"fill="{colour}""#),
|
|
||||||
GlyphPaint::Outlined => {
|
|
||||||
format!(r#"fill="none" stroke="{colour}" stroke-width="3""#)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn suit_glyph(suit: Suit) -> &'static str {
|
|
||||||
match suit {
|
|
||||||
Suit::Clubs => "♣",
|
|
||||||
Suit::Diamonds => "♦",
|
|
||||||
Suit::Hearts => "♥",
|
|
||||||
Suit::Spades => "♠",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the face-card SVG. Sizes are doubled from the design-system
|
|
||||||
/// logical pixels (the spec describes a 128 × 192 card; we emit at
|
|
||||||
/// 256 × 384).
|
|
||||||
fn face_svg(rank: Rank, suit: Suit) -> String {
|
|
||||||
let (colour, paint) = suit_paint(suit);
|
|
||||||
let glyph = suit_glyph(suit);
|
|
||||||
let rank_text = rank_str(rank);
|
|
||||||
let small_glyph_attrs = glyph_paint_attrs(colour, paint);
|
|
||||||
let large_glyph_attrs = glyph_paint_attrs(colour, paint);
|
|
||||||
|
|
||||||
// Numbers come from `design-system.md` § Game Cards, scaled 2×:
|
|
||||||
// border: 1 px → 2 px stroke-width
|
|
||||||
// corner radius: 8 px → 16 px rx/ry
|
|
||||||
// rank font: 18 px → 36 px
|
|
||||||
// small glyph: 10 px → 20 px
|
|
||||||
// large glyph: 32 px → 64 px
|
|
||||||
//
|
|
||||||
// Inset the border by 1 px (`x="1" y="1" width="254" height="382"`)
|
|
||||||
// so the 2 px stroke renders fully inside the 256 × 384 pixmap
|
|
||||||
// rather than getting clipped at the edge.
|
|
||||||
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_FACE}" stroke="{colour}" 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="{colour}">{rank_text}</text>
|
|
||||||
<text x="14" y="68" font-family="Fira Mono" font-size="20"
|
|
||||||
{small_glyph_attrs}>{glyph}</text>
|
|
||||||
|
|
||||||
<!-- Bottom-right large suit glyph, rotated 180° so it reads
|
|
||||||
upside-down (the convention for inverted-corner indicators). -->
|
|
||||||
<text x="242" y="350" font-family="Fira Mono" font-size="64"
|
|
||||||
text-anchor="end" {large_glyph_attrs}
|
|
||||||
transform="rotate(180 242 332)">{glyph}</text>
|
|
||||||
</svg>"##
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a card-back SVG with the canonical Terminal scanline
|
|
||||||
/// pattern. `accent` swaps only the top-left badge — every other
|
|
||||||
/// element stays palette-locked so all 5 backs read as members of
|
|
||||||
/// the same family.
|
|
||||||
fn back_svg(accent: &str) -> String {
|
|
||||||
// Pattern tile: 1 px line + 1 px gap at logical scale → 2 px +
|
|
||||||
// 2 px at 2× output. `patternUnits="userSpaceOnUse"` so the tile
|
|
||||||
// size is in viewBox pixels rather than fractions of the box.
|
|
||||||
//
|
|
||||||
// Badge: 12 × 16 px logical → 24 × 32 px output, 12 px from corner.
|
|
||||||
// Monogram: "▌RS" in 12 px logical → 24 px output, 12 px inset.
|
|
||||||
format!(
|
|
||||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
|
||||||
<defs>
|
|
||||||
<pattern id="scanlines" x="0" y="0" width="2" height="4" patternUnits="userSpaceOnUse">
|
|
||||||
<rect x="0" y="0" width="2" height="2" fill="{BACK_SCANLINE}"/>
|
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Background fill, then scanlines on top (the scanlines stay
|
|
||||||
darker than BACK_BG so the "off" rows show through). -->
|
|
||||||
<rect x="1" y="1" width="254" height="382" rx="16" ry="16"
|
|
||||||
fill="{BACK_BG}" stroke="{BACK_BORDER}" stroke-width="2"/>
|
|
||||||
<rect x="1" y="1" width="254" height="382" rx="16" ry="16"
|
|
||||||
fill="url(#scanlines)"/>
|
|
||||||
|
|
||||||
<!-- Top-left accent badge (the only theme-varying element). -->
|
|
||||||
<rect x="12" y="12" width="24" height="32" fill="{accent}"/>
|
|
||||||
|
|
||||||
<!-- Bottom-right "▌RS" monogram in JetBrains-Mono-styled FiraMono. -->
|
|
||||||
<text x="244" y="368" font-family="Fira Mono" font-size="24"
|
|
||||||
fill="{BACK_MONOGRAM}" text-anchor="end">▌RS</text>
|
|
||||||
</svg>"##
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rasterize_to_pixmap(svg: &str) -> Pixmap {
|
fn rasterize_to_pixmap(svg: &str) -> Pixmap {
|
||||||
let image = rasterize_svg(svg.as_bytes(), TARGET).expect("rasterise card SVG");
|
let image = rasterize_svg(svg.as_bytes(), TARGET).expect("rasterise card SVG");
|
||||||
let bytes = image.data.expect("rasterised image carries pixel data");
|
let bytes = image.data.expect("rasterised image carries pixel data");
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
//! SVG builders for the Terminal-aesthetic card-face artwork.
|
||||||
|
//!
|
||||||
|
//! Used by the `card_face_generator` example to emit the 52 face PNGs +
|
||||||
|
//! 5 back PNGs into `assets/cards/`, and by the `card_face_svg_pin`
|
||||||
|
//! integration test to pin the rendered output against `usvg`/`resvg`
|
||||||
|
//! drift.
|
||||||
|
//!
|
||||||
|
//! The numbers below are 2× the `design-system.md` § Game Cards
|
||||||
|
//! logical sizes — the spec describes a 128 × 192 logical card and
|
||||||
|
//! this module emits at 256 × 384.
|
||||||
|
//!
|
||||||
|
//! See `docs/ui-mockups/card-face-migration.md` for the full
|
||||||
|
//! migration plan and the rationale behind the output dimensions
|
||||||
|
//! and palette mapping.
|
||||||
|
//!
|
||||||
|
//! # Filled vs outlined glyphs
|
||||||
|
//!
|
||||||
|
//! Hearts (♥) and spades (♠) render as filled glyphs. Diamonds (♦)
|
||||||
|
//! and clubs (♣) render as outlined glyphs (1.5 px stroke at logical
|
||||||
|
//! scale → 3 px at output). This is the design-system's "always-on"
|
||||||
|
//! color-blind glyph differentiation and is independent of the
|
||||||
|
//! red/black colour split.
|
||||||
|
|
||||||
|
use bevy::math::UVec2;
|
||||||
|
use solitaire_core::card::{Rank, Suit};
|
||||||
|
|
||||||
|
/// Target rasterisation size in pixels (2:3 aspect, half the default
|
||||||
|
/// `SvgLoaderSettings` resolution).
|
||||||
|
pub const TARGET: UVec2 = UVec2::new(256, 384);
|
||||||
|
|
||||||
|
const BG_FACE: &str = "#1a1a1a"; // BG_ELEVATED — face background
|
||||||
|
const SUIT_RED: &str = "#fb9fb1"; // hearts + diamonds
|
||||||
|
const SUIT_DARK: &str = "#d0d0d0"; // spades + clubs (also TEXT_PRIMARY)
|
||||||
|
|
||||||
|
const BACK_BG: &str = "#151515";
|
||||||
|
const BACK_SCANLINE: &str = "#1a1a1a";
|
||||||
|
const BACK_BORDER: &str = "#353535";
|
||||||
|
const BACK_MONOGRAM: &str = "#505050";
|
||||||
|
|
||||||
|
/// Five back-theme accent colours. Slot 0 is the canonical "Terminal"
|
||||||
|
/// back from the design system; the other four cycle through the
|
||||||
|
/// remaining base16-eighties accents so all 5 slots stay visually
|
||||||
|
/// distinct without leaving the palette.
|
||||||
|
pub const BACK_ACCENTS: [&str; 5] = [
|
||||||
|
"#6fc2ef", // 0 — cyan (Terminal canonical)
|
||||||
|
"#acc267", // 1 — lime
|
||||||
|
"#e1a3ee", // 2 — lavender
|
||||||
|
"#fb9fb1", // 3 — pink
|
||||||
|
"#ddb26f", // 4 — gold
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Every rank in the canonical Ace → King order. Mirrors the order
|
||||||
|
/// `card_plugin::load_card_images` uses to index `CardImageSet.faces`.
|
||||||
|
pub const ALL_RANKS: [Rank; 13] = [
|
||||||
|
Rank::Ace,
|
||||||
|
Rank::Two,
|
||||||
|
Rank::Three,
|
||||||
|
Rank::Four,
|
||||||
|
Rank::Five,
|
||||||
|
Rank::Six,
|
||||||
|
Rank::Seven,
|
||||||
|
Rank::Eight,
|
||||||
|
Rank::Nine,
|
||||||
|
Rank::Ten,
|
||||||
|
Rank::Jack,
|
||||||
|
Rank::Queen,
|
||||||
|
Rank::King,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Every suit in `Clubs, Diamonds, Hearts, Spades` order — matches
|
||||||
|
/// `card_plugin::load_card_images` so the suit index used here lines
|
||||||
|
/// up with `CardImageSet.faces[suit]`.
|
||||||
|
pub const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
|
|
||||||
|
/// The rank component of the on-disk filename — `A`, `2`..`10`, `J`,
|
||||||
|
/// `Q`, `K`. Matches `card_plugin::load_card_images`'s `RANK_STRS`.
|
||||||
|
pub fn rank_filename(rank: Rank) -> &'static str {
|
||||||
|
match rank {
|
||||||
|
Rank::Ace => "A",
|
||||||
|
Rank::Two => "2",
|
||||||
|
Rank::Three => "3",
|
||||||
|
Rank::Four => "4",
|
||||||
|
Rank::Five => "5",
|
||||||
|
Rank::Six => "6",
|
||||||
|
Rank::Seven => "7",
|
||||||
|
Rank::Eight => "8",
|
||||||
|
Rank::Nine => "9",
|
||||||
|
Rank::Ten => "10",
|
||||||
|
Rank::Jack => "J",
|
||||||
|
Rank::Queen => "Q",
|
||||||
|
Rank::King => "K",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The suit component of the on-disk filename — `C`, `D`, `H`, `S`.
|
||||||
|
/// Matches `card_plugin::load_card_images`'s `SUIT_CHARS`.
|
||||||
|
pub fn suit_filename(suit: Suit) -> &'static str {
|
||||||
|
match suit {
|
||||||
|
Suit::Clubs => "C",
|
||||||
|
Suit::Diamonds => "D",
|
||||||
|
Suit::Hearts => "H",
|
||||||
|
Suit::Spades => "S",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
|
enum GlyphPaint {
|
||||||
|
Filled,
|
||||||
|
/// 1.5 px stroke at logical scale → 3 px at 2× output.
|
||||||
|
Outlined,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn suit_paint(suit: Suit) -> (&'static str, GlyphPaint) {
|
||||||
|
match suit {
|
||||||
|
Suit::Hearts => (SUIT_RED, GlyphPaint::Filled),
|
||||||
|
Suit::Diamonds => (SUIT_RED, GlyphPaint::Outlined),
|
||||||
|
Suit::Spades => (SUIT_DARK, GlyphPaint::Filled),
|
||||||
|
Suit::Clubs => (SUIT_DARK, GlyphPaint::Outlined),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glyph_paint_attrs(colour: &str, paint: GlyphPaint) -> String {
|
||||||
|
match paint {
|
||||||
|
GlyphPaint::Filled => format!(r#"fill="{colour}""#),
|
||||||
|
GlyphPaint::Outlined => {
|
||||||
|
format!(r#"fill="none" stroke="{colour}" stroke-width="3""#)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn suit_glyph(suit: Suit) -> &'static str {
|
||||||
|
match suit {
|
||||||
|
Suit::Clubs => "♣",
|
||||||
|
Suit::Diamonds => "♦",
|
||||||
|
Suit::Hearts => "♥",
|
||||||
|
Suit::Spades => "♠",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the SVG markup for a single face card. The output is a
|
||||||
|
/// self-contained, parsable SVG document.
|
||||||
|
pub fn face_svg(rank: Rank, suit: Suit) -> String {
|
||||||
|
let (colour, paint) = suit_paint(suit);
|
||||||
|
let glyph = suit_glyph(suit);
|
||||||
|
let rank_text = rank_filename(rank);
|
||||||
|
let small_glyph_attrs = glyph_paint_attrs(colour, paint);
|
||||||
|
let large_glyph_attrs = glyph_paint_attrs(colour, paint);
|
||||||
|
|
||||||
|
// Numbers come from `design-system.md` § Game Cards, scaled 2×:
|
||||||
|
// border: 1 px → 2 px stroke-width
|
||||||
|
// corner radius: 8 px → 16 px rx/ry
|
||||||
|
// rank font: 18 px → 36 px
|
||||||
|
// small glyph: 10 px → 20 px
|
||||||
|
// large glyph: 32 px → 64 px
|
||||||
|
//
|
||||||
|
// Inset the border by 1 px so the 2 px stroke renders fully
|
||||||
|
// inside the 256 × 384 pixmap rather than getting clipped.
|
||||||
|
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_FACE}" stroke="{colour}" 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="{colour}">{rank_text}</text>
|
||||||
|
<text x="14" y="68" font-family="Fira Mono" font-size="20"
|
||||||
|
{small_glyph_attrs}>{glyph}</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"
|
||||||
|
text-anchor="end" {large_glyph_attrs}
|
||||||
|
transform="rotate(180 242 332)">{glyph}</text>
|
||||||
|
</svg>"##
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the SVG markup for a card back with the canonical Terminal
|
||||||
|
/// scanline pattern. `accent` swaps only the top-left badge.
|
||||||
|
pub fn back_svg(accent: &str) -> String {
|
||||||
|
// Scanline tile: 1 px line + 1 px gap at logical scale → 2 px +
|
||||||
|
// 2 px at 2× output. `patternUnits="userSpaceOnUse"` so the tile
|
||||||
|
// size is in viewBox pixels rather than fractions of the box.
|
||||||
|
//
|
||||||
|
// Badge: 12 × 16 px logical → 24 × 32 px output, 12 px from corner.
|
||||||
|
// Monogram: "▌RS" in 12 px logical → 24 px output, 12 px inset.
|
||||||
|
format!(
|
||||||
|
r##"<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<defs>
|
||||||
|
<pattern id="scanlines" x="0" y="0" width="2" height="4" patternUnits="userSpaceOnUse">
|
||||||
|
<rect x="0" y="0" width="2" height="2" fill="{BACK_SCANLINE}"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background fill, then scanlines on top (the scanlines stay
|
||||||
|
darker than BACK_BG so the "off" rows show through). -->
|
||||||
|
<rect x="1" y="1" width="254" height="382" rx="16" ry="16"
|
||||||
|
fill="{BACK_BG}" stroke="{BACK_BORDER}" stroke-width="2"/>
|
||||||
|
<rect x="1" y="1" width="254" height="382" rx="16" ry="16"
|
||||||
|
fill="url(#scanlines)"/>
|
||||||
|
|
||||||
|
<!-- Top-left accent badge (the only theme-varying element). -->
|
||||||
|
<rect x="12" y="12" width="24" height="32" fill="{accent}"/>
|
||||||
|
|
||||||
|
<!-- Bottom-right "▌RS" monogram in JetBrains-Mono-styled FiraMono. -->
|
||||||
|
<text x="244" y="368" font-family="Fira Mono" font-size="24"
|
||||||
|
fill="{BACK_MONOGRAM}" text-anchor="end">▌RS</text>
|
||||||
|
</svg>"##
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
//! (user-themes directory). Phase 3 will extend it further with custom
|
//! (user-themes directory). Phase 3 will extend it further with custom
|
||||||
//! `AssetSource` implementations for `embedded://` and `themes://`.
|
//! `AssetSource` implementations for `embedded://` and `themes://`.
|
||||||
|
|
||||||
|
pub mod card_face_svg;
|
||||||
pub mod sources;
|
pub mod sources;
|
||||||
pub mod svg_loader;
|
pub mod svg_loader;
|
||||||
pub mod user_dir;
|
pub mod user_dir;
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
//! Pinning test for the Terminal card-face SVG builders.
|
||||||
|
//!
|
||||||
|
//! Hashes the raw RGBA8 pixel bytes produced by rasterising every
|
||||||
|
//! `face_svg` × `back_svg` output through `assets::rasterize_svg`,
|
||||||
|
//! and compares each hash to an embedded constant. Catches silent
|
||||||
|
//! rendering drift if `usvg`, `resvg`, `tiny_skia`, or the bundled
|
||||||
|
//! `FiraMono` font ever change in a way that perturbs the rendered
|
||||||
|
//! pixels.
|
||||||
|
//!
|
||||||
|
//! When the SVG builders intentionally change (or a dependency
|
||||||
|
//! upgrade legitimately changes rendering), update `EXPECTED` by
|
||||||
|
//! emptying it (`&[]`) and re-running this test once — the test
|
||||||
|
//! will panic with the new hashes formatted as Rust source ready
|
||||||
|
//! to paste back in.
|
||||||
|
//!
|
||||||
|
//! Hashing is FNV-1a 64-bit on the raw RGBA byte buffer. PNG
|
||||||
|
//! compression is intentionally not in the loop — we only want
|
||||||
|
//! the test to fire on actual pixel changes, not zlib-level
|
||||||
|
//! shifts that don't affect what a player sees.
|
||||||
|
|
||||||
|
use solitaire_engine::assets::card_face_svg::{
|
||||||
|
back_svg, face_svg, rank_filename, suit_filename, ALL_RANKS, ALL_SUITS, BACK_ACCENTS, TARGET,
|
||||||
|
};
|
||||||
|
use solitaire_engine::assets::rasterize_svg;
|
||||||
|
|
||||||
|
const EXPECTED: &[(&str, u64)] = &[
|
||||||
|
("face_AC", 0xca11dff5bb9f0eb0),
|
||||||
|
("face_2C", 0xc929a25f0f217577),
|
||||||
|
("face_3C", 0xdaede8383266b5c3),
|
||||||
|
("face_4C", 0xeaa3ea51866f69e5),
|
||||||
|
("face_5C", 0xe5a74589cb09cc5c),
|
||||||
|
("face_6C", 0xdbbc1036895ee08e),
|
||||||
|
("face_7C", 0xb8a28119a85ccf5d),
|
||||||
|
("face_8C", 0xab4d19ce4b8d15e7),
|
||||||
|
("face_9C", 0x17c95eb07f382059),
|
||||||
|
("face_10C", 0x1f1b2c84e42211b1),
|
||||||
|
("face_JC", 0xd87c45124df8b03d),
|
||||||
|
("face_QC", 0xe23701b6685994b2),
|
||||||
|
("face_KC", 0xc628e55b8a15472a),
|
||||||
|
("face_AD", 0x49a140d84b0a731b),
|
||||||
|
("face_2D", 0x713f755b5ecfb67a),
|
||||||
|
("face_3D", 0xe59a72abc47af7d4),
|
||||||
|
("face_4D", 0xf75ac828822079d1),
|
||||||
|
("face_5D", 0x6db0cc9a5849395f),
|
||||||
|
("face_6D", 0x9b034cf6851512de),
|
||||||
|
("face_7D", 0x85f96e0326780a6e),
|
||||||
|
("face_8D", 0x59ec5533b615ecd4),
|
||||||
|
("face_9D", 0x3689911671b30921),
|
||||||
|
("face_10D", 0x682684217e3e8b60),
|
||||||
|
("face_JD", 0xd999f85e6862c5a7),
|
||||||
|
("face_QD", 0x6db493a3b370b211),
|
||||||
|
("face_KD", 0x4c2ec19166fdee7b),
|
||||||
|
("face_AH", 0x0d41c498281b9a74),
|
||||||
|
("face_2H", 0xec6493b71d4576b1),
|
||||||
|
("face_3H", 0xd2fb4b5956caf15b),
|
||||||
|
("face_4H", 0xfbe8e1eaa2b28c5a),
|
||||||
|
("face_5H", 0x649a0964e549f008),
|
||||||
|
("face_6H", 0xa10fa42b5549fc85),
|
||||||
|
("face_7H", 0x6823107295c149b5),
|
||||||
|
("face_8H", 0x474d2de14865e65b),
|
||||||
|
("face_9H", 0x1b0de1af8dae108a),
|
||||||
|
("face_10H", 0x451fd5855859c9d7),
|
||||||
|
("face_JH", 0xd821a7d4c79a37e0),
|
||||||
|
("face_QH", 0xde0c6ef7e963861a),
|
||||||
|
("face_KH", 0xe29039cb6a115214),
|
||||||
|
("face_AS", 0x1697fbcc61b64e0f),
|
||||||
|
("face_2S", 0x5ada7ea3e39547d0),
|
||||||
|
("face_3S", 0x6d8eed531f2d659c),
|
||||||
|
("face_4S", 0x1b1a2d25e080d71e),
|
||||||
|
("face_5S", 0x5eb82baa4f9a74bb),
|
||||||
|
("face_6S", 0xa00b217892d32ead),
|
||||||
|
("face_7S", 0xaf60935ec8d93346),
|
||||||
|
("face_8S", 0xffbde852d8699a80),
|
||||||
|
("face_9S", 0x8f68afa04b88e1a2),
|
||||||
|
("face_10S", 0x96fa4a08f168210a),
|
||||||
|
("face_JS", 0x73030a8109b5b5e6),
|
||||||
|
("face_QS", 0x303eb6c33e363cc1),
|
||||||
|
("face_KS", 0x3ed5b5a9432c91e9),
|
||||||
|
("back_0", 0xf698d0e161eae13a),
|
||||||
|
("back_1", 0x446fdc0a3c83a03a),
|
||||||
|
("back_2", 0xcf188fdec9f5819a),
|
||||||
|
("back_3", 0xcaffd02af141743a),
|
||||||
|
("back_4", 0xcee8a700bbaaf71a),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rasterised_card_bytes_match_pinned_hashes() {
|
||||||
|
let actual = compute_actual_hashes();
|
||||||
|
|
||||||
|
if EXPECTED.is_empty() {
|
||||||
|
panic_with_hashes_to_paste(&actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
actual.len(),
|
||||||
|
EXPECTED.len(),
|
||||||
|
"card-output count drifted (actual {} vs expected {})",
|
||||||
|
actual.len(),
|
||||||
|
EXPECTED.len(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut mismatches: Vec<String> = Vec::new();
|
||||||
|
for ((actual_name, actual_hash), (expected_name, expected_hash)) in
|
||||||
|
actual.iter().zip(EXPECTED.iter())
|
||||||
|
{
|
||||||
|
assert_eq!(
|
||||||
|
actual_name, expected_name,
|
||||||
|
"card-output naming/order drifted",
|
||||||
|
);
|
||||||
|
if actual_hash != expected_hash {
|
||||||
|
mismatches.push(format!(
|
||||||
|
" {actual_name}: actual 0x{actual_hash:016x} expected 0x{expected_hash:016x}",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !mismatches.is_empty() {
|
||||||
|
let mut msg = String::from(
|
||||||
|
"rasterised card bytes drifted from EXPECTED — usvg/resvg/tiny_skia/font upgrade?\n",
|
||||||
|
);
|
||||||
|
for m in &mismatches {
|
||||||
|
msg.push_str(m);
|
||||||
|
msg.push('\n');
|
||||||
|
}
|
||||||
|
msg.push_str(
|
||||||
|
"\nIf this drift is intentional, replace EXPECTED with `&[]` and re-run\nthis test to print fresh hashes.\n",
|
||||||
|
);
|
||||||
|
panic!("{msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_actual_hashes() -> Vec<(String, u64)> {
|
||||||
|
let mut out = Vec::with_capacity(ALL_RANKS.len() * ALL_SUITS.len() + BACK_ACCENTS.len());
|
||||||
|
for suit in ALL_SUITS {
|
||||||
|
for rank in ALL_RANKS {
|
||||||
|
let name = format!("face_{}{}", rank_filename(rank), suit_filename(suit));
|
||||||
|
out.push((name, hash_rasterised(&face_svg(rank, suit))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (idx, accent) in BACK_ACCENTS.iter().enumerate() {
|
||||||
|
out.push((format!("back_{idx}"), hash_rasterised(&back_svg(accent))));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_rasterised(svg: &str) -> u64 {
|
||||||
|
let image = rasterize_svg(svg.as_bytes(), TARGET).expect("rasterise card SVG");
|
||||||
|
let bytes = image.data.expect("rasterised image carries RGBA pixel data");
|
||||||
|
fnv1a(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FNV-1a 64-bit. Inline rather than a dependency — adding `sha2`
|
||||||
|
/// or `blake3` for ~5 lines of code would burn a CLAUDE.md §8
|
||||||
|
/// "ask before adding deps" round-trip for no real benefit.
|
||||||
|
/// Cryptographic strength isn't load-bearing here — we just need
|
||||||
|
/// stable byte fingerprints.
|
||||||
|
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||||
|
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
|
for &b in bytes {
|
||||||
|
h ^= b as u64;
|
||||||
|
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||||
|
}
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
fn panic_with_hashes_to_paste(actual: &[(String, u64)]) -> ! {
|
||||||
|
let mut out = String::from(
|
||||||
|
"\nEXPECTED is empty — paste the following into the const literal:\n\nconst EXPECTED: &[(&str, u64)] = &[\n",
|
||||||
|
);
|
||||||
|
for (name, hash) in actual {
|
||||||
|
out.push_str(&format!(" (\"{name}\", 0x{hash:016x}),\n"));
|
||||||
|
}
|
||||||
|
out.push_str("];\n");
|
||||||
|
panic!("{out}");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user