feat(engine): SVG → Image asset loader (Card theme phase 1)
Implements the runtime SVG rasterisation pipeline that the card-theme
system (CARD_PLAN.md) is built on. Bevy 0.18 has no native SVG support;
this loader bridges usvg (parser) + resvg (renderer) + tiny-skia (CPU
pixmap) so the rest of the engine consumes themes as plain
Handle<Image>. Rasterisation happens once per (asset, settings) pair at
load time — Bevy's asset cache absorbs the cost.
solitaire_engine/src/assets/
mod.rs — module entrypoint
svg_loader.rs — SvgLoader (AssetLoader for .svg → Image)
SvgLoaderSettings { target_size: UVec2 } default 512×768
SvgLoaderError (Io / Parse / PixmapAlloc) via thiserror
rasterize_svg() helper exposed for non-asset-graph
callers (the future zip-importer validation step)
The rasteriser scales-to-fit while preserving aspect ratio, centring
the SVG inside the target box so a non-2:3 source doesn't pin to the
top-left corner.
7 new unit tests — default + custom target size, zero-dimension reject,
malformed-input reject, RGBA byte-count, extension advertisement, and
a compile-time guard that SvgLoaderSettings still satisfies the
AssetLoader::Settings trait bounds.
Workspace deps added: usvg 0.47, resvg 0.47, tiny-skia 0.12 (latest
minor versions; CARD_PLAN.md called out the placeholder numbers
needed verification).
cargo build / cargo clippy --workspace --all-targets -- -D warnings
/ cargo test --workspace all green (913 passed, 0 failed, 9 ignored —
+7 from the new loader tests).
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
//! Asset-loading infrastructure for runtime SVG rasterisation.
|
||||
//!
|
||||
//! See `CARD_PLAN.md` for the multi-phase implementation plan. This module
|
||||
//! is the entry point for Phase 1 (the SVG → `Image` asset loader). Later
|
||||
//! phases extend it with custom asset sources for embedded and user
|
||||
//! themes, and a `CardTheme` asset that aggregates 53 image handles.
|
||||
|
||||
pub mod svg_loader;
|
||||
|
||||
pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings};
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Bevy `AssetLoader` that rasterises an SVG into `bevy::image::Image`.
|
||||
//!
|
||||
//! The card-theme system (see `CARD_PLAN.md`) 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 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;
|
||||
|
||||
/// 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::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(),
|
||||
))
|
||||
}
|
||||
|
||||
#[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)));
|
||||
}
|
||||
|
||||
#[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>();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Bevy integration layer for Solitaire Quest.
|
||||
|
||||
pub mod assets;
|
||||
pub mod card_animation;
|
||||
pub mod achievement_plugin;
|
||||
pub mod animation_plugin;
|
||||
|
||||
Reference in New Issue
Block a user