feat(engine): add stroke-based SVG icon set for the touch tab bar

Five white 24-box stroke icons (undo/draw/hint/pause/menu) rasterised
through the existing usvg/resvg pipeline; tinted at spawn time via
ImageNode::color. Avoids FiraMono's missing icon glyphs on Android.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-15 13:42:37 -07:00
parent 37919cfe17
commit 7c2daa1e32
2 changed files with 131 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
//! SVG builders for the floating tab bar's action icons.
//!
//! The touch tab bar (`hud_plugin::tab_bar`) is icon-first, but the bundled
//! FiraMono face has no icon glyphs (and the Geometric Shapes block U+25xx
//! is missing on Android entirely), so the icons are authored here as tiny
//! stroke-based SVGs and rasterised through the same `usvg`/`resvg` pipeline
//! as the card themes (`assets::rasterize_svg`).
//!
//! Icons are drawn in **white** on a transparent background inside a
//! 24-unit logical box; the spawn site tints them to the theme colour via
//! `ImageNode::color` (a per-node multiply), so one texture serves every
//! button state.
use bevy::image::Image;
use bevy::math::UVec2;
use super::svg_loader::{SvgLoaderError, rasterize_svg};
/// Rasterisation size for one tab-bar icon texture. Icons display at
/// 24 logical px; 96 px texels keep them crisp at the Fold's 1.2 scale
/// factor (and any future 3× phone) while staying trivially small.
pub const HUD_ICON_TEXTURE_PX: u32 = 96;
/// The five actions the floating touch tab bar can show.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HudIcon {
/// Counter-clockwise "take back" arrow.
Undo,
/// Two offset card outlines — draw from the stock.
Draw,
/// Lightbulb — highlight a suggested move.
Hint,
/// Two vertical bars.
Pause,
/// Three-line hamburger — the floating menu button.
Menu,
}
impl HudIcon {
/// Build the icon's SVG document. All icons share one visual language:
/// 2-unit white strokes, round caps, transparent fill, 24-unit box.
pub fn svg(self) -> String {
let body = match self {
// Arrow head pointing left into a half-circle sweeping back
// over the top — the conventional undo mark.
HudIcon::Undo => concat!(
r##"<path d="M 8 5 L 4 9 L 8 13"/>"##,
r##"<path d="M 4 9 H 14 A 5.5 5.5 0 0 1 14 20 H 8"/>"##,
),
// Back card peeks out behind the front card; the front card is
// drawn second so its stroke reads on top.
HudIcon::Draw => concat!(
r##"<rect x="8.5" y="3" width="11" height="15" rx="2"/>"##,
r##"<rect x="4" y="6.5" width="11" height="15" rx="2"/>"##,
),
// Bulb dome with a flat lamp base beneath.
HudIcon::Hint => concat!(
r##"<path d="M 9 15.5 A 6.5 6.5 0 1 1 15 15.5 V 18 H 9 Z"/>"##,
r##"<path d="M 10 21 H 14"/>"##,
),
HudIcon::Pause => concat!(
r##"<path d="M 9 6 V 18"/>"##,
r##"<path d="M 15 6 V 18"/>"##,
),
HudIcon::Menu => concat!(
r##"<path d="M 4 6.5 H 20"/>"##,
r##"<path d="M 4 12 H 20"/>"##,
r##"<path d="M 4 17.5 H 20"/>"##,
),
};
format!(
concat!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">"##,
r##"<g fill="none" stroke="#ffffff" stroke-width="2" "##,
r##"stroke-linecap="round" stroke-linejoin="round">{body}</g>"##,
r##"</svg>"##,
),
body = body
)
}
/// Rasterise this icon into a Bevy [`Image`] at
/// [`HUD_ICON_TEXTURE_PX`] square.
pub fn rasterize(self) -> Result<Image, SvgLoaderError> {
rasterize_svg(
self.svg().as_bytes(),
UVec2::splat(HUD_ICON_TEXTURE_PX),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
const ALL: [HudIcon; 5] = [
HudIcon::Undo,
HudIcon::Draw,
HudIcon::Hint,
HudIcon::Pause,
HudIcon::Menu,
];
/// Every icon must survive the real usvg/resvg pipeline at the real
/// texture size — a malformed path here would otherwise only surface
/// as a blank button on device.
#[test]
fn every_icon_rasterizes_at_texture_size() {
for icon in ALL {
let img = icon.rasterize().expect("icon SVG must rasterise");
assert_eq!(img.width(), HUD_ICON_TEXTURE_PX, "{icon:?} width");
assert_eq!(img.height(), HUD_ICON_TEXTURE_PX, "{icon:?} height");
}
}
/// Rasterised icons must contain visible (non-transparent) pixels —
/// guards against a stroke accidentally drawn outside the viewBox.
#[test]
fn every_icon_has_visible_pixels() {
for icon in ALL {
let img = icon.rasterize().expect("icon SVG must rasterise");
let data = img.data.as_ref().expect("rasterised image has data");
let opaque = data.chunks_exact(4).filter(|px| px[3] > 0).count();
assert!(
opaque > 100,
"{icon:?} renders only {opaque} visible pixels"
);
}
}
}
+1
View File
@@ -5,6 +5,7 @@
//! custom `AssetSource` implementations used by the theme system.
pub mod card_face_svg;
pub mod hud_icon_svg;
pub mod icon_svg;
pub mod sources;
pub mod svg_loader;