Import Oxide engine (Stages 0–10) under MIT license

Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit f56a1eea3b
128 changed files with 40493 additions and 2 deletions
+429
View File
@@ -0,0 +1,429 @@
//! Font loading and per-glyph metrics — thin wrapper over [`ab_glyph::FontVec`].
//!
//! The text system stays a layer above the font crate so it can swap
//! rasterizers later (an SDF generator, a different parser) without churning
//! the public Stage-8 API. Every text query a [`super::shape::shape`] or
//! [`super::atlas::GlyphAtlas`] call needs goes through [`Font`]'s methods —
//! `ab_glyph` is never visible to consumers of the engine.
use std::collections::HashMap;
use std::path::Path;
use ab_glyph::{Font as AbFont, FontVec, PxScale, ScaleFont};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::super::visual::FontRef;
/// Errors returned from font loading.
#[derive(Debug, Error)]
pub enum FontError {
/// Reading the font file from disk failed.
#[error("font file read failed: {0}")]
Io(#[from] std::io::Error),
/// The bytes were not a valid TTF / OTF font.
#[error("not a valid TTF/OTF font")]
InvalidFont,
}
/// Stable, opaque identifier for a font registered in a [`FontStore`].
///
/// Held in [`GlyphKey`](super::atlas::GlyphKey)s in the atlas and in
/// [`TextStyle`](super::shape::TextStyle)s passed to the shaper, so a font's
/// id never changes once registered. `Copy` + `Hash` so it indexes hash maps
/// cheaply.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FontId(pub u32);
/// One loaded font — a parsed TTF/OTF that can report metrics and rasterize
/// individual glyphs.
pub struct Font {
inner: FontVec,
}
impl std::fmt::Debug for Font {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Font").finish_non_exhaustive()
}
}
/// Result of rasterizing one glyph at a specific pixel size — the alpha mask
/// plus enough metrics to position it on a baseline.
#[derive(Debug, Clone, PartialEq)]
pub struct RasterizedGlyph {
/// Width of the alpha mask in pixels.
pub width: u32,
/// Height of the alpha mask in pixels.
pub height: u32,
/// X offset from the glyph's pen position to the mask's left edge.
pub bearing_x: f32,
/// Y offset from the glyph's baseline to the mask's top edge (negative
/// for glyphs that extend above the baseline, which is most of them).
pub bearing_y: f32,
/// How far to advance the pen along the baseline before the next glyph.
pub advance_x: f32,
/// Row-major alpha bytes (`width * height` bytes, `0 = transparent`,
/// `255 = opaque`).
pub bitmap: Vec<u8>,
}
impl Font {
/// Parse a TTF/OTF font from raw bytes. Bytes are owned by the [`Font`].
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, FontError> {
FontVec::try_from_vec(bytes)
.map(|inner| Self { inner })
.map_err(|_| FontError::InvalidFont)
}
/// Load and parse a TTF/OTF file from disk.
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, FontError> {
let bytes = std::fs::read(path.as_ref())?;
Self::from_bytes(bytes)
}
/// The glyph id for a `char`. Returns the font's `notdef` glyph (id `0`)
/// for characters the font does not contain — same behavior as
/// `ab_glyph`.
pub fn glyph_id(&self, ch: char) -> GlyphId {
GlyphId(self.inner.glyph_id(ch).0)
}
/// Horizontal advance for the next glyph at `size_px` logical pixels.
pub fn h_advance_px(&self, glyph: GlyphId, size_px: f32) -> f32 {
self.inner
.as_scaled(PxScale::from(size_px))
.h_advance(ab_glyph::GlyphId(glyph.0))
}
/// Ascender height in pixels at the given size.
pub fn ascent_px(&self, size_px: f32) -> f32 {
self.inner.as_scaled(PxScale::from(size_px)).ascent()
}
/// Descender depth in pixels at the given size. Negative for fonts where
/// the descender sits below the baseline (the common case).
pub fn descent_px(&self, size_px: f32) -> f32 {
self.inner.as_scaled(PxScale::from(size_px)).descent()
}
/// Line gap in pixels — extra leading the font recommends between lines.
pub fn line_gap_px(&self, size_px: f32) -> f32 {
self.inner.as_scaled(PxScale::from(size_px)).line_gap()
}
/// Total recommended line height at `size_px` (ascent descent +
/// line_gap). Multiplied by `TextStyle`'s line-height factor by the
/// shaper.
pub fn line_height_px(&self, size_px: f32) -> f32 {
let scaled = self.inner.as_scaled(PxScale::from(size_px));
scaled.ascent() - scaled.descent() + scaled.line_gap()
}
/// Rasterize a single glyph to an alpha bitmap. Returns `None` for
/// glyphs with no outline (e.g., the space character) — the caller still
/// gets the advance via [`Font::h_advance_px`] and should treat the
/// glyph as zero-area.
pub fn rasterize(&self, glyph: GlyphId, size_px: f32) -> Option<RasterizedGlyph> {
let scale = PxScale::from(size_px);
let scaled = self.inner.as_scaled(scale);
let advance_x = scaled.h_advance(ab_glyph::GlyphId(glyph.0));
let mut positioned = ab_glyph::GlyphId(glyph.0).with_scale(scale);
positioned.position = ab_glyph::point(0.0, 0.0);
let outlined = self.inner.outline_glyph(positioned)?;
let bounds = outlined.px_bounds();
let width = bounds.width().ceil().max(1.0) as u32;
let height = bounds.height().ceil().max(1.0) as u32;
let mut bitmap = vec![0u8; (width as usize) * (height as usize)];
outlined.draw(|x, y, coverage| {
if x < width && y < height {
let idx = (y as usize) * (width as usize) + (x as usize);
bitmap[idx] = (coverage * 255.0).round().clamp(0.0, 255.0) as u8;
}
});
Some(RasterizedGlyph {
width,
height,
bearing_x: bounds.min.x,
bearing_y: bounds.min.y,
advance_x,
bitmap,
})
}
}
/// [`AssetLoader`](crate::asset::AssetLoader) for TTF/OTF fonts.
///
/// Registered by default on every [`AssetServer`](crate::asset::AssetServer), so
/// a font file under a project's `assets/fonts/` can be loaded by path and an
/// [`AssetRef<Font>`](crate::asset::AssetRef) resolved to a [`Handle<Font>`](crate::asset::Handle)
/// — the link that lets the UI canvas pick a font asset and the runtime draw with it.
pub struct FontLoader;
impl crate::asset::AssetLoader for FontLoader {
type Asset = Font;
fn extensions(&self) -> &'static [&'static str] {
&["ttf", "otf"]
}
fn load(&self, path: &Path) -> Result<Font, crate::asset::AssetError> {
Font::from_path(path).map_err(|err| crate::asset::AssetError::Load {
path: path.to_path_buf(),
message: err.to_string(),
})
}
}
/// Opaque per-font glyph index. Mirrors `ab_glyph::GlyphId` but is the only
/// glyph type exposed by the engine, so consumers do not need an `ab_glyph`
/// dependency.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GlyphId(pub u16);
/// Registry of loaded fonts, indexed by [`FontId`] and (optionally) by
/// [`FontRef`] descriptor.
///
/// Why a descriptor index: piece-2 [`Theme`](super::super::theme::Theme)s
/// store fonts by family + weight + italic (`FontRef`), not by raw bytes.
/// `FontStore::resolve(&font_ref)` turns the descriptor into a [`FontId`] the
/// shaper can use, so a theme like `{ font: Some(FontRef::bold("Inter")) }`
/// works end-to-end as soon as the matching face has been registered.
#[derive(Default)]
pub struct FontStore {
fonts: Vec<Font>,
by_descriptor: HashMap<FontRef, FontId>,
}
impl std::fmt::Debug for FontStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FontStore")
.field("len", &self.fonts.len())
.field("descriptors", &self.by_descriptor.len())
.finish()
}
}
impl FontStore {
/// Create an empty store.
pub fn new() -> Self {
Self::default()
}
/// Register a font with no descriptor — accessible only by its returned
/// [`FontId`]. Useful for one-off uses where the font isn't part of a
/// theme cascade.
pub fn insert(&mut self, font: Font) -> FontId {
let id = FontId(self.fonts.len() as u32);
self.fonts.push(font);
id
}
/// Register a font and associate it with a descriptor.
///
/// Re-registering the same descriptor replaces the previous association
/// but does not free the previous [`FontId`] — both ids continue to
/// reference the now-distinct font. This matches Stage-7 `ActionMap`
/// re-registration semantics: ids are stable, names can be remapped.
pub fn insert_with_descriptor(&mut self, descriptor: FontRef, font: Font) -> FontId {
let id = self.insert(font);
self.by_descriptor.insert(descriptor, id);
id
}
/// Look up a font by `FontId`.
pub fn get(&self, id: FontId) -> Option<&Font> {
self.fonts.get(id.0 as usize)
}
/// Resolve a [`FontRef`] descriptor (piece-2 theme value) to a
/// [`FontId`], if the matching face has been registered.
pub fn resolve(&self, descriptor: &FontRef) -> Option<FontId> {
self.by_descriptor.get(descriptor).copied()
}
/// Number of registered fonts.
pub fn len(&self) -> usize {
self.fonts.len()
}
/// `true` if no fonts are registered.
pub fn is_empty(&self) -> bool {
self.fonts.is_empty()
}
}
/// Common system paths a Linux-style host is likely to have a sans-serif
/// TTF at. Used by tests (and the eventual editor "no theme font set" path)
/// to find *some* font without bundling one.
///
/// Returned in priority order; the first existing path is the one to try.
/// Empty on hosts the search doesn't know about — the caller must handle
/// "no candidate found" gracefully.
pub fn common_system_font_paths() -> &'static [&'static str] {
&[
// Linux distributions:
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf",
// macOS:
"/Library/Fonts/Arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
]
}
/// Try to load a sans-serif font from a well-known system path. Returns
/// `None` (and prints `SKIP:`) if no candidate exists — the same pattern
/// the Stage-4 GPU tests use for "no adapter".
///
/// Test-only helper shared between the `font`, `atlas`, and `shape` modules
/// so the same "skip when no system font" branch isn't duplicated.
#[cfg(test)]
pub(crate) fn try_load_system_font() -> Option<Font> {
for path in common_system_font_paths() {
if Path::new(path).exists() {
match Font::from_path(path) {
Ok(font) => return Some(font),
Err(err) => {
eprintln!("SKIP-candidate: {path} present but failed to load: {err}");
}
}
}
}
eprintln!("SKIP: no system font available at any common Linux/macOS path");
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_garbage_bytes() {
let err = Font::from_bytes(vec![0u8; 32]).unwrap_err();
assert!(matches!(err, FontError::InvalidFont));
}
#[test]
fn missing_file_returns_io_error() {
let err = Font::from_path("/nonexistent/font.ttf").unwrap_err();
assert!(matches!(err, FontError::Io(_)));
}
#[test]
fn font_loader_loads_through_the_asset_server() {
use crate::asset::{AssetRef, AssetServer, AssetUid};
// Find a real font file on disk; skip cleanly if the host has none.
let Some(path) = common_system_font_paths()
.iter()
.map(std::path::Path::new)
.find(|p| p.exists())
else {
eprintln!("SKIP: no system font path available");
return;
};
// The default-registered FontLoader makes `.ttf`/`.otf` loadable.
let server = AssetServer::new();
let handle = server.load::<Font>(path);
assert!(handle.is_loaded(), "font should load: {:?}", handle.error());
// An asset reference to a hypothetical uid resolves to a handle when the
// database hands back this path (proven in asset::database tests); here
// we just confirm the loaded Font is usable.
assert!(handle.get().unwrap().h_advance_px(GlyphId(0), 16.0) >= 0.0);
// AssetRef<Font> is constructible (the field type the UI canvas uses).
let _ = AssetRef::<Font>::new(AssetUid(1));
}
#[test]
fn store_assigns_distinct_ids() {
let Some(a) = try_load_system_font() else {
return;
};
let Some(b) = try_load_system_font() else {
return;
};
let mut store = FontStore::new();
let id_a = store.insert(a);
let id_b = store.insert(b);
assert_ne!(id_a, id_b);
assert_eq!(store.len(), 2);
assert!(store.get(id_a).is_some());
assert!(store.get(id_b).is_some());
assert!(store.get(FontId(99)).is_none());
}
#[test]
fn descriptor_resolves_to_registered_font() {
let Some(font) = try_load_system_font() else {
return;
};
let descriptor = FontRef::regular("System");
let mut store = FontStore::new();
let id = store.insert_with_descriptor(descriptor.clone(), font);
assert_eq!(store.resolve(&descriptor), Some(id));
// A different descriptor with no associated font is None.
assert_eq!(store.resolve(&FontRef::bold("System")), None);
}
#[test]
fn metrics_are_finite_and_non_zero() {
let Some(font) = try_load_system_font() else {
return;
};
let advance = font.h_advance_px(font.glyph_id('A'), 24.0);
assert!(advance.is_finite());
assert!(advance > 0.0);
let ascent = font.ascent_px(24.0);
let descent = font.descent_px(24.0);
assert!(ascent > 0.0);
// ab_glyph's `descent` is negative for descenders below the baseline.
assert!(descent <= 0.0);
assert!(font.line_height_px(24.0) > 0.0);
}
#[test]
fn rasterize_produces_bitmap_for_solid_glyph() {
let Some(font) = try_load_system_font() else {
return;
};
let raster = font
.rasterize(font.glyph_id('A'), 24.0)
.expect("'A' outlines");
assert!(raster.width > 0 && raster.height > 0);
assert_eq!(
raster.bitmap.len(),
(raster.width as usize) * (raster.height as usize)
);
// A capital A at 24px should have at least one fully-opaque pixel
// near its central stroke.
assert!(raster.bitmap.iter().any(|&p| p > 200));
// And some transparent pixels (it's not a solid square).
assert!(raster.bitmap.iter().any(|&p| p < 10));
}
#[test]
fn rasterize_space_returns_none_but_advance_works() {
let Some(font) = try_load_system_font() else {
return;
};
let space = font.glyph_id(' ');
// Space has no outline — rasterize returns None.
assert!(font.rasterize(space, 24.0).is_none());
// But the advance is still positive so the shaper can lay it out.
assert!(font.h_advance_px(space, 24.0) > 0.0);
}
#[test]
fn common_system_font_paths_returns_some_candidates() {
let paths = common_system_font_paths();
assert!(!paths.is_empty());
// Every entry should be an absolute path so the existence check is
// unambiguous on the host.
for p in paths {
assert!(p.starts_with('/'), "{p:?} should be an absolute path");
}
}
}