859b69b3c5
A2: docs/ANDROID.md — remove stale "permanent fix to come" note;
clarify --lib is the canonical command; root-cause the upstream
cargo-apk bug. SESSION_HANDOFF.md closes the open item.
A3: Remove dead CARD_PLAN.md references from four source module
doc comments (theme/importer.rs, theme/plugin.rs, assets/mod.rs,
assets/svg_loader.rs). Also fix stale "future picker UI" language
in plugin.rs (picker shipped in Phase 7).
A4: ui_modal.rs spawn_modal_button — add min_height: Val::Px(48.0)
so every modal action button meets Material's 48 dp touch target
minimum. Modal button height was 42 px (2×SPACE_3 + TYPE_BODY_LG);
now clamped to 48 px minimum. Cards at 40 dp on 360 dp phones are
layout-constrained (7 columns) and cannot be widened.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
285 lines
11 KiB
Rust
285 lines
11 KiB
Rust
//! Bevy `AssetLoader` that rasterises an SVG into `bevy::image::Image`.
|
||
//!
|
||
//! The card-theme system ships SVG sources both as
|
||
//! the embedded default theme and as user-supplied themes. Bevy 0.18 has
|
||
//! no built-in SVG support, so this loader bridges `usvg` (parser) +
|
||
//! `resvg` (renderer) + `tiny-skia` (CPU pixmap) to produce textures
|
||
//! that the rest of the engine consumes as plain `Handle<Image>` — no
|
||
//! awareness of vector graphics leaks past this boundary.
|
||
//!
|
||
//! Rasterisation happens once per (asset, settings) pair at load time.
|
||
//! Bevy's asset system caches the resulting `Image`, so the cost is paid
|
||
//! exactly once per theme switch, not per frame.
|
||
//!
|
||
//! # Settings
|
||
//!
|
||
//! Each `Handle<Image>` produced via this loader carries
|
||
//! [`SvgLoaderSettings`]. The most important field is `target_size` —
|
||
//! callers should specify the rasterisation resolution explicitly when
|
||
//! loading via `load_with_settings(...)`. The default of 512×768 is a
|
||
//! safe fallback that fits a typical 2:3 playing card.
|
||
|
||
use std::sync::{Arc, OnceLock};
|
||
|
||
use bevy::asset::io::Reader;
|
||
use bevy::asset::{AssetLoader, LoadContext, RenderAssetUsages};
|
||
use bevy::image::Image;
|
||
use bevy::math::UVec2;
|
||
use bevy::reflect::TypePath;
|
||
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
|
||
use serde::{Deserialize, Serialize};
|
||
use thiserror::Error;
|
||
use usvg::fontdb;
|
||
|
||
/// Per-asset settings consumed by [`SvgLoader::load`].
|
||
///
|
||
/// `target_size` controls the rasterisation resolution. SVG content is
|
||
/// scaled uniformly to fit this box while preserving aspect ratio.
|
||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||
pub struct SvgLoaderSettings {
|
||
/// Output texture dimensions in pixels.
|
||
pub target_size: UVec2,
|
||
}
|
||
|
||
impl Default for SvgLoaderSettings {
|
||
fn default() -> Self {
|
||
// 512×768 is a 2:3 aspect at a resolution that stays sharp on
|
||
// typical desktop windows where individual cards never exceed
|
||
// ~250 px wide. Callers that need higher fidelity should
|
||
// override via `load_with_settings`.
|
||
Self {
|
||
target_size: UVec2::new(512, 768),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Errors surfaced by [`SvgLoader::load`].
|
||
#[derive(Debug, Error)]
|
||
pub enum SvgLoaderError {
|
||
/// The asset reader failed before the SVG bytes were consumed.
|
||
#[error("io: {0}")]
|
||
Io(#[from] std::io::Error),
|
||
/// `usvg` rejected the input as malformed or unsupported.
|
||
#[error("svg parse: {0}")]
|
||
Parse(#[from] usvg::Error),
|
||
/// `tiny_skia::Pixmap::new` returned `None` — typically because the
|
||
/// requested target_size is zero or absurdly large.
|
||
#[error("could not allocate pixmap of size {0}x{1}")]
|
||
PixmapAlloc(u32, u32),
|
||
}
|
||
|
||
/// `AssetLoader` registered for the `.svg` extension.
|
||
///
|
||
/// Stateless; safe to construct via `Default` and register once at
|
||
/// startup with `app.register_asset_loader(SvgLoader)`.
|
||
#[derive(Debug, Default, TypePath)]
|
||
pub struct SvgLoader;
|
||
|
||
impl AssetLoader for SvgLoader {
|
||
type Asset = Image;
|
||
type Settings = SvgLoaderSettings;
|
||
type Error = SvgLoaderError;
|
||
|
||
async fn load(
|
||
&self,
|
||
reader: &mut dyn Reader,
|
||
settings: &Self::Settings,
|
||
_load_context: &mut LoadContext<'_>,
|
||
) -> Result<Image, Self::Error> {
|
||
let mut bytes = Vec::new();
|
||
reader.read_to_end(&mut bytes).await?;
|
||
rasterize_svg(&bytes, settings.target_size)
|
||
}
|
||
|
||
fn extensions(&self) -> &[&str] {
|
||
&["svg"]
|
||
}
|
||
}
|
||
|
||
/// Rasterises an SVG byte buffer into an `Image` of exactly
|
||
/// `target.x × target.y` pixels. Content is scaled uniformly to fit
|
||
/// while preserving aspect ratio; unused area is left transparent.
|
||
///
|
||
/// Exposed separately from the `AssetLoader` impl so callers (tests,
|
||
/// the Phase 7 zip importer's "is this a valid SVG?" check, future
|
||
/// thumbnail generators) can rasterise without going through the
|
||
/// asset graph.
|
||
pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoaderError> {
|
||
let opt = usvg::Options {
|
||
fontdb: shared_fontdb(),
|
||
// The bundled fontdb only contains FiraMono and the resolver
|
||
// routes every named-family request to it; this is a default
|
||
// for SVGs that don't specify a family at all.
|
||
font_family: "Fira Mono".to_string(),
|
||
font_resolver: bundled_font_resolver(),
|
||
..Default::default()
|
||
};
|
||
let tree = usvg::Tree::from_data(svg_bytes, &opt)?;
|
||
|
||
let svg_size = tree.size();
|
||
let svg_w = svg_size.width();
|
||
let svg_h = svg_size.height();
|
||
|
||
let target_w = target.x as f32;
|
||
let target_h = target.y as f32;
|
||
|
||
// Scale-to-fit while preserving aspect — the smaller axis ratio wins
|
||
// so the entire SVG is visible inside the target box.
|
||
let scale = (target_w / svg_w).min(target_h / svg_h);
|
||
|
||
let mut pixmap = tiny_skia::Pixmap::new(target.x, target.y)
|
||
.ok_or(SvgLoaderError::PixmapAlloc(target.x, target.y))?;
|
||
|
||
// Centre the scaled SVG inside the target box so any aspect-ratio
|
||
// mismatch is balanced rather than pinned to the top-left corner.
|
||
let dx = (target_w - svg_w * scale) * 0.5;
|
||
let dy = (target_h - svg_h * scale) * 0.5;
|
||
let transform = tiny_skia::Transform::from_scale(scale, scale).post_translate(dx, dy);
|
||
|
||
resvg::render(&tree, transform, &mut pixmap.as_mut());
|
||
|
||
Ok(Image::new(
|
||
Extent3d {
|
||
width: target.x,
|
||
height: target.y,
|
||
depth_or_array_layers: 1,
|
||
},
|
||
TextureDimension::D2,
|
||
pixmap.take(),
|
||
TextureFormat::Rgba8UnormSrgb,
|
||
RenderAssetUsages::default(),
|
||
))
|
||
}
|
||
|
||
/// FiraMono-Medium bytes embedded at compile time. Mirrors the embed in
|
||
/// `solitaire_engine::font_plugin` so the SVG rasteriser and the Bevy UI
|
||
/// share the same canonical face.
|
||
const BUNDLED_FONT_BYTES: &[u8] = include_bytes!("../../../assets/fonts/main.ttf");
|
||
|
||
/// Returns a process-wide font database holding only the bundled
|
||
/// FiraMono-Medium face. Initialised lazily on first SVG that references
|
||
/// text, then shared (via `Arc`) across every subsequent rasterisation.
|
||
///
|
||
/// The bundled card SVGs reference families like `Arial` and
|
||
/// `Bitstream Vera Sans` by name; [`bundled_font_resolver`] maps every
|
||
/// such request directly to FiraMono so rasterisation is deterministic
|
||
/// across machines and the system font path is never consulted.
|
||
///
|
||
/// Aborts the program if the embedded bytes don't parse — bundled at
|
||
/// compile time, so a parse failure means the binary is corrupt.
|
||
fn shared_fontdb() -> Arc<fontdb::Database> {
|
||
static DB: OnceLock<Arc<fontdb::Database>> = OnceLock::new();
|
||
DB.get_or_init(|| {
|
||
let mut db = fontdb::Database::new();
|
||
db.load_font_data(BUNDLED_FONT_BYTES.to_vec());
|
||
assert!(
|
||
db.faces().next().is_some(),
|
||
"bundled FiraMono failed to parse — binary is corrupt"
|
||
);
|
||
Arc::new(db)
|
||
})
|
||
.clone()
|
||
}
|
||
|
||
/// Resolver that ignores the SVG's `font-family` request and always
|
||
/// returns the single bundled FiraMono face. Bundled card SVGs ask for
|
||
/// fonts by name (Arial, Bitstream Vera Sans) that this binary
|
||
/// deliberately doesn't ship; routing every query to FiraMono keeps
|
||
/// rendering deterministic and removes the system-font path entirely.
|
||
fn bundled_font_resolver() -> usvg::FontResolver<'static> {
|
||
use usvg::FontResolver;
|
||
|
||
usvg::FontResolver {
|
||
select_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)),
|
||
select_fallback: FontResolver::default_fallback_selector(),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Minimal but non-trivial SVG: yellow rectangle + dark circle.
|
||
/// Embedded inline so tests have no filesystem dependencies. The
|
||
/// `##` raw-string delimiter lets us inline `#`-prefixed hex colours.
|
||
const TEST_SVG: &[u8] = br##"<?xml version="1.0" encoding="UTF-8"?>
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300" width="200" height="300">
|
||
<rect x="0" y="0" width="200" height="300" fill="#FFD23F"/>
|
||
<circle cx="100" cy="150" r="80" fill="#1A0F2E"/>
|
||
</svg>"##;
|
||
|
||
#[test]
|
||
fn rasterizes_at_default_size() {
|
||
let settings = SvgLoaderSettings::default();
|
||
let image = rasterize_svg(TEST_SVG, settings.target_size).expect("rasterisation");
|
||
assert_eq!(image.size().x, 512);
|
||
assert_eq!(image.size().y, 768);
|
||
}
|
||
|
||
#[test]
|
||
fn rasterizes_at_custom_size() {
|
||
let image = rasterize_svg(TEST_SVG, UVec2::new(64, 96)).expect("rasterisation");
|
||
assert_eq!(image.size().x, 64);
|
||
assert_eq!(image.size().y, 96);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_zero_dimension() {
|
||
let err = rasterize_svg(TEST_SVG, UVec2::new(0, 100)).unwrap_err();
|
||
assert!(matches!(err, SvgLoaderError::PixmapAlloc(0, 100)));
|
||
}
|
||
|
||
/// SVG with a text node that requests an unlikely-installed family
|
||
/// ("FontThatProbablyDoesNotExist"). Exercises `lenient_font_resolver`'s
|
||
/// "fall through to system sans-serif/serif" behaviour: rasterising
|
||
/// must succeed, never panic, and the test runner's log output must
|
||
/// not contain `No match for ... font-family.` for the named family.
|
||
/// Catching the warn directly would require a tracing subscriber; we
|
||
/// rely on `cargo test`'s default behaviour of capturing stdout/stderr
|
||
/// and surfacing only failing tests' output, plus visual review of
|
||
/// the suite's log stream.
|
||
const TEST_SVG_WITH_TEXT: &[u8] = br##"<?xml version="1.0" encoding="UTF-8"?>
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300" width="200" height="300">
|
||
<text x="100" y="150" style="font-family:FontThatProbablyDoesNotExist;font-size:32">A</text>
|
||
</svg>"##;
|
||
|
||
#[test]
|
||
fn rasterizes_svg_with_unmatched_font_family() {
|
||
let image =
|
||
rasterize_svg(TEST_SVG_WITH_TEXT, UVec2::new(64, 96)).expect("rasterisation");
|
||
assert_eq!(image.size().x, 64);
|
||
assert_eq!(image.size().y, 96);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_malformed_svg() {
|
||
let err = rasterize_svg(b"not actually svg", UVec2::new(64, 96)).unwrap_err();
|
||
assert!(matches!(err, SvgLoaderError::Parse(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn pixmap_data_is_rgba_with_target_byte_count() {
|
||
let image =
|
||
rasterize_svg(TEST_SVG, UVec2::new(32, 48)).expect("rasterisation");
|
||
let pixels = image.data.as_ref().expect("rasterised image carries pixel data");
|
||
// 32 × 48 × 4 (RGBA bytes) = 6144 bytes
|
||
assert_eq!(pixels.len(), 32 * 48 * 4);
|
||
}
|
||
|
||
#[test]
|
||
fn loader_advertises_svg_extension() {
|
||
let loader = SvgLoader;
|
||
assert_eq!(loader.extensions(), &["svg"]);
|
||
}
|
||
|
||
/// Compile-time guard that `SvgLoaderSettings` satisfies the trait
|
||
/// bounds Bevy expects on `AssetLoader::Settings` — keeps the
|
||
/// loader's `#[derive]` set honest if the upstream signature ever
|
||
/// tightens.
|
||
#[test]
|
||
fn settings_satisfies_loader_bounds() {
|
||
fn assert_loader_settings<T: Default + serde::Serialize + serde::de::DeserializeOwned>() {}
|
||
assert_loader_settings::<SvgLoaderSettings>();
|
||
}
|
||
}
|