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:
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "oxide-engine"
|
||||
description = "Oxide 3D game engine — core library"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
glam.workspace = true
|
||||
hecs.workspace = true
|
||||
winit.workspace = true
|
||||
wgpu.workspace = true
|
||||
pollster.workspace = true
|
||||
bytemuck.workspace = true
|
||||
gltf.workspace = true
|
||||
ab_glyph.workspace = true
|
||||
log.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
ron.workspace = true
|
||||
notify.workspace = true
|
||||
oxide-engine-derive = { path = "../engine-derive" }
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger.workspace = true
|
||||
criterion.workspace = true
|
||||
|
||||
[[bench]]
|
||||
name = "transform"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "scene"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "app"
|
||||
harness = false
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Benchmark for the Stage 5 schedule/module overhead.
|
||||
//!
|
||||
//! Stage 5 criterion: module/system scheduling overhead must be negligible
|
||||
//! compared to the Stage-4 hardcoded loop. There is no per-frame work here — the
|
||||
//! benchmark measures the *frame overhead itself*: advancing timing, walking the
|
||||
//! phase lists, and the fixed-timestep accumulator, with a realistic handful of
|
||||
//! empty systems registered. Check that `app_empty_update` is in the low
|
||||
//! nanoseconds (i.e. lost in the noise next to any real system's work).
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use oxide_engine::app::{App, Schedule};
|
||||
|
||||
fn empty_update(c: &mut Criterion) {
|
||||
let mut app = App::new();
|
||||
// A few no-op systems spread across phases, as a trivial game might have.
|
||||
for _ in 0..4 {
|
||||
app.add_system(Schedule::Update, |_| {});
|
||||
}
|
||||
app.add_system(Schedule::FixedUpdate, |_| {});
|
||||
app.add_system(Schedule::Render, |_| {});
|
||||
|
||||
c.bench_function("app_empty_update", |bencher| {
|
||||
bencher.iter(|| {
|
||||
app.update(black_box(1.0 / 60.0));
|
||||
black_box(app.time.frame)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, empty_update);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Benchmark for scene world-transform resolution.
|
||||
//!
|
||||
//! Stage 3 test criterion: a 10,000-entity scene with a 5-level-deep hierarchy
|
||||
//! must resolve all world transforms in under 1ms. The `world_transforms_10k`
|
||||
//! benchmark builds exactly that scene and measures a full bulk resolve; check
|
||||
//! its reported time against the 1ms budget.
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use oxide_engine::math::{Transform, Vec3};
|
||||
use oxide_engine::scene::{Entity, Scene};
|
||||
|
||||
/// Builds a scene of `total` entities arranged as a `depth`-level hierarchy.
|
||||
///
|
||||
/// Level 0 holds the roots; each subsequent level's entities are distributed as
|
||||
/// children of the previous level, so the tree is `depth` levels deep and the
|
||||
/// node count is exactly `total`.
|
||||
fn build_scene(total: usize, depth: usize) -> Scene {
|
||||
let mut scene = Scene::new();
|
||||
let per_level = total / depth;
|
||||
let mut previous: Vec<Entity> = Vec::new();
|
||||
|
||||
for level in 0..depth {
|
||||
// The last level absorbs any remainder so the count is exact.
|
||||
let count = if level == depth - 1 {
|
||||
total - per_level * (depth - 1)
|
||||
} else {
|
||||
per_level
|
||||
};
|
||||
let mut current = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let t = Transform::from_translation(Vec3::new(0.01 * i as f32, 0.02, 0.03));
|
||||
let entity = if previous.is_empty() {
|
||||
scene.spawn("n", t)
|
||||
} else {
|
||||
// Spread children across the previous level round-robin.
|
||||
scene.spawn_child(previous[i % previous.len()], "n", t)
|
||||
};
|
||||
current.push(entity);
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
scene
|
||||
}
|
||||
|
||||
fn world_transforms_10k(c: &mut Criterion) {
|
||||
let scene = build_scene(10_000, 5);
|
||||
assert_eq!(scene.len(), 10_000);
|
||||
|
||||
c.bench_function("world_transforms_10k_depth5", |bencher| {
|
||||
bencher.iter(|| {
|
||||
let resolved = scene.world_transforms();
|
||||
black_box(resolved.len())
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, world_transforms_10k);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Benchmark for transform composition.
|
||||
//!
|
||||
//! Stage 1 test criterion: 1M transform multiplications must complete under
|
||||
//! 10ms. The `compose_1m` benchmark below measures exactly that workload; check
|
||||
//! its reported time against the 10ms budget.
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use oxide_engine::math::{Quat, Transform, Vec3};
|
||||
|
||||
fn compose_1m(c: &mut Criterion) {
|
||||
// A representative non-trivial transform (uniform scale → exact fast path).
|
||||
let a = Transform::from_trs(
|
||||
Vec3::new(1.0, 2.0, 3.0),
|
||||
Quat::from_euler(glam::EulerRot::XYZ, 0.3, 0.5, 0.7),
|
||||
Vec3::splat(1.5),
|
||||
);
|
||||
let b = Transform::from_trs(
|
||||
Vec3::new(-2.0, 0.5, 4.0),
|
||||
Quat::from_rotation_y(0.9),
|
||||
Vec3::splat(0.8),
|
||||
);
|
||||
|
||||
c.bench_function("compose_1m", |bencher| {
|
||||
bencher.iter(|| {
|
||||
// Compose 1M times. Inputs are re-fetched through `black_box` each
|
||||
// iteration so the optimizer can neither hoist the call nor let the
|
||||
// accumulated values blow up to infinity; the product is consumed.
|
||||
let mut acc = Vec3::ZERO;
|
||||
for _ in 0..1_000_000 {
|
||||
let product = black_box(a).mul_transform(&black_box(b));
|
||||
acc += product.translation;
|
||||
}
|
||||
black_box(acc)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn point_transform_1m(c: &mut Criterion) {
|
||||
let t = Transform::from_trs(
|
||||
Vec3::new(1.0, 2.0, 3.0),
|
||||
Quat::from_rotation_z(0.6),
|
||||
Vec3::splat(2.0),
|
||||
);
|
||||
c.bench_function("transform_point_1m", |bencher| {
|
||||
bencher.iter(|| {
|
||||
let mut acc = Vec3::ZERO;
|
||||
for i in 0..1_000_000u32 {
|
||||
let p = Vec3::splat(i as f32 * 1e-6);
|
||||
acc += t.transform_point(black_box(p));
|
||||
}
|
||||
black_box(acc)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, compose_1m, point_transform_1m);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,503 @@
|
||||
//! The application core: an [`App`] assembled by registering [`Module`]s.
|
||||
//!
|
||||
//! Stage 5 ties the core framework together. An `App` owns the shared engine
|
||||
//! state — the [`Scene`], the [`AssetServer`], the [`TypeRegistry`], the
|
||||
//! [`LayerRegistry`], frame [`Time`], and arbitrary user resources — plus a
|
||||
//! [`Schedule`] of systems. Functionality is added by **modules**: each
|
||||
//! [`Module::build`] registers systems, component types, asset loaders, and
|
||||
//! resources, so the engine is composed rather than hard-wired and an exported
|
||||
//! game compiles in only the modules it uses.
|
||||
//!
|
||||
//! ```
|
||||
//! use oxide_engine::app::{App, DefaultModules};
|
||||
//!
|
||||
//! let mut app = App::new();
|
||||
//! app.add_modules(DefaultModules);
|
||||
//! app.update(1.0 / 60.0); // advance one frame
|
||||
//! ```
|
||||
|
||||
mod module;
|
||||
mod schedule;
|
||||
|
||||
pub use module::{CoreModule, DefaultModules, Module, RenderModule};
|
||||
pub use schedule::Schedule;
|
||||
|
||||
use std::any::{Any, TypeId};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use schedule::{run_phase, SystemEntry, Systems};
|
||||
|
||||
use crate::asset::{AssetLoader, AssetServer};
|
||||
use crate::layer::LayerRegistry;
|
||||
use crate::reflect::TypeRegistry;
|
||||
use crate::scene::Scene;
|
||||
|
||||
/// The default fixed-timestep duration (60 Hz) for [`Schedule::FixedUpdate`].
|
||||
pub const DEFAULT_FIXED_TIMESTEP: f32 = 1.0 / 60.0;
|
||||
|
||||
/// An upper bound on fixed steps per frame, so a long stall (e.g. a breakpoint)
|
||||
/// cannot trigger an unbounded catch-up "spiral of death".
|
||||
const MAX_FIXED_STEPS_PER_FRAME: u32 = 8;
|
||||
|
||||
/// Per-frame timing, refreshed by [`App::update`] and readable by systems.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Time {
|
||||
/// Seconds elapsed since the previous frame.
|
||||
pub delta: f32,
|
||||
/// Seconds elapsed since the app started.
|
||||
pub elapsed: f32,
|
||||
/// The fixed-timestep duration used by [`Schedule::FixedUpdate`].
|
||||
pub fixed_delta: f32,
|
||||
/// Frames advanced so far.
|
||||
pub frame: u64,
|
||||
}
|
||||
|
||||
impl Default for Time {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
delta: 0.0,
|
||||
elapsed: 0.0,
|
||||
fixed_delta: DEFAULT_FIXED_TIMESTEP,
|
||||
frame: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The application core. See the [module docs](self).
|
||||
pub struct App {
|
||||
/// The active scene graph.
|
||||
pub scene: Scene,
|
||||
/// The shared asset server (built-in loaders registered).
|
||||
pub assets: AssetServer,
|
||||
/// The reflection/type registry for dual-editable components.
|
||||
pub types: TypeRegistry,
|
||||
/// The project's named layers.
|
||||
pub layers: LayerRegistry,
|
||||
/// Per-frame timing.
|
||||
pub time: Time,
|
||||
|
||||
resources: HashMap<TypeId, Box<dyn Any>>,
|
||||
systems: Systems,
|
||||
|
||||
/// Registered modules → enabled flag.
|
||||
modules: BTreeMap<&'static str, bool>,
|
||||
/// The module currently being built, so registrations can be attributed.
|
||||
current_module: Option<&'static str>,
|
||||
/// Per-module bookkeeping for clean removal.
|
||||
module_types: HashMap<&'static str, Vec<&'static str>>,
|
||||
module_loaders: HashMap<&'static str, Vec<String>>,
|
||||
module_resources: HashMap<&'static str, Vec<TypeId>>,
|
||||
|
||||
fixed_accumulator: f32,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// A new app with empty core state and no modules. The [`AssetServer`] comes
|
||||
/// with the engine's built-in loaders already registered.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
scene: Scene::new(),
|
||||
assets: AssetServer::new(),
|
||||
types: TypeRegistry::new(),
|
||||
layers: LayerRegistry::new(),
|
||||
time: Time::default(),
|
||||
resources: HashMap::new(),
|
||||
systems: Systems::default(),
|
||||
modules: BTreeMap::new(),
|
||||
current_module: None,
|
||||
module_types: HashMap::new(),
|
||||
module_loaders: HashMap::new(),
|
||||
module_resources: HashMap::new(),
|
||||
fixed_accumulator: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Modules -----------------------------------------------------------
|
||||
|
||||
/// Adds a module, running its [`Module::build`] and attributing everything
|
||||
/// it registers to it.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if a module with the same [`name`](Module::name) is already added.
|
||||
pub fn add_module<M: Module>(&mut self, module: M) -> &mut Self {
|
||||
let name = module.name();
|
||||
assert!(
|
||||
!self.modules.contains_key(name),
|
||||
"module '{name}' is already added"
|
||||
);
|
||||
self.modules.insert(name, true);
|
||||
let previous = self.current_module.replace(name);
|
||||
module.build(self);
|
||||
self.current_module = previous;
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a bundle of modules (e.g. [`DefaultModules`]).
|
||||
pub fn add_modules<B: ModuleBundle>(&mut self, bundle: B) -> &mut Self {
|
||||
bundle.add_to(self);
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether a module is registered.
|
||||
pub fn has_module(&self, name: &str) -> bool {
|
||||
self.modules.contains_key(name)
|
||||
}
|
||||
|
||||
/// The registered module names, sorted.
|
||||
pub fn modules(&self) -> impl Iterator<Item = &'static str> + '_ {
|
||||
self.modules.keys().copied()
|
||||
}
|
||||
|
||||
/// Whether a registered module is enabled. Unknown modules report `false`.
|
||||
pub fn is_module_enabled(&self, name: &str) -> bool {
|
||||
self.modules.get(name).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Enables or disables a module's systems without removing them. Disabled
|
||||
/// modules' systems are skipped each frame. Returns whether the module exists.
|
||||
pub fn set_module_enabled(&mut self, name: &str, enabled: bool) -> bool {
|
||||
match self.modules.get_mut(name) {
|
||||
Some(flag) => {
|
||||
*flag = enabled;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a module and everything it contributed — systems, registered
|
||||
/// component types, asset loaders, and resources — leaving no dangling
|
||||
/// references. Returns whether the module existed.
|
||||
pub fn remove_module(&mut self, name: &str) -> bool {
|
||||
if self.modules.remove(name).is_none() {
|
||||
return false;
|
||||
}
|
||||
self.systems.remove_module(name);
|
||||
for type_name in self.module_types.remove(name).unwrap_or_default() {
|
||||
self.types.unregister(type_name);
|
||||
}
|
||||
for ext in self.module_loaders.remove(name).unwrap_or_default() {
|
||||
self.assets.unregister_loader(&ext);
|
||||
}
|
||||
for type_id in self.module_resources.remove(name).unwrap_or_default() {
|
||||
self.resources.remove(&type_id);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether a system contributed by `module` should run this frame: systems
|
||||
/// with no owning module always run; module-owned systems run only while
|
||||
/// their module is enabled.
|
||||
pub(crate) fn is_system_enabled(&self, module: Option<&'static str>) -> bool {
|
||||
match module {
|
||||
None => true,
|
||||
Some(name) => self.is_module_enabled(name),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Registration (attributed to the current module) -------------------
|
||||
|
||||
/// Adds a system to a schedule phase. Systems run in phase order, then in
|
||||
/// registration order within a phase.
|
||||
pub fn add_system(
|
||||
&mut self,
|
||||
phase: Schedule,
|
||||
system: impl FnMut(&mut App) + 'static,
|
||||
) -> &mut Self {
|
||||
self.systems.push(
|
||||
phase,
|
||||
SystemEntry {
|
||||
module: self.current_module,
|
||||
run: Box::new(system),
|
||||
},
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
/// Registers a reflected component type under `name` (see [`TypeRegistry`]).
|
||||
pub fn register_type<T>(&mut self, name: &'static str) -> &mut Self
|
||||
where
|
||||
T: hecs::Component + serde::Serialize + serde::de::DeserializeOwned,
|
||||
{
|
||||
self.types.register::<T>(name);
|
||||
if let Some(module) = self.current_module {
|
||||
self.module_types.entry(module).or_default().push(name);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Registers an asset loader (see [`AssetServer::register_loader`]).
|
||||
pub fn add_loader<L: AssetLoader>(&mut self, loader: L) -> &mut Self {
|
||||
if let Some(module) = self.current_module {
|
||||
let exts = loader.extensions().iter().map(|e| e.to_lowercase());
|
||||
self.module_loaders.entry(module).or_default().extend(exts);
|
||||
}
|
||||
self.assets.register_loader(loader);
|
||||
self
|
||||
}
|
||||
|
||||
// --- Resources ---------------------------------------------------------
|
||||
|
||||
/// Inserts (or replaces) a shared resource of type `T`.
|
||||
pub fn insert_resource<T: 'static>(&mut self, value: T) -> &mut Self {
|
||||
let id = TypeId::of::<T>();
|
||||
if let Some(module) = self.current_module {
|
||||
self.module_resources.entry(module).or_default().push(id);
|
||||
}
|
||||
self.resources.insert(id, Box::new(value));
|
||||
self
|
||||
}
|
||||
|
||||
/// Borrows a resource of type `T`, or `None` if absent.
|
||||
pub fn get_resource<T: 'static>(&self) -> Option<&T> {
|
||||
self.resources
|
||||
.get(&TypeId::of::<T>())
|
||||
.and_then(|b| b.downcast_ref::<T>())
|
||||
}
|
||||
|
||||
/// Mutably borrows a resource of type `T`, or `None` if absent.
|
||||
pub fn get_resource_mut<T: 'static>(&mut self) -> Option<&mut T> {
|
||||
self.resources
|
||||
.get_mut(&TypeId::of::<T>())
|
||||
.and_then(|b| b.downcast_mut::<T>())
|
||||
}
|
||||
|
||||
/// Removes and returns the resource of type `T`, or `None` if absent.
|
||||
///
|
||||
/// Lets a system take exclusive ownership of a resource for the duration of
|
||||
/// a call — e.g. the physics step takes the `PhysicsWorld` out so it can
|
||||
/// borrow the [`Scene`] mutably at the same time — then re-inserts it.
|
||||
pub fn remove_resource<T: 'static>(&mut self) -> Option<T> {
|
||||
self.resources
|
||||
.remove(&TypeId::of::<T>())
|
||||
.and_then(|b| b.downcast::<T>().ok())
|
||||
.map(|b| *b)
|
||||
}
|
||||
|
||||
/// Whether a resource of type `T` is present.
|
||||
pub fn has_resource<T: 'static>(&self) -> bool {
|
||||
self.resources.contains_key(&TypeId::of::<T>())
|
||||
}
|
||||
|
||||
// --- Running -----------------------------------------------------------
|
||||
|
||||
/// Sets the fixed-timestep duration used by [`Schedule::FixedUpdate`].
|
||||
pub fn set_fixed_timestep(&mut self, seconds: f32) -> &mut Self {
|
||||
assert!(seconds > 0.0, "fixed timestep must be positive");
|
||||
self.time.fixed_delta = seconds;
|
||||
self
|
||||
}
|
||||
|
||||
/// The number of systems registered across all phases.
|
||||
pub fn system_count(&self) -> usize {
|
||||
self.systems.total()
|
||||
}
|
||||
|
||||
/// Advances one frame by `delta` seconds: runs the per-frame phases once and
|
||||
/// [`FixedUpdate`](Schedule::FixedUpdate) as many whole fixed steps as the
|
||||
/// accumulated time allows (capped to avoid a catch-up spiral).
|
||||
pub fn update(&mut self, delta: f32) {
|
||||
self.time.delta = delta;
|
||||
self.time.elapsed += delta;
|
||||
self.time.frame += 1;
|
||||
|
||||
// How many fixed steps to run this frame.
|
||||
self.fixed_accumulator += delta;
|
||||
let mut steps = (self.fixed_accumulator / self.time.fixed_delta) as u32;
|
||||
if steps > MAX_FIXED_STEPS_PER_FRAME {
|
||||
steps = MAX_FIXED_STEPS_PER_FRAME;
|
||||
self.fixed_accumulator = 0.0;
|
||||
} else {
|
||||
self.fixed_accumulator -= steps as f32 * self.time.fixed_delta;
|
||||
}
|
||||
|
||||
self.run_frame(steps);
|
||||
}
|
||||
|
||||
/// Advances **exactly one fixed timestep**: bumps frame time by
|
||||
/// [`fixed_delta`](Time::fixed_delta) and runs the per-frame phases once with
|
||||
/// a single [`FixedUpdate`](Schedule::FixedUpdate), bypassing the
|
||||
/// accumulator. This is the editor play-mode **Step** primitive — single-step
|
||||
/// the simulation while paused — and yields one deterministic tick.
|
||||
pub fn step(&mut self) {
|
||||
let dt = self.time.fixed_delta;
|
||||
self.time.delta = dt;
|
||||
self.time.elapsed += dt;
|
||||
self.time.frame += 1;
|
||||
self.run_frame(1);
|
||||
}
|
||||
|
||||
/// Runs the per-frame phases once with `fixed_steps` runs of
|
||||
/// [`FixedUpdate`](Schedule::FixedUpdate). The systems are moved out first so
|
||||
/// each gets exclusive `&mut App`, then anything registered mid-frame is
|
||||
/// folded back. Shared by [`update`](Self::update) and [`step`](Self::step).
|
||||
fn run_frame(&mut self, fixed_steps: u32) {
|
||||
let mut systems = std::mem::take(&mut self.systems);
|
||||
run_phase(&mut systems, self, Schedule::First);
|
||||
run_phase(&mut systems, self, Schedule::Input);
|
||||
run_phase(&mut systems, self, Schedule::PreUpdate);
|
||||
for _ in 0..fixed_steps {
|
||||
run_phase(&mut systems, self, Schedule::FixedUpdate);
|
||||
}
|
||||
run_phase(&mut systems, self, Schedule::Update);
|
||||
run_phase(&mut systems, self, Schedule::PostUpdate);
|
||||
run_phase(&mut systems, self, Schedule::Render);
|
||||
run_phase(&mut systems, self, Schedule::Last);
|
||||
|
||||
// Fold back anything registered during the frame, then restore.
|
||||
systems.merge(std::mem::take(&mut self.systems));
|
||||
self.systems = systems;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// A group of modules added together. Implemented for [`DefaultModules`] and for
|
||||
/// tuples, so `app.add_modules((ModuleA, ModuleB))` works.
|
||||
pub trait ModuleBundle {
|
||||
/// Adds every module in the bundle to `app`.
|
||||
fn add_to(self, app: &mut App);
|
||||
}
|
||||
|
||||
impl<A: Module> ModuleBundle for (A,) {
|
||||
fn add_to(self, app: &mut App) {
|
||||
app.add_module(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: Module, B: Module> ModuleBundle for (A, B) {
|
||||
fn add_to(self, app: &mut App) {
|
||||
app.add_module(self.0);
|
||||
app.add_module(self.1);
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: Module, B: Module, C: Module> ModuleBundle for (A, B, C) {
|
||||
fn add_to(self, app: &mut App) {
|
||||
app.add_module(self.0);
|
||||
app.add_module(self.1);
|
||||
app.add_module(self.2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::{Transform, Vec3};
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[test]
|
||||
fn empty_app_updates_and_advances_time() {
|
||||
let mut app = App::new();
|
||||
assert_eq!(app.time.frame, 0);
|
||||
app.update(0.5);
|
||||
assert_eq!(app.time.frame, 1);
|
||||
assert!((app.time.elapsed - 0.5).abs() < 1e-6);
|
||||
assert!((app.time.delta - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn systems_run_in_phase_then_registration_order() {
|
||||
let log = Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let mut app = App::new();
|
||||
let l = log.clone();
|
||||
app.add_system(Schedule::Update, move |_| l.borrow_mut().push("update-1"));
|
||||
let l = log.clone();
|
||||
app.add_system(Schedule::Update, move |_| l.borrow_mut().push("update-2"));
|
||||
let l = log.clone();
|
||||
app.add_system(Schedule::First, move |_| l.borrow_mut().push("first"));
|
||||
app.update(0.0);
|
||||
assert_eq!(*log.borrow(), vec!["first", "update-1", "update-2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_update_runs_by_accumulated_time() {
|
||||
let count = Rc::new(Cell::new(0u32));
|
||||
let mut app = App::new();
|
||||
app.set_fixed_timestep(0.1);
|
||||
let c = count.clone();
|
||||
app.add_system(Schedule::FixedUpdate, move |_| c.set(c.get() + 1));
|
||||
|
||||
app.update(0.25); // 0.25 / 0.1 = 2 whole steps, ~0.05 left over
|
||||
assert_eq!(count.get(), 2);
|
||||
// 0.05 carried + 0.06 = 0.11 -> 1 more step (kept off the exact float
|
||||
// boundary so the result is robust to f32 rounding).
|
||||
app.update(0.06);
|
||||
assert_eq!(count.get(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_update_is_capped_against_spiral() {
|
||||
let count = Rc::new(Cell::new(0u32));
|
||||
let mut app = App::new();
|
||||
app.set_fixed_timestep(0.001);
|
||||
let c = count.clone();
|
||||
app.add_system(Schedule::FixedUpdate, move |_| c.set(c.get() + 1));
|
||||
app.update(10.0); // would be 10000 steps; capped
|
||||
assert_eq!(count.get(), MAX_FIXED_STEPS_PER_FRAME);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_runs_one_fixed_tick_and_the_per_frame_phases_once() {
|
||||
let fixed = Rc::new(Cell::new(0u32));
|
||||
let update = Rc::new(Cell::new(0u32));
|
||||
let mut app = App::new();
|
||||
app.set_fixed_timestep(0.1);
|
||||
let f = fixed.clone();
|
||||
app.add_system(Schedule::FixedUpdate, move |_| f.set(f.get() + 1));
|
||||
let u = update.clone();
|
||||
app.add_system(Schedule::Update, move |_| u.set(u.get() + 1));
|
||||
|
||||
app.step();
|
||||
// Exactly one fixed step and one Update, regardless of accumulator.
|
||||
assert_eq!(fixed.get(), 1);
|
||||
assert_eq!(update.get(), 1);
|
||||
assert_eq!(app.time.frame, 1);
|
||||
assert!((app.time.elapsed - 0.1).abs() < 1e-6);
|
||||
assert!((app.time.delta - 0.1).abs() < 1e-6);
|
||||
|
||||
// A second step advances exactly one more, deterministically.
|
||||
app.step();
|
||||
assert_eq!(fixed.get(), 2);
|
||||
assert_eq!(update.get(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resources_round_trip() {
|
||||
let mut app = App::new();
|
||||
app.insert_resource(42u32);
|
||||
assert_eq!(app.get_resource::<u32>(), Some(&42));
|
||||
*app.get_resource_mut::<u32>().unwrap() += 1;
|
||||
assert_eq!(app.get_resource::<u32>(), Some(&43));
|
||||
assert!(app.get_resource::<String>().is_none());
|
||||
|
||||
// remove_resource takes ownership and clears the slot.
|
||||
assert_eq!(app.remove_resource::<u32>(), Some(43));
|
||||
assert!(!app.has_resource::<u32>());
|
||||
assert_eq!(app.remove_resource::<u32>(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_system_can_mutate_the_scene_each_frame() {
|
||||
let mut app = App::new();
|
||||
app.scene.spawn("a", Transform::IDENTITY);
|
||||
// Each Update, nudge every entity's transform.
|
||||
app.add_system(Schedule::Update, |app| {
|
||||
let entities: Vec<_> = app.scene.entities().collect();
|
||||
for e in entities {
|
||||
if let Some(mut t) = app.scene.get_mut::<Transform>(e) {
|
||||
t.translation += Vec3::X;
|
||||
}
|
||||
}
|
||||
});
|
||||
app.update(0.0);
|
||||
app.update(0.0);
|
||||
let e = app.scene.entities().next().unwrap();
|
||||
assert!((app.scene.local_transform(e).unwrap().translation.x - 2.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! The [`Module`] trait and the engine's built-in modules.
|
||||
//!
|
||||
//! A module is the unit of engine extension: it bundles systems, component
|
||||
//! types, asset loaders, and resources behind one documented entry point, so
|
||||
//! anyone — including AI agents — can add a capability by writing a module, and
|
||||
//! an exported game compiles in only the modules it registers. The editor
|
||||
//! integration half of the trait arrives in Stage 6.
|
||||
|
||||
use super::{App, ModuleBundle, Schedule};
|
||||
use crate::layer::{Layer, Tags};
|
||||
use crate::math::Transform;
|
||||
use crate::render::MeshRenderer;
|
||||
use crate::scene::Node;
|
||||
|
||||
/// A self-contained unit of engine functionality.
|
||||
///
|
||||
/// Implement [`build`](Self::build) to register everything the module provides
|
||||
/// via the [`App`] facade ([`add_system`](App::add_system),
|
||||
/// [`register_type`](App::register_type), [`add_loader`](App::add_loader),
|
||||
/// [`insert_resource`](App::insert_resource)). Everything registered during
|
||||
/// `build` is attributed to the module, so it can be enabled, disabled, or
|
||||
/// removed as a unit.
|
||||
pub trait Module: 'static {
|
||||
/// A stable, unique name (used to enable/disable/remove the module and, in
|
||||
/// later stages, to express dependencies).
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Registers the module's systems, types, loaders, and resources on `app`.
|
||||
fn build(&self, app: &mut App);
|
||||
}
|
||||
|
||||
/// The core module: registers the always-present scene component types for
|
||||
/// reflection (dual-editability), so the editor and scripts can address them.
|
||||
///
|
||||
/// This is the runtime "wrapper" for the math/scene/layer building blocks that
|
||||
/// already exist as plain library types — it does not add behavior, it exposes
|
||||
/// those types through the [`TypeRegistry`](crate::reflect::TypeRegistry).
|
||||
pub struct CoreModule;
|
||||
|
||||
impl Module for CoreModule {
|
||||
fn name(&self) -> &'static str {
|
||||
"core"
|
||||
}
|
||||
|
||||
fn build(&self, app: &mut App) {
|
||||
app.register_type::<Transform>("Transform");
|
||||
app.register_type::<Node>("Node");
|
||||
app.register_type::<Layer>("Layer");
|
||||
app.register_type::<Tags>("Tags");
|
||||
}
|
||||
}
|
||||
|
||||
/// The render module: registers the renderable scene components for reflection.
|
||||
///
|
||||
/// The forward renderer itself is driven by the editor/host today; this module
|
||||
/// is what makes [`MeshRenderer`] a first-class, dual-editable component. As the
|
||||
/// data-driven render pipeline grows it will register its render-phase systems
|
||||
/// here too.
|
||||
pub struct RenderModule;
|
||||
|
||||
impl Module for RenderModule {
|
||||
fn name(&self) -> &'static str {
|
||||
"render"
|
||||
}
|
||||
|
||||
fn build(&self, app: &mut App) {
|
||||
app.register_type::<MeshRenderer>("MeshRenderer");
|
||||
// Placeholder render-phase system so the phase is exercised; real passes
|
||||
// land with the Stage 5 render pipeline piece.
|
||||
app.add_system(Schedule::Render, |_app| {});
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's standard set of built-in modules, added with
|
||||
/// [`App::add_modules`](super::App::add_modules).
|
||||
///
|
||||
/// ```
|
||||
/// use oxide_engine::app::{App, DefaultModules};
|
||||
/// let mut app = App::new();
|
||||
/// app.add_modules(DefaultModules);
|
||||
/// assert!(app.has_module("core") && app.has_module("render"));
|
||||
/// ```
|
||||
pub struct DefaultModules;
|
||||
|
||||
impl ModuleBundle for DefaultModules {
|
||||
fn add_to(self, app: &mut App) {
|
||||
app.add_module(CoreModule);
|
||||
app.add_module(RenderModule);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::render::PrimitiveShape;
|
||||
|
||||
#[test]
|
||||
fn default_modules_register_core_types() {
|
||||
let mut app = App::new();
|
||||
app.add_modules(DefaultModules);
|
||||
assert!(app.has_module("core"));
|
||||
assert!(app.has_module("render"));
|
||||
assert!(app.types.is_registered("Transform"));
|
||||
assert!(app.types.is_registered("MeshRenderer"));
|
||||
assert_eq!(app.modules().collect::<Vec<_>>(), vec!["core", "render"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_module_removes_its_contributions() {
|
||||
let mut app = App::new();
|
||||
app.add_modules(DefaultModules);
|
||||
assert!(app.types.is_registered("MeshRenderer"));
|
||||
let systems_before = app.system_count();
|
||||
|
||||
assert!(app.remove_module("render"));
|
||||
// Its registered type is gone, its render system is gone, core remains.
|
||||
assert!(!app.has_module("render"));
|
||||
assert!(!app.types.is_registered("MeshRenderer"));
|
||||
assert!(app.types.is_registered("Transform"));
|
||||
assert!(app.system_count() < systems_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_a_module_skips_its_systems_without_removing() {
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
struct Ticker(Rc<Cell<u32>>);
|
||||
impl Module for Ticker {
|
||||
fn name(&self) -> &'static str {
|
||||
"ticker"
|
||||
}
|
||||
fn build(&self, app: &mut App) {
|
||||
let counter = self.0.clone();
|
||||
app.add_system(Schedule::Update, move |_| counter.set(counter.get() + 1));
|
||||
}
|
||||
}
|
||||
|
||||
let count = Rc::new(Cell::new(0u32));
|
||||
let mut app = App::new();
|
||||
app.add_module(Ticker(count.clone()));
|
||||
|
||||
app.update(0.0);
|
||||
assert_eq!(count.get(), 1);
|
||||
|
||||
app.set_module_enabled("ticker", false);
|
||||
app.update(0.0); // skipped
|
||||
assert_eq!(count.get(), 1);
|
||||
|
||||
app.set_module_enabled("ticker", true);
|
||||
app.update(0.0); // runs again
|
||||
assert_eq!(count.get(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_module_can_add_a_loader_removed_with_it() {
|
||||
// A module registers MeshRenderer + uses a primitive, then is removed.
|
||||
let mut app = App::new();
|
||||
app.add_module(RenderModule);
|
||||
// Sanity: the primitive enum the render component references is usable.
|
||||
assert_eq!(PrimitiveShape::ALL.len(), 3);
|
||||
assert!(app.remove_module("render"));
|
||||
assert!(!app.types.is_registered("MeshRenderer"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! The system schedule: the ordered phases an [`App`](super::App) runs each
|
||||
//! frame, and the per-phase lists of systems modules attach to.
|
||||
|
||||
use super::App;
|
||||
|
||||
/// The ordered phases of one frame.
|
||||
///
|
||||
/// Systems are attached to a phase and run in phase order; within a phase they
|
||||
/// run in registration order, so behavior is fully deterministic. The phases
|
||||
/// mirror a conventional game loop:
|
||||
///
|
||||
/// - [`First`](Self::First) — start-of-frame bookkeeping.
|
||||
/// - [`Input`](Self::Input) — gather input (Stage 7).
|
||||
/// - [`PreUpdate`](Self::PreUpdate) — engine work before game logic.
|
||||
/// - [`FixedUpdate`](Self::FixedUpdate) — fixed-timestep work; runs **zero or
|
||||
/// more** times per frame so simulation is frame-rate independent. Physics
|
||||
/// (Stage 9) lives here.
|
||||
/// - [`Update`](Self::Update) — per-frame game logic.
|
||||
/// - [`PostUpdate`](Self::PostUpdate) — engine work after game logic.
|
||||
/// - [`Render`](Self::Render) — drawing (Stage 5 pipeline onward).
|
||||
/// - [`Last`](Self::Last) — end-of-frame cleanup.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum Schedule {
|
||||
First,
|
||||
Input,
|
||||
PreUpdate,
|
||||
FixedUpdate,
|
||||
Update,
|
||||
PostUpdate,
|
||||
Render,
|
||||
Last,
|
||||
}
|
||||
|
||||
impl Schedule {
|
||||
/// The once-per-frame phases, in order (everything except `FixedUpdate`,
|
||||
/// which is driven separately by the fixed-timestep accumulator).
|
||||
pub(crate) const PER_FRAME: [Schedule; 7] = [
|
||||
Schedule::First,
|
||||
Schedule::Input,
|
||||
Schedule::PreUpdate,
|
||||
Schedule::Update,
|
||||
Schedule::PostUpdate,
|
||||
Schedule::Render,
|
||||
Schedule::Last,
|
||||
];
|
||||
}
|
||||
|
||||
/// One registered system: a closure plus the module that contributed it (so the
|
||||
/// module can be disabled or removed).
|
||||
pub(crate) struct SystemEntry {
|
||||
pub(crate) module: Option<&'static str>,
|
||||
pub(crate) run: Box<dyn FnMut(&mut App)>,
|
||||
}
|
||||
|
||||
/// The collection of systems, grouped by phase, in registration order.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Systems {
|
||||
first: Vec<SystemEntry>,
|
||||
input: Vec<SystemEntry>,
|
||||
pre_update: Vec<SystemEntry>,
|
||||
fixed_update: Vec<SystemEntry>,
|
||||
update: Vec<SystemEntry>,
|
||||
post_update: Vec<SystemEntry>,
|
||||
render: Vec<SystemEntry>,
|
||||
last: Vec<SystemEntry>,
|
||||
}
|
||||
|
||||
impl Systems {
|
||||
fn phase_mut(&mut self, phase: Schedule) -> &mut Vec<SystemEntry> {
|
||||
match phase {
|
||||
Schedule::First => &mut self.first,
|
||||
Schedule::Input => &mut self.input,
|
||||
Schedule::PreUpdate => &mut self.pre_update,
|
||||
Schedule::FixedUpdate => &mut self.fixed_update,
|
||||
Schedule::Update => &mut self.update,
|
||||
Schedule::PostUpdate => &mut self.post_update,
|
||||
Schedule::Render => &mut self.render,
|
||||
Schedule::Last => &mut self.last,
|
||||
}
|
||||
}
|
||||
|
||||
fn phase(&self, phase: Schedule) -> &[SystemEntry] {
|
||||
match phase {
|
||||
Schedule::First => &self.first,
|
||||
Schedule::Input => &self.input,
|
||||
Schedule::PreUpdate => &self.pre_update,
|
||||
Schedule::FixedUpdate => &self.fixed_update,
|
||||
Schedule::Update => &self.update,
|
||||
Schedule::PostUpdate => &self.post_update,
|
||||
Schedule::Render => &self.render,
|
||||
Schedule::Last => &self.last,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn push(&mut self, phase: Schedule, entry: SystemEntry) {
|
||||
self.phase_mut(phase).push(entry);
|
||||
}
|
||||
|
||||
pub(crate) fn total(&self) -> usize {
|
||||
Schedule::PER_FRAME
|
||||
.iter()
|
||||
.chain(std::iter::once(&Schedule::FixedUpdate))
|
||||
.map(|p| self.phase(*p).len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
const ALL_PHASES: [Schedule; 8] = [
|
||||
Schedule::First,
|
||||
Schedule::Input,
|
||||
Schedule::PreUpdate,
|
||||
Schedule::FixedUpdate,
|
||||
Schedule::Update,
|
||||
Schedule::PostUpdate,
|
||||
Schedule::Render,
|
||||
Schedule::Last,
|
||||
];
|
||||
|
||||
/// Drops every system contributed by `module`.
|
||||
pub(crate) fn remove_module(&mut self, module: &str) {
|
||||
for phase in Self::ALL_PHASES {
|
||||
self.phase_mut(phase).retain(|e| e.module != Some(module));
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends all of `other`'s systems (used to fold back systems registered
|
||||
/// while the frame was running).
|
||||
pub(crate) fn merge(&mut self, mut other: Systems) {
|
||||
for phase in Self::ALL_PHASES {
|
||||
let tail = std::mem::take(other.phase_mut(phase));
|
||||
self.phase_mut(phase).extend(tail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one phase: every enabled system in registration order.
|
||||
///
|
||||
/// The [`Systems`] are moved out of the [`App`] before phases run (so systems
|
||||
/// get exclusive `&mut App` access), so `systems` and `app` here are disjoint.
|
||||
/// Systems from a disabled module are skipped without being removed.
|
||||
pub(crate) fn run_phase(systems: &mut Systems, app: &mut App, phase: Schedule) {
|
||||
for entry in systems.phase_mut(phase) {
|
||||
if app.is_system_enabled(entry.module) {
|
||||
(entry.run)(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
//! [`AssetDatabase`]: stable, project-relative asset references.
|
||||
//!
|
||||
//! The [`AssetServer`](super::AssetServer) loads assets by *path*, but a scene
|
||||
//! or UI document must not bake **absolute** system paths into its saved data —
|
||||
//! that would break the moment the project is moved to another machine or
|
||||
//! directory, and it is the chief obstacle to a clean game export (Stage 16).
|
||||
//!
|
||||
//! The asset database is the bridge. It assigns every imported asset a stable
|
||||
//! [`AssetUid`] and records, per project, the mapping
|
||||
//! **`AssetUid` ↔ assets-relative path** (e.g. `"fonts/Inter-Regular.ttf"`).
|
||||
//! Saved documents reference assets by `AssetUid`; resolving a uid yields the
|
||||
//! relative path, which combined with the (possibly new) project root gives an
|
||||
//! absolute path the [`AssetServer`](super::AssetServer) loads and deduplicates.
|
||||
//! Because the stored mapping is purely relative, a reference resolves to the
|
||||
//! same [`Handle`] across save/load **and** after the whole project directory
|
||||
//! moves.
|
||||
//!
|
||||
//! The uid layer (rather than referencing by relative path directly) means an
|
||||
//! asset can later be *renamed or moved within* the project without breaking
|
||||
//! references — the uid travels with the file in the manifest.
|
||||
//!
|
||||
//! Assets live under typed subfolders of the project's `assets/` directory
|
||||
//! ([`AssetKind`] → folder), so the database (and the editor's browser) can
|
||||
//! present and filter them by type without inspecting file contents.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{AssetServer, Handle};
|
||||
use crate::project::ASSETS_DIR;
|
||||
|
||||
/// The manifest file (RON) at the project root recording the uid ↔ path map.
|
||||
///
|
||||
/// It sits at the root rather than inside `assets/` so a scan of the typed
|
||||
/// asset folders never treats the manifest itself as an asset.
|
||||
pub const ASSET_MANIFEST_FILE: &str = "assets.manifest";
|
||||
|
||||
/// The typed category of a project asset.
|
||||
///
|
||||
/// A kind fixes the asset's subfolder under `assets/` and the file extensions
|
||||
/// that belong to it, letting the database classify files by where they live
|
||||
/// (with extension as a fallback for files dropped directly in `assets/`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum AssetKind {
|
||||
/// Text rendering fonts (`fonts/`): `.ttf`, `.otf`.
|
||||
Font,
|
||||
/// Images and textures (`textures/`): `.png`, `.jpg`, …
|
||||
Texture,
|
||||
/// 3D models (`models/`): `.gltf`, `.glb`, `.obj`.
|
||||
Model,
|
||||
/// Sound and music (`audio/`): `.wav`, `.ogg`, …
|
||||
Audio,
|
||||
/// Serialized UI documents (`ui/`).
|
||||
Ui,
|
||||
/// Game-logic scripts (`scripts/`): `.rhai` (Stage 10).
|
||||
Script,
|
||||
/// Anything that does not fall into a known typed folder or extension.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl AssetKind {
|
||||
/// The typed kinds in their canonical order (excludes [`Other`](Self::Other),
|
||||
/// which has no folder of its own).
|
||||
pub const TYPED: [AssetKind; 6] = [
|
||||
AssetKind::Font,
|
||||
AssetKind::Texture,
|
||||
AssetKind::Model,
|
||||
AssetKind::Audio,
|
||||
AssetKind::Ui,
|
||||
AssetKind::Script,
|
||||
];
|
||||
|
||||
/// The subfolder name under `assets/` for this kind (empty for
|
||||
/// [`Other`](Self::Other), which has no dedicated folder).
|
||||
pub fn folder(self) -> &'static str {
|
||||
match self {
|
||||
AssetKind::Font => "fonts",
|
||||
AssetKind::Texture => "textures",
|
||||
AssetKind::Model => "models",
|
||||
AssetKind::Audio => "audio",
|
||||
AssetKind::Ui => "ui",
|
||||
AssetKind::Script => "scripts",
|
||||
AssetKind::Other => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// The lower-case file extensions (without the dot) that belong to this
|
||||
/// kind. [`Other`](Self::Other) claims none.
|
||||
pub fn extensions(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
AssetKind::Font => &["ttf", "otf"],
|
||||
AssetKind::Texture => &["png", "jpg", "jpeg", "tga", "bmp", "dds", "ktx2"],
|
||||
AssetKind::Model => &["gltf", "glb", "obj"],
|
||||
AssetKind::Audio => &["wav", "ogg", "mp3", "flac"],
|
||||
// UI documents share the `.ron` extension with scenes, so a UI asset
|
||||
// is recognised by its `ui/` folder rather than its extension.
|
||||
AssetKind::Ui => &[],
|
||||
AssetKind::Script => &["rhai"],
|
||||
AssetKind::Other => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind owning the typed `folder` name, if any.
|
||||
pub fn from_folder(folder: &str) -> Option<AssetKind> {
|
||||
AssetKind::TYPED.into_iter().find(|k| k.folder() == folder)
|
||||
}
|
||||
|
||||
/// The kind that claims `extension` (without the dot, any case), if any.
|
||||
pub fn from_extension(extension: &str) -> Option<AssetKind> {
|
||||
let ext = extension.to_lowercase();
|
||||
AssetKind::TYPED
|
||||
.into_iter()
|
||||
.find(|k| k.extensions().contains(&ext.as_str()))
|
||||
}
|
||||
|
||||
/// Classifies an assets-relative path. The leading folder wins (so a file in
|
||||
/// `ui/` is [`Ui`](Self::Ui) regardless of extension); files outside a typed
|
||||
/// folder fall back to their extension, else [`Other`](Self::Other).
|
||||
pub fn classify(relative_path: &str) -> AssetKind {
|
||||
if let Some((head, _)) = relative_path.split_once('/') {
|
||||
if let Some(kind) = AssetKind::from_folder(head) {
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
Path::new(relative_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.and_then(AssetKind::from_extension)
|
||||
.unwrap_or(AssetKind::Other)
|
||||
}
|
||||
|
||||
/// The kind an asset reference of target type `target` refers to, used to
|
||||
/// filter an asset picker. `target` is the inner type of an `AssetRef<T>`
|
||||
/// (or `Handle<T>`) field (see [`asset_ref_target`]); unknown targets yield
|
||||
/// `None` so the picker can offer every kind.
|
||||
pub fn for_handle_target(target: &str) -> Option<AssetKind> {
|
||||
match target {
|
||||
"Font" | "UiFont" => Some(AssetKind::Font),
|
||||
"GltfModel" | "Model" | "Mesh" => Some(AssetKind::Model),
|
||||
"Texture" | "Image" => Some(AssetKind::Texture),
|
||||
"AudioClip" | "Sound" | "Audio" => Some(AssetKind::Audio),
|
||||
"UiPanel" | "UiDocument" => Some(AssetKind::Ui),
|
||||
"ScriptAsset" | "Script" => Some(AssetKind::Script),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// If `type_name` is an asset-reference field spelling — [`AssetRef<T>`] (the
|
||||
/// serializable reference components store) or a bare [`Handle<T>`] — returns
|
||||
/// the inner target type's short name; otherwise `None`.
|
||||
///
|
||||
/// Reflection records a field's *syntactic* type name (see
|
||||
/// [`FieldInfo::type_name`](crate::reflect::FieldInfo::type_name)), which for an
|
||||
/// asset-reference field is something like `"AssetRef < Font >"` or
|
||||
/// `"Handle<crate::ui::Font>"`. This normalizes whitespace, unwraps the single
|
||||
/// generic argument, and strips any module path, yielding e.g. `"Font"`. The
|
||||
/// editor uses it to recognise such fields and pick the right asset filter via
|
||||
/// [`AssetKind::for_handle_target`].
|
||||
pub fn asset_ref_target(type_name: &str) -> Option<&str> {
|
||||
// Peel the wrapper structurally, trimming whitespace at each step, so both
|
||||
// `"AssetRef < Font >"` and `"AssetRef<Font>"` parse and we return a borrow
|
||||
// of the original string.
|
||||
let t = type_name.trim();
|
||||
let inner = t
|
||||
.strip_prefix("AssetRef")
|
||||
.or_else(|| t.strip_prefix("Handle"))?
|
||||
.trim_start();
|
||||
let inner = inner.strip_prefix('<')?.trim();
|
||||
let inner = inner.strip_suffix('>')?.trim();
|
||||
// Reject nested generics / multiple args we don't understand.
|
||||
if inner.contains('<') || inner.contains(',') {
|
||||
return None;
|
||||
}
|
||||
// Strip any module path (`crate::ui::Font` -> `Font`).
|
||||
Some(inner.rsplit("::").next().unwrap_or(inner).trim())
|
||||
}
|
||||
|
||||
/// A stable, per-project identifier for one asset.
|
||||
///
|
||||
/// Unlike [`AssetId`](super::AssetId) — which is process-unique and changes
|
||||
/// every run — an `AssetUid` is persisted in the project's manifest and stays
|
||||
/// attached to its asset across sessions, so saved references keep resolving.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct AssetUid(pub u64);
|
||||
|
||||
impl AssetUid {
|
||||
/// The raw numeric value.
|
||||
pub fn value(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed, serializable reference to a project asset.
|
||||
///
|
||||
/// This is what a **component** stores when it points at an asset (a UI label's
|
||||
/// font, a renderer's mesh, …). A live [`Handle<T>`] is not serializable and is
|
||||
/// tied to one process run, so persisting it would be wrong; an `AssetRef<T>`
|
||||
/// instead holds the stable [`AssetUid`] and resolves to a handle on demand via
|
||||
/// [`resolve`](Self::resolve) (database → relative path → server → handle).
|
||||
///
|
||||
/// Being a thin wrapper over `Option<AssetUid>`, it serializes compactly and
|
||||
/// round-trips through reflection's RON path, so an asset-reference field is
|
||||
/// editable in the inspector with no per-type code. The phantom `T` records the
|
||||
/// target asset type, which the editor reads from the field's spelling
|
||||
/// (`"AssetRef < Font >"`) via [`asset_ref_target`] to filter the picker.
|
||||
pub struct AssetRef<T> {
|
||||
uid: Option<AssetUid>,
|
||||
_marker: std::marker::PhantomData<fn() -> T>,
|
||||
}
|
||||
|
||||
impl<T> AssetRef<T> {
|
||||
/// An empty reference, pointing at no asset.
|
||||
pub const fn none() -> Self {
|
||||
Self {
|
||||
uid: None,
|
||||
_marker: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to the asset with stable id `uid`.
|
||||
pub const fn new(uid: AssetUid) -> Self {
|
||||
Self {
|
||||
uid: Some(uid),
|
||||
_marker: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
/// The referenced asset's stable id, or `None` if empty.
|
||||
pub fn uid(self) -> Option<AssetUid> {
|
||||
self.uid
|
||||
}
|
||||
|
||||
/// Whether this reference points at an asset.
|
||||
pub fn is_some(self) -> bool {
|
||||
self.uid.is_some()
|
||||
}
|
||||
|
||||
/// Points the reference at `uid` (or clears it with `None`).
|
||||
pub fn set(&mut self, uid: Option<AssetUid>) {
|
||||
self.uid = uid;
|
||||
}
|
||||
|
||||
/// Resolves to a loaded [`Handle<T>`] via `db` + `server`, or `None` if the
|
||||
/// reference is empty or its uid is unknown to the database.
|
||||
pub fn resolve(self, db: &AssetDatabase, server: &AssetServer) -> Option<Handle<T>>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
{
|
||||
db.load::<T>(server, self.uid?)
|
||||
}
|
||||
}
|
||||
|
||||
// Hand-written trait impls: deriving would wrongly require `T: Clone`/`Default`
|
||||
// etc., but an `AssetRef<T>` carries no `T` value — only a uid + phantom marker.
|
||||
impl<T> Clone for AssetRef<T> {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
impl<T> Copy for AssetRef<T> {}
|
||||
impl<T> Default for AssetRef<T> {
|
||||
fn default() -> Self {
|
||||
Self::none()
|
||||
}
|
||||
}
|
||||
impl<T> PartialEq for AssetRef<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.uid == other.uid
|
||||
}
|
||||
}
|
||||
impl<T> Eq for AssetRef<T> {}
|
||||
impl<T> std::fmt::Debug for AssetRef<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("AssetRef").field(&self.uid).finish()
|
||||
}
|
||||
}
|
||||
// Serialize transparently as the inner `Option<AssetUid>` so saved data is just
|
||||
// the uid (or unit `None`) and stays independent of `T`.
|
||||
impl<T> Serialize for AssetRef<T> {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
self.uid.serialize(serializer)
|
||||
}
|
||||
}
|
||||
impl<'de, T> Deserialize<'de> for AssetRef<T> {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
Ok(Self {
|
||||
uid: Option::<AssetUid>::deserialize(deserializer)?,
|
||||
_marker: std::marker::PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One asset's record in the database: its stable id, kind, and the path it
|
||||
/// lives at *relative to the project's `assets/` directory* (forward slashes).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssetEntry {
|
||||
/// The stable identifier saved references use.
|
||||
pub uid: AssetUid,
|
||||
/// The asset's typed category.
|
||||
pub kind: AssetKind,
|
||||
/// Path relative to `assets/`, e.g. `"fonts/Inter-Regular.ttf"`.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// The on-disk manifest: the uid allocator plus every known entry.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
struct Manifest {
|
||||
/// The next uid to hand out; persisted so a deleted asset's uid is never
|
||||
/// reused by a freshly imported one.
|
||||
next_uid: u64,
|
||||
/// Every recorded asset (sorted by uid when written, for stable diffs).
|
||||
entries: Vec<AssetEntry>,
|
||||
}
|
||||
|
||||
/// Maps stable asset ids to project-relative paths and back, and resolves them
|
||||
/// to [`Handle`]s through an [`AssetServer`](super::AssetServer).
|
||||
///
|
||||
/// Construct it for a project root with [`new`](Self::new) (empty) or
|
||||
/// [`open`](Self::open) (reading any existing manifest), then [`scan`](Self::scan)
|
||||
/// the asset folders or [`register`](Self::register) individual imports. The
|
||||
/// root may be changed with [`set_root`](Self::set_root) — e.g. after opening
|
||||
/// the same project from a new location — without disturbing the uid mapping.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AssetDatabase {
|
||||
root: PathBuf,
|
||||
by_uid: HashMap<AssetUid, AssetEntry>,
|
||||
by_path: HashMap<String, AssetUid>,
|
||||
next_uid: u64,
|
||||
}
|
||||
|
||||
impl AssetDatabase {
|
||||
/// An empty database for the project rooted at `root`.
|
||||
pub fn new(root: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
root: root.as_ref().to_path_buf(),
|
||||
by_uid: HashMap::new(),
|
||||
by_path: HashMap::new(),
|
||||
next_uid: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the database for the project at `root`, reading its manifest if
|
||||
/// present. A missing or unreadable manifest yields an empty database (a
|
||||
/// later [`scan`](Self::scan) repopulates it from disk).
|
||||
pub fn open(root: impl AsRef<Path>) -> Self {
|
||||
let root = root.as_ref().to_path_buf();
|
||||
let mut db = Self::new(&root);
|
||||
let manifest_path = root.join(ASSET_MANIFEST_FILE);
|
||||
if let Ok(text) = std::fs::read_to_string(&manifest_path) {
|
||||
if let Ok(manifest) = ron::from_str::<Manifest>(&text) {
|
||||
for entry in manifest.entries {
|
||||
db.by_path.insert(entry.path.clone(), entry.uid);
|
||||
db.by_uid.insert(entry.uid, entry);
|
||||
}
|
||||
db.next_uid = manifest.next_uid.max(db.highest_uid() + 1);
|
||||
}
|
||||
}
|
||||
db
|
||||
}
|
||||
|
||||
/// Writes the manifest to `<root>/assets.manifest`.
|
||||
pub fn save(&self) -> std::io::Result<()> {
|
||||
let mut entries: Vec<AssetEntry> = self.by_uid.values().cloned().collect();
|
||||
entries.sort_by_key(|e| e.uid);
|
||||
let manifest = Manifest {
|
||||
next_uid: self.next_uid,
|
||||
entries,
|
||||
};
|
||||
let text = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
std::fs::write(self.manifest_path(), text)
|
||||
}
|
||||
|
||||
/// The project root the database resolves paths against.
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// Points the database at a new project root (e.g. after the project
|
||||
/// directory moved). The uid ↔ relative-path mapping is unaffected, so all
|
||||
/// existing references keep resolving — now against the new location.
|
||||
pub fn set_root(&mut self, root: impl AsRef<Path>) {
|
||||
self.root = root.as_ref().to_path_buf();
|
||||
}
|
||||
|
||||
/// The `assets/` directory under the project root.
|
||||
pub fn assets_dir(&self) -> PathBuf {
|
||||
self.root.join(ASSETS_DIR)
|
||||
}
|
||||
|
||||
/// The manifest file path.
|
||||
pub fn manifest_path(&self) -> PathBuf {
|
||||
self.root.join(ASSET_MANIFEST_FILE)
|
||||
}
|
||||
|
||||
/// Records the asset at `relative_path` (relative to `assets/`), returning
|
||||
/// its uid — the existing one if already known, else a freshly allocated
|
||||
/// one. The kind is inferred from the path. Idempotent for a given path.
|
||||
pub fn register(&mut self, relative_path: impl AsRef<str>) -> AssetUid {
|
||||
let path = normalize_relative(relative_path.as_ref());
|
||||
if let Some(&uid) = self.by_path.get(&path) {
|
||||
return uid;
|
||||
}
|
||||
let uid = AssetUid(self.next_uid);
|
||||
self.next_uid += 1;
|
||||
let entry = AssetEntry {
|
||||
uid,
|
||||
kind: AssetKind::classify(&path),
|
||||
path: path.clone(),
|
||||
};
|
||||
self.by_path.insert(path, uid);
|
||||
self.by_uid.insert(uid, entry);
|
||||
uid
|
||||
}
|
||||
|
||||
/// Scans the typed asset folders under `assets/` and reconciles the
|
||||
/// database with what is on disk: existing files keep their uid, new files
|
||||
/// are [registered](Self::register), and entries whose files no longer exist
|
||||
/// are dropped. Returns the number of newly registered assets.
|
||||
///
|
||||
/// Call [`save`](Self::save) afterwards to persist any new uids.
|
||||
pub fn scan(&mut self) -> usize {
|
||||
let assets_dir = self.assets_dir();
|
||||
let mut found: Vec<String> = Vec::new();
|
||||
for kind in AssetKind::TYPED {
|
||||
collect_files(&assets_dir.join(kind.folder()), &assets_dir, &mut found);
|
||||
}
|
||||
|
||||
// Drop entries whose backing file disappeared.
|
||||
let present: std::collections::HashSet<&String> = found.iter().collect();
|
||||
let removed: Vec<(AssetUid, String)> = self
|
||||
.by_uid
|
||||
.values()
|
||||
.filter(|e| !present.contains(&e.path))
|
||||
.map(|e| (e.uid, e.path.clone()))
|
||||
.collect();
|
||||
for (uid, path) in removed {
|
||||
self.by_uid.remove(&uid);
|
||||
self.by_path.remove(&path);
|
||||
}
|
||||
|
||||
// Register anything new.
|
||||
let before = self.by_uid.len();
|
||||
for path in found {
|
||||
self.register(path);
|
||||
}
|
||||
self.by_uid.len().saturating_sub(before)
|
||||
}
|
||||
|
||||
/// The entry for `uid`, if known.
|
||||
pub fn entry(&self, uid: AssetUid) -> Option<&AssetEntry> {
|
||||
self.by_uid.get(&uid)
|
||||
}
|
||||
|
||||
/// The uid recorded for an assets-relative path, if any.
|
||||
pub fn uid_of(&self, relative_path: impl AsRef<str>) -> Option<AssetUid> {
|
||||
self.by_path
|
||||
.get(&normalize_relative(relative_path.as_ref()))
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// The assets-relative path for `uid`, if known.
|
||||
pub fn relative_path(&self, uid: AssetUid) -> Option<&str> {
|
||||
self.by_uid.get(&uid).map(|e| e.path.as_str())
|
||||
}
|
||||
|
||||
/// The absolute filesystem path for `uid` under the current root, if known.
|
||||
pub fn absolute_path(&self, uid: AssetUid) -> Option<PathBuf> {
|
||||
self.by_uid.get(&uid).map(|e| {
|
||||
self.assets_dir()
|
||||
.join(e.path.replace('/', std::path::MAIN_SEPARATOR_STR))
|
||||
})
|
||||
}
|
||||
|
||||
/// Every entry, in unspecified order.
|
||||
pub fn entries(&self) -> impl Iterator<Item = &AssetEntry> {
|
||||
self.by_uid.values()
|
||||
}
|
||||
|
||||
/// Entries of a given kind, in unspecified order.
|
||||
pub fn entries_of_kind(&self, kind: AssetKind) -> impl Iterator<Item = &AssetEntry> {
|
||||
self.by_uid.values().filter(move |e| e.kind == kind)
|
||||
}
|
||||
|
||||
/// The number of recorded assets.
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_uid.len()
|
||||
}
|
||||
|
||||
/// Whether the database has no entries.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.by_uid.is_empty()
|
||||
}
|
||||
|
||||
/// Resolves `uid` to a loaded [`Handle<T>`] via `server`, or `None` if the
|
||||
/// uid is unknown. The handle is deduplicated by the server, so resolving
|
||||
/// the same uid (even after the project moved) yields the same asset.
|
||||
pub fn load<T: Send + Sync + 'static>(
|
||||
&self,
|
||||
server: &AssetServer,
|
||||
uid: AssetUid,
|
||||
) -> Option<Handle<T>> {
|
||||
let path = self.absolute_path(uid)?;
|
||||
Some(server.load::<T>(path))
|
||||
}
|
||||
|
||||
// --- internals ---------------------------------------------------------
|
||||
|
||||
fn highest_uid(&self) -> u64 {
|
||||
self.by_uid.keys().map(|u| u.0).max().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalizes a path to the database's canonical relative form: forward slashes,
|
||||
/// no leading `./` or separator.
|
||||
fn normalize_relative(path: &str) -> String {
|
||||
let trimmed = path.replace('\\', "/");
|
||||
let trimmed = trimmed.strip_prefix("./").unwrap_or(&trimmed);
|
||||
trimmed.trim_start_matches('/').to_string()
|
||||
}
|
||||
|
||||
/// Recursively collects files under `dir`, pushing each one's path relative to
|
||||
/// `base` (forward slashes) into `out`. A missing `dir` is silently skipped.
|
||||
fn collect_files(dir: &Path, base: &Path, out: &mut Vec<String>) {
|
||||
let Ok(read) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in read.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_files(&path, base, out);
|
||||
} else if let Ok(rel) = path.strip_prefix(base) {
|
||||
out.push(rel.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
fn temp_root(tag: &str) -> PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"oxide_assetdb_test_{}_{}_{tag}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::SeqCst),
|
||||
));
|
||||
path
|
||||
}
|
||||
|
||||
/// Creates `assets/<rel>` under `root` with placeholder contents.
|
||||
fn touch_asset(root: &Path, rel: &str) {
|
||||
let full = root.join(ASSETS_DIR).join(rel);
|
||||
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
|
||||
std::fs::write(full, b"x").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_by_folder_then_extension() {
|
||||
assert_eq!(AssetKind::classify("fonts/Inter.ttf"), AssetKind::Font);
|
||||
assert_eq!(AssetKind::classify("ui/menu.ron"), AssetKind::Ui);
|
||||
assert_eq!(AssetKind::classify("textures/wall.png"), AssetKind::Texture);
|
||||
// No typed folder → fall back to extension.
|
||||
assert_eq!(AssetKind::classify("loose.glb"), AssetKind::Model);
|
||||
assert_eq!(AssetKind::classify("notes.md"), AssetKind::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_ref_target_parses_and_maps_to_kind() {
|
||||
// The derive's spelling (spaces around the generic args), for both the
|
||||
// serializable `AssetRef<T>` and a bare `Handle<T>`.
|
||||
assert_eq!(asset_ref_target("AssetRef < Font >"), Some("Font"));
|
||||
assert_eq!(asset_ref_target("Handle < Font >"), Some("Font"));
|
||||
// Compact and module-qualified spellings.
|
||||
assert_eq!(asset_ref_target("AssetRef<GltfModel>"), Some("GltfModel"));
|
||||
assert_eq!(asset_ref_target("Handle<crate::ui::Font>"), Some("Font"));
|
||||
// Non-reference and unsupported (nested / multi-arg) fields.
|
||||
assert_eq!(asset_ref_target("f32"), None);
|
||||
assert_eq!(asset_ref_target("Vec<AssetRef<Font>>"), None);
|
||||
assert_eq!(asset_ref_target("HashMap<String, u32>"), None);
|
||||
|
||||
// Target type -> picker filter kind.
|
||||
assert_eq!(AssetKind::for_handle_target("Font"), Some(AssetKind::Font));
|
||||
assert_eq!(
|
||||
AssetKind::for_handle_target("GltfModel"),
|
||||
Some(AssetKind::Model)
|
||||
);
|
||||
assert_eq!(AssetKind::for_handle_target("Whatever"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_ref_serializes_as_uid_and_resolves() {
|
||||
// Empty and populated references round-trip through RON as just the uid.
|
||||
let empty = AssetRef::<String>::none();
|
||||
assert!(!empty.is_some());
|
||||
let ron_empty = ron::to_string(&empty).unwrap();
|
||||
assert_eq!(
|
||||
ron::from_str::<AssetRef<String>>(&ron_empty).unwrap(),
|
||||
empty
|
||||
);
|
||||
|
||||
let r = AssetRef::<String>::new(AssetUid(7));
|
||||
let round: AssetRef<String> = ron::from_str(&ron::to_string(&r).unwrap()).unwrap();
|
||||
assert_eq!(round.uid(), Some(AssetUid(7)));
|
||||
|
||||
// resolve() goes ref -> db -> server -> handle.
|
||||
let root = temp_root("assetref");
|
||||
let full = root.join(ASSETS_DIR).join("textures");
|
||||
std::fs::create_dir_all(&full).unwrap();
|
||||
std::fs::write(full.join("a.txt"), "hi").unwrap();
|
||||
let mut db = AssetDatabase::new(&root);
|
||||
let uid = db.register("textures/a.txt");
|
||||
let server = AssetServer::empty();
|
||||
server.register_loader(TxtLoader);
|
||||
|
||||
let reference = AssetRef::<String>::new(uid);
|
||||
let handle = reference.resolve(&db, &server).unwrap();
|
||||
assert_eq!(handle.get().unwrap().as_str(), "hi");
|
||||
// An empty ref resolves to nothing.
|
||||
assert!(AssetRef::<String>::none().resolve(&db, &server).is_none());
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_is_idempotent_and_infers_kind() {
|
||||
let mut db = AssetDatabase::new(temp_root("register"));
|
||||
let a = db.register("fonts/Inter-Regular.ttf");
|
||||
let b = db.register("fonts/Inter-Regular.ttf");
|
||||
assert_eq!(a, b, "same path returns same uid");
|
||||
assert_eq!(db.len(), 1);
|
||||
assert_eq!(db.entry(a).unwrap().kind, AssetKind::Font);
|
||||
// Path normalization: a `./`-prefixed, back-slashed spelling collapses.
|
||||
assert_eq!(db.uid_of(".\\fonts\\Inter-Regular.ttf"), Some(a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_picks_up_typed_folders_and_prunes_missing() {
|
||||
let root = temp_root("scan");
|
||||
touch_asset(&root, "fonts/Inter.ttf");
|
||||
touch_asset(&root, "textures/wall.png");
|
||||
touch_asset(&root, "models/cube.glb");
|
||||
|
||||
let mut db = AssetDatabase::new(&root);
|
||||
assert_eq!(db.scan(), 3);
|
||||
assert_eq!(db.len(), 3);
|
||||
assert_eq!(db.entries_of_kind(AssetKind::Font).count(), 1);
|
||||
|
||||
// Remove one file and rescan: it is pruned, the rest keep their uids.
|
||||
let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
|
||||
let wall_uid = db.uid_of("textures/wall.png").unwrap();
|
||||
std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap();
|
||||
assert_eq!(db.scan(), 0);
|
||||
assert_eq!(db.len(), 2);
|
||||
assert!(db.entry(font_uid).is_none());
|
||||
assert_eq!(db.uid_of("textures/wall.png"), Some(wall_uid));
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_round_trips_uids() {
|
||||
let root = temp_root("manifest");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
touch_asset(&root, "fonts/Inter.ttf");
|
||||
touch_asset(&root, "audio/click.wav");
|
||||
|
||||
let mut db = AssetDatabase::new(&root);
|
||||
db.scan();
|
||||
let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
|
||||
let click_uid = db.uid_of("audio/click.wav").unwrap();
|
||||
let next = db.next_uid;
|
||||
db.save().unwrap();
|
||||
|
||||
// Reload from the manifest: every uid is preserved, and the allocator
|
||||
// does not reuse a freed id.
|
||||
let reloaded = AssetDatabase::open(&root);
|
||||
assert_eq!(reloaded.uid_of("fonts/Inter.ttf"), Some(font_uid));
|
||||
assert_eq!(reloaded.uid_of("audio/click.wav"), Some(click_uid));
|
||||
assert_eq!(reloaded.next_uid, next);
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_survives_save_load_and_moved_project() {
|
||||
// A reference (uid) saved with the project must resolve to the same
|
||||
// handle after reload AND after the whole project directory moves.
|
||||
let root = temp_root("move_src");
|
||||
touch_asset(&root, "fonts/Inter.ttf");
|
||||
let mut db = AssetDatabase::open(&root);
|
||||
db.scan();
|
||||
db.save().unwrap();
|
||||
let uid = db.uid_of("fonts/Inter.ttf").unwrap();
|
||||
|
||||
// Simulate moving the project to a new directory on disk.
|
||||
let moved = temp_root("move_dst");
|
||||
std::fs::create_dir_all(&moved).unwrap();
|
||||
copy_dir(&root, &moved);
|
||||
|
||||
// Open the database from the new location: same uid, new absolute path.
|
||||
let moved_db = AssetDatabase::open(&moved);
|
||||
assert_eq!(moved_db.uid_of("fonts/Inter.ttf"), Some(uid));
|
||||
let abs = moved_db.absolute_path(uid).unwrap();
|
||||
assert!(abs.starts_with(&moved));
|
||||
assert!(abs.exists());
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
std::fs::remove_dir_all(moved).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_dedups_through_the_server() {
|
||||
// Use a tiny custom loader so we don't need a real asset format.
|
||||
let root = temp_root("load");
|
||||
let full = root.join(ASSETS_DIR).join("textures");
|
||||
std::fs::create_dir_all(&full).unwrap();
|
||||
std::fs::write(full.join("a.txt"), "hi").unwrap();
|
||||
|
||||
let mut db = AssetDatabase::new(&root);
|
||||
let uid = db.register("textures/a.txt");
|
||||
|
||||
let server = AssetServer::empty();
|
||||
server.register_loader(TxtLoader);
|
||||
let h1 = db.load::<String>(&server, uid).unwrap();
|
||||
let h2 = db.load::<String>(&server, uid).unwrap();
|
||||
assert_eq!(h1.id(), h2.id(), "same uid resolves to one shared asset");
|
||||
assert_eq!(h1.get().unwrap().as_str(), "hi");
|
||||
assert!(db.load::<String>(&server, AssetUid(999)).is_none());
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
struct TxtLoader;
|
||||
impl crate::asset::AssetLoader for TxtLoader {
|
||||
type Asset = String;
|
||||
fn extensions(&self) -> &'static [&'static str] {
|
||||
&["txt"]
|
||||
}
|
||||
fn load(&self, path: &Path) -> Result<String, crate::asset::AssetError> {
|
||||
std::fs::read_to_string(path).map_err(|e| crate::asset::AssetError::Load {
|
||||
path: path.to_path_buf(),
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
for entry in std::fs::read_dir(from).unwrap().flatten() {
|
||||
let dst = to.join(entry.file_name());
|
||||
if entry.path().is_dir() {
|
||||
std::fs::create_dir_all(&dst).unwrap();
|
||||
copy_dir(&entry.path(), &dst);
|
||||
} else {
|
||||
std::fs::copy(entry.path(), dst).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! glTF 2.0 static-mesh importer.
|
||||
//!
|
||||
//! Loads the mesh primitives of a glTF document into engine [`Mesh`]es, reading
|
||||
//! their PBR-lite [`Material`] factors and the world [`Transform`] of each
|
||||
//! placement (the node hierarchy is flattened into world space). Missing
|
||||
//! normals are generated; missing UVs default to zero. Animation, skinning, and
|
||||
//! textures are out of scope for Stage 4.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::math::{Color, Transform, Vec2, Vec3};
|
||||
use crate::render::{Material, Mesh, Vertex};
|
||||
|
||||
/// Errors produced while importing a glTF document.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GltfError {
|
||||
/// The file could not be read or parsed as glTF.
|
||||
#[error("failed to load glTF: {0}")]
|
||||
Load(#[from] gltf::Error),
|
||||
|
||||
/// A mesh primitive was missing the required `POSITION` attribute.
|
||||
#[error("glTF primitive has no POSITION attribute")]
|
||||
MissingPositions,
|
||||
}
|
||||
|
||||
/// One imported mesh placement: geometry, material, and world transform.
|
||||
pub struct GltfMesh {
|
||||
/// Optional node/mesh name from the document.
|
||||
pub name: Option<String>,
|
||||
/// The primitive's geometry.
|
||||
pub mesh: Mesh,
|
||||
/// The primitive's PBR-lite material.
|
||||
pub material: Material,
|
||||
/// World-space placement (node hierarchy flattened).
|
||||
pub transform: Transform,
|
||||
}
|
||||
|
||||
/// An imported glTF model: a flat list of mesh placements in world space.
|
||||
pub struct GltfModel {
|
||||
/// Every mesh primitive in the default scene, already placed in world space.
|
||||
pub meshes: Vec<GltfMesh>,
|
||||
}
|
||||
|
||||
impl GltfModel {
|
||||
/// Total triangle count across all imported primitives.
|
||||
pub fn triangle_count(&self) -> usize {
|
||||
self.meshes.iter().map(|m| m.mesh.triangle_count()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports a glTF/GLB file from `path` (external buffers are resolved relative
|
||||
/// to the file).
|
||||
pub fn load_gltf(path: impl AsRef<Path>) -> Result<GltfModel, GltfError> {
|
||||
let (document, buffers, _images) = gltf::import(path)?;
|
||||
build_model(&document, &buffers)
|
||||
}
|
||||
|
||||
/// The [`AssetServer`](super::AssetServer) loader for glTF/GLB files.
|
||||
///
|
||||
/// Registered by default (handles `.gltf` and `.glb`), so
|
||||
/// `assets.load::<GltfModel>("model.gltf")` works out of the box; it simply
|
||||
/// wraps [`load_gltf`] and adapts its error into [`AssetError`].
|
||||
pub struct GltfLoader;
|
||||
|
||||
impl super::AssetLoader for GltfLoader {
|
||||
type Asset = GltfModel;
|
||||
|
||||
fn extensions(&self) -> &'static [&'static str] {
|
||||
&["gltf", "glb"]
|
||||
}
|
||||
|
||||
fn load(&self, path: &Path) -> Result<GltfModel, super::AssetError> {
|
||||
load_gltf(path).map_err(|err| super::AssetError::Load {
|
||||
path: path.to_path_buf(),
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports a glTF/GLB document from an in-memory byte slice (buffers must be
|
||||
/// embedded; used for tests and bundled assets).
|
||||
pub fn load_gltf_slice(bytes: &[u8]) -> Result<GltfModel, GltfError> {
|
||||
let (document, buffers, _images) = gltf::import_slice(bytes)?;
|
||||
build_model(&document, &buffers)
|
||||
}
|
||||
|
||||
/// Walks the default scene's node hierarchy, accumulating world transforms and
|
||||
/// emitting one [`GltfMesh`] per primitive.
|
||||
fn build_model(
|
||||
document: &gltf::Document,
|
||||
buffers: &[gltf::buffer::Data],
|
||||
) -> Result<GltfModel, GltfError> {
|
||||
let mut meshes = Vec::new();
|
||||
let scene = document
|
||||
.default_scene()
|
||||
.or_else(|| document.scenes().next());
|
||||
if let Some(scene) = scene {
|
||||
for node in scene.nodes() {
|
||||
visit_node(&node, Transform::IDENTITY, buffers, &mut meshes)?;
|
||||
}
|
||||
}
|
||||
Ok(GltfModel { meshes })
|
||||
}
|
||||
|
||||
fn visit_node(
|
||||
node: &gltf::Node,
|
||||
parent: Transform,
|
||||
buffers: &[gltf::buffer::Data],
|
||||
out: &mut Vec<GltfMesh>,
|
||||
) -> Result<(), GltfError> {
|
||||
let world = parent.mul_transform(&node_transform(node));
|
||||
|
||||
if let Some(mesh) = node.mesh() {
|
||||
for primitive in mesh.primitives() {
|
||||
let geometry = read_primitive(&primitive, buffers)?;
|
||||
out.push(GltfMesh {
|
||||
name: node.name().or_else(|| mesh.name()).map(str::to_owned),
|
||||
mesh: geometry,
|
||||
material: read_material(&primitive),
|
||||
transform: world,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for child in node.children() {
|
||||
visit_node(&child, world, buffers, out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Converts a node's local TRS into an engine [`Transform`].
|
||||
fn node_transform(node: &gltf::Node) -> Transform {
|
||||
let (t, r, s) = node.transform().decomposed();
|
||||
Transform::from_trs(
|
||||
Vec3::from_array(t),
|
||||
glam::Quat::from_array(r),
|
||||
Vec3::from_array(s),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads one primitive's vertices and indices into a [`Mesh`].
|
||||
fn read_primitive(
|
||||
primitive: &gltf::Primitive,
|
||||
buffers: &[gltf::buffer::Data],
|
||||
) -> Result<Mesh, GltfError> {
|
||||
let reader = primitive.reader(|buffer| Some(&buffers[buffer.index()]));
|
||||
|
||||
let positions: Vec<[f32; 3]> = reader
|
||||
.read_positions()
|
||||
.ok_or(GltfError::MissingPositions)?
|
||||
.collect();
|
||||
|
||||
let normals: Option<Vec<[f32; 3]>> = reader.read_normals().map(|n| n.collect());
|
||||
let uvs: Option<Vec<[f32; 2]>> = reader.read_tex_coords(0).map(|tc| tc.into_f32().collect());
|
||||
|
||||
let indices: Vec<u32> = match reader.read_indices() {
|
||||
Some(idx) => idx.into_u32().collect(),
|
||||
// Non-indexed primitive: every three positions form a triangle.
|
||||
None => (0..positions.len() as u32).collect(),
|
||||
};
|
||||
|
||||
// Generate flat normals when the document omits them, so lighting still works.
|
||||
let normals = normals.unwrap_or_else(|| compute_normals(&positions, &indices));
|
||||
|
||||
let vertices = positions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &p)| {
|
||||
let n = normals.get(i).copied().unwrap_or([0.0, 1.0, 0.0]);
|
||||
let uv = uvs
|
||||
.as_ref()
|
||||
.and_then(|u| u.get(i))
|
||||
.copied()
|
||||
.unwrap_or([0.0, 0.0]);
|
||||
Vertex::new(
|
||||
Vec3::from_array(p),
|
||||
Vec3::from_array(n),
|
||||
Vec2::from_array(uv),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Mesh::new(vertices, indices))
|
||||
}
|
||||
|
||||
/// Smooth per-vertex normals: accumulate each triangle's face normal at its
|
||||
/// vertices, then normalize.
|
||||
fn compute_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
|
||||
let mut normals = vec![Vec3::ZERO; positions.len()];
|
||||
for tri in indices.chunks_exact(3) {
|
||||
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
|
||||
let pa = Vec3::from_array(positions[a]);
|
||||
let pb = Vec3::from_array(positions[b]);
|
||||
let pc = Vec3::from_array(positions[c]);
|
||||
let face = (pb - pa).cross(pc - pa);
|
||||
normals[a] += face;
|
||||
normals[b] += face;
|
||||
normals[c] += face;
|
||||
}
|
||||
normals
|
||||
.into_iter()
|
||||
.map(|n| n.normalize_or_zero().to_array())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Maps a primitive's PBR metallic-roughness factors onto a [`Material`].
|
||||
fn read_material(primitive: &gltf::Primitive) -> Material {
|
||||
let pbr = primitive.material().pbr_metallic_roughness();
|
||||
let [r, g, b, a] = pbr.base_color_factor();
|
||||
Material {
|
||||
albedo: Color::rgba(r, g, b, a),
|
||||
metallic: pbr.metallic_factor(),
|
||||
roughness: pbr.roughness_factor(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! [`Handle`]: a typed, ref-counted reference to a loaded asset.
|
||||
//!
|
||||
//! A handle is the unit of *ownership* in the asset system. It is cheap to clone
|
||||
//! (an `Arc` bump), and the asset behind it lives exactly as long as at least
|
||||
//! one handle does — drop the last handle and the asset is freed. The
|
||||
//! [`AssetServer`](super::AssetServer) keeps only a [`Weak`] reference in its
|
||||
//! dedup cache, so it never keeps an otherwise-unused asset alive.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
/// A process-unique identifier assigned to every asset slot.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct AssetId(pub(crate) u64);
|
||||
|
||||
impl AssetId {
|
||||
/// The raw numeric id.
|
||||
pub fn value(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The lifecycle state of an asset behind a [`Handle`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LoadState {
|
||||
/// A background load is in progress; the value is not ready yet.
|
||||
Loading,
|
||||
/// The asset loaded successfully and can be read with [`Handle::get`].
|
||||
Loaded,
|
||||
/// Loading failed; see [`Handle::error`] for why.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// The interior of an asset slot: its current state and (once ready) the value.
|
||||
///
|
||||
/// The value is stored as an `Arc<T>` so it can be cloned out cheaply and so a
|
||||
/// live reload can swap in fresh contents without disturbing readers that
|
||||
/// already hold the previous `Arc`.
|
||||
pub(crate) enum CellState<T> {
|
||||
Loading,
|
||||
Loaded(Arc<T>),
|
||||
Failed(Arc<str>),
|
||||
}
|
||||
|
||||
/// The shared, reference-counted storage for one asset.
|
||||
///
|
||||
/// Handles hold an `Arc<AssetCell<T>>`; the server's cache holds a
|
||||
/// `Weak<dyn Any>` to the same allocation for deduplication only.
|
||||
pub(crate) struct AssetCell<T> {
|
||||
id: AssetId,
|
||||
source: Option<PathBuf>,
|
||||
state: Mutex<CellState<T>>,
|
||||
ready: Condvar,
|
||||
}
|
||||
|
||||
impl<T> AssetCell<T> {
|
||||
pub(crate) fn new_loading(id: AssetId, source: Option<PathBuf>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
id,
|
||||
source,
|
||||
state: Mutex::new(CellState::Loading),
|
||||
ready: Condvar::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn new_loaded(id: AssetId, source: Option<PathBuf>, value: T) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
id,
|
||||
source,
|
||||
state: Mutex::new(CellState::Loaded(Arc::new(value))),
|
||||
ready: Condvar::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn new_failed(id: AssetId, source: Option<PathBuf>, message: String) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
id,
|
||||
source,
|
||||
state: Mutex::new(CellState::Failed(Arc::from(message))),
|
||||
ready: Condvar::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn set_loaded(&self, value: T) {
|
||||
*self.state.lock().unwrap() = CellState::Loaded(Arc::new(value));
|
||||
self.ready.notify_all();
|
||||
}
|
||||
|
||||
pub(crate) fn set_failed(&self, message: String) {
|
||||
*self.state.lock().unwrap() = CellState::Failed(Arc::from(message));
|
||||
self.ready.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed, reference-counted handle to an asset of type `T`.
|
||||
///
|
||||
/// Clone it freely to share ownership; the asset is freed when the last handle
|
||||
/// is dropped. Read the value with [`get`](Self::get) (returns `None` until the
|
||||
/// asset is loaded) or block for it with [`wait`](Self::wait).
|
||||
pub struct Handle<T> {
|
||||
cell: Arc<AssetCell<T>>,
|
||||
}
|
||||
|
||||
impl<T> Handle<T> {
|
||||
pub(crate) fn from_cell(cell: Arc<AssetCell<T>>) -> Self {
|
||||
Self { cell }
|
||||
}
|
||||
|
||||
/// This asset's process-unique id.
|
||||
pub fn id(&self) -> AssetId {
|
||||
self.cell.id
|
||||
}
|
||||
|
||||
/// The source path the asset was loaded from, if any (in-memory assets added
|
||||
/// with [`AssetServer::add`](super::AssetServer::add) have none).
|
||||
pub fn source(&self) -> Option<&Path> {
|
||||
self.cell.source.as_deref()
|
||||
}
|
||||
|
||||
/// The current lifecycle state.
|
||||
pub fn state(&self) -> LoadState {
|
||||
match &*self.cell.state.lock().unwrap() {
|
||||
CellState::Loading => LoadState::Loading,
|
||||
CellState::Loaded(_) => LoadState::Loaded,
|
||||
CellState::Failed(_) => LoadState::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the asset has finished loading successfully.
|
||||
pub fn is_loaded(&self) -> bool {
|
||||
matches!(&*self.cell.state.lock().unwrap(), CellState::Loaded(_))
|
||||
}
|
||||
|
||||
/// The loaded value as a cheap `Arc<T>` clone, or `None` if it is still
|
||||
/// loading or failed.
|
||||
pub fn get(&self) -> Option<Arc<T>> {
|
||||
match &*self.cell.state.lock().unwrap() {
|
||||
CellState::Loaded(value) => Some(value.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The error message if loading failed, else `None`.
|
||||
pub fn error(&self) -> Option<String> {
|
||||
match &*self.cell.state.lock().unwrap() {
|
||||
CellState::Failed(message) => Some(message.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocks until the asset is no longer [`Loading`](LoadState::Loading),
|
||||
/// returning the value on success or `None` if it failed.
|
||||
pub fn wait(&self) -> Option<Arc<T>> {
|
||||
let mut guard = self.cell.state.lock().unwrap();
|
||||
loop {
|
||||
match &*guard {
|
||||
CellState::Loading => guard = self.cell.ready.wait(guard).unwrap(),
|
||||
CellState::Loaded(value) => return Some(value.clone()),
|
||||
CellState::Failed(_) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of live handles to this asset (including this one). The
|
||||
/// server holds only a weak reference, so this counts handles alone.
|
||||
pub fn ref_count(&self) -> usize {
|
||||
Arc::strong_count(&self.cell)
|
||||
}
|
||||
|
||||
/// Replaces the asset's contents in place; every existing handle observes
|
||||
/// the new value on its next [`get`](Self::get). Used by live reload.
|
||||
pub(crate) fn set_loaded(&self, value: T) {
|
||||
self.cell.set_loaded(value);
|
||||
}
|
||||
|
||||
/// Marks the asset as failed in place.
|
||||
pub(crate) fn set_failed(&self, message: String) {
|
||||
self.cell.set_failed(message);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Clone for Handle<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
cell: self.cell.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> fmt::Debug for Handle<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Handle")
|
||||
.field("id", &self.cell.id.0)
|
||||
.field("state", &self.state())
|
||||
.field("source", &self.cell.source)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Asset loading and management.
|
||||
//!
|
||||
//! Stage 4 introduced the first importer: a static-mesh [`glTF`](gltf) loader
|
||||
//! that turns a `.gltf`/`.glb` file into engine [`Mesh`](crate::render::Mesh)es,
|
||||
//! [`Material`](crate::render::Material)s, and placement [`Transform`](crate::math::Transform)s.
|
||||
//!
|
||||
//! Stage 5 adds the [`AssetServer`]: a central registry that loads assets through
|
||||
//! pluggable [`AssetLoader`]s, deduplicates by path+type, and hands out
|
||||
//! reference-counted [`Handle`]s (an asset lives as long as a handle to it does).
|
||||
//! It supports synchronous and background loading and in-place [reload](AssetServer::reload),
|
||||
//! the foundation later stages build live reload, streaming, and export packing
|
||||
//! on. The standalone [`load_gltf`] importer stays available; the server reaches
|
||||
//! it through the built-in [`GltfLoader`].
|
||||
|
||||
mod database;
|
||||
mod gltf;
|
||||
mod handle;
|
||||
mod server;
|
||||
|
||||
pub use database::{
|
||||
asset_ref_target, AssetDatabase, AssetEntry, AssetKind, AssetRef, AssetUid, ASSET_MANIFEST_FILE,
|
||||
};
|
||||
pub use gltf::{load_gltf, load_gltf_slice, GltfError, GltfLoader, GltfMesh, GltfModel};
|
||||
pub use handle::{AssetId, Handle, LoadState};
|
||||
pub use server::{AssetError, AssetLoader, AssetServer};
|
||||
|
||||
/// Registers the engine's built-in asset loaders on `server`. Called by
|
||||
/// [`AssetServer::new`].
|
||||
pub(crate) fn register_default_loaders(server: &AssetServer) {
|
||||
server.register_loader(GltfLoader);
|
||||
server.register_loader(crate::ui::FontLoader);
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
//! [`AssetServer`]: the central registry that loads, deduplicates, and hands out
|
||||
//! [`Handle`]s, plus the [`AssetLoader`] trait that makes it extensible.
|
||||
|
||||
use std::any::{Any, TypeId};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock, Weak};
|
||||
|
||||
use super::handle::{AssetCell, AssetId, Handle};
|
||||
|
||||
/// Errors produced while loading assets.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AssetError {
|
||||
/// The path had no file extension to pick a loader by.
|
||||
#[error("path has no file extension: {0}")]
|
||||
NoExtension(PathBuf),
|
||||
|
||||
/// No loader was registered for the file's extension.
|
||||
#[error("no loader registered for extension '.{0}'")]
|
||||
NoLoader(String),
|
||||
|
||||
/// A loader exists for the extension, but it produces a different asset
|
||||
/// type than the one requested at the call site.
|
||||
#[error("loader for '.{ext}' produces a different asset type than requested")]
|
||||
TypeMismatch {
|
||||
/// The extension whose loader was selected.
|
||||
ext: String,
|
||||
},
|
||||
|
||||
/// The loader itself failed (I/O, parse, etc.).
|
||||
#[error("failed to load {path}: {message}")]
|
||||
Load {
|
||||
/// The asset path.
|
||||
path: PathBuf,
|
||||
/// The loader's error message.
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A pluggable importer that turns a file into an asset of one concrete type.
|
||||
///
|
||||
/// Implement this for each asset format and register it with
|
||||
/// [`AssetServer::register_loader`]. The server dispatches by file extension and
|
||||
/// checks that the loader's [`Asset`](Self::Asset) type matches what the caller
|
||||
/// asked to load.
|
||||
pub trait AssetLoader: Send + Sync + 'static {
|
||||
/// The type this loader produces.
|
||||
type Asset: Send + Sync + 'static;
|
||||
|
||||
/// The lower-or-mixed-case extensions (without the dot) this loader handles,
|
||||
/// e.g. `&["gltf", "glb"]`.
|
||||
fn extensions(&self) -> &'static [&'static str];
|
||||
|
||||
/// Loads and parses the asset at `path`.
|
||||
fn load(&self, path: &Path) -> Result<Self::Asset, AssetError>;
|
||||
}
|
||||
|
||||
/// Type-erased view of an [`AssetLoader`] so loaders of different output types
|
||||
/// can share one registry.
|
||||
trait ErasedLoader: Send + Sync {
|
||||
fn output_type(&self) -> TypeId;
|
||||
fn load(&self, path: &Path) -> Result<Box<dyn Any + Send + Sync>, AssetError>;
|
||||
}
|
||||
|
||||
impl<L: AssetLoader> ErasedLoader for L {
|
||||
fn output_type(&self) -> TypeId {
|
||||
TypeId::of::<L::Asset>()
|
||||
}
|
||||
|
||||
fn load(&self, path: &Path) -> Result<Box<dyn Any + Send + Sync>, AssetError> {
|
||||
Ok(Box::new(<L as AssetLoader>::load(self, path)?))
|
||||
}
|
||||
}
|
||||
|
||||
type CacheKey = (TypeId, PathBuf);
|
||||
|
||||
/// One entry in the dedup cache. Carries a weak reference to the asset cell so
|
||||
/// dropped assets are pruned, plus a function pointer that knows how to rerun
|
||||
/// the loader for the cell's concrete type. Storing the reload-by-type as a
|
||||
/// per-entry `fn` is what lets [`AssetServer::reload_path`] reload an asset
|
||||
/// without knowing its `T` at the call site — the original `insert_cache::<T>`
|
||||
/// captures `T` into the function pointer.
|
||||
#[derive(Clone)]
|
||||
struct CacheEntry {
|
||||
weak: Weak<dyn Any + Send + Sync>,
|
||||
reload_in_place: fn(&AssetServer, &Path),
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
loaders: RwLock<HashMap<String, Arc<dyn ErasedLoader>>>,
|
||||
/// Dedup cache: weak references, so a cached asset with no live handles is
|
||||
/// collected and reloaded fresh next time.
|
||||
cache: Mutex<HashMap<CacheKey, CacheEntry>>,
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
/// The central asset registry.
|
||||
///
|
||||
/// Cloning an `AssetServer` is cheap (it shares one inner state via `Arc`) so it
|
||||
/// can be handed to background load threads and stored across systems. Loading
|
||||
/// the same path+type twice returns handles to **one** shared asset; when the
|
||||
/// last handle is dropped the asset is freed.
|
||||
///
|
||||
/// ```no_run
|
||||
/// use oxide_engine::asset::AssetServer;
|
||||
/// use oxide_engine::asset::GltfModel;
|
||||
///
|
||||
/// let assets = AssetServer::new(); // glTF loader registered by default
|
||||
/// let model = assets.load::<GltfModel>("assets/models/cube.gltf");
|
||||
/// if let Some(model) = model.get() {
|
||||
/// println!("{} meshes", model.meshes.len());
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct AssetServer {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl AssetServer {
|
||||
/// A server with the engine's built-in loaders registered (currently glTF).
|
||||
pub fn new() -> Self {
|
||||
let server = Self::empty();
|
||||
super::register_default_loaders(&server);
|
||||
server
|
||||
}
|
||||
|
||||
/// A server with **no** loaders registered. Use [`register_loader`] to add
|
||||
/// them; handy for tests or fully custom asset pipelines.
|
||||
///
|
||||
/// [`register_loader`]: Self::register_loader
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
loaders: RwLock::new(HashMap::new()),
|
||||
cache: Mutex::new(HashMap::new()),
|
||||
next_id: AtomicU64::new(1),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers `loader`, mapping each of its extensions to it.
|
||||
pub fn register_loader<L: AssetLoader>(&self, loader: L) {
|
||||
let exts: Vec<String> = loader
|
||||
.extensions()
|
||||
.iter()
|
||||
.map(|e| e.to_lowercase())
|
||||
.collect();
|
||||
let erased: Arc<dyn ErasedLoader> = Arc::new(loader);
|
||||
let mut loaders = self.inner.loaders.write().unwrap();
|
||||
for ext in exts {
|
||||
loaders.insert(ext, erased.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the loader registered for `extension` (without the dot). Returns
|
||||
/// whether one was present. Used when a module that added a loader is removed.
|
||||
pub fn unregister_loader(&self, extension: &str) -> bool {
|
||||
self.inner
|
||||
.loaders
|
||||
.write()
|
||||
.unwrap()
|
||||
.remove(&extension.to_lowercase())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Loads the asset at `path` as type `T`, blocking until it is ready.
|
||||
///
|
||||
/// Returns a handle to a cached asset if one of the same path+type is
|
||||
/// already live. On failure the returned handle is in the
|
||||
/// [`Failed`](super::LoadState::Failed) state (inspect [`Handle::error`]).
|
||||
pub fn load<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Handle<T> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let key = (TypeId::of::<T>(), path.clone());
|
||||
if let Some(handle) = self.cached::<T>(&key) {
|
||||
return handle;
|
||||
}
|
||||
match self.run_loader::<T>(&path) {
|
||||
Ok(value) => {
|
||||
let cell = AssetCell::new_loaded(self.next_id(), Some(path), value);
|
||||
self.insert_cache(key, &cell);
|
||||
Handle::from_cell(cell)
|
||||
}
|
||||
// Failures are not cached, so a later load retries from scratch.
|
||||
Err(err) => Handle::from_cell(AssetCell::new_failed(
|
||||
self.next_id(),
|
||||
Some(path),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the asset at `path` as type `T` on a background thread, returning a
|
||||
/// handle immediately in the [`Loading`](super::LoadState::Loading) state.
|
||||
///
|
||||
/// Poll [`Handle::state`]/[`Handle::get`], or block with [`Handle::wait`].
|
||||
pub fn load_async<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Handle<T> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let key = (TypeId::of::<T>(), path.clone());
|
||||
if let Some(handle) = self.cached::<T>(&key) {
|
||||
return handle;
|
||||
}
|
||||
// Insert the loading cell up front so concurrent requests dedup onto it.
|
||||
let cell = AssetCell::<T>::new_loading(self.next_id(), Some(path.clone()));
|
||||
self.insert_cache(key.clone(), &cell);
|
||||
|
||||
let server = self.clone();
|
||||
let worker_cell = cell.clone();
|
||||
std::thread::spawn(move || match server.run_loader::<T>(&path) {
|
||||
Ok(value) => worker_cell.set_loaded(value),
|
||||
Err(err) => {
|
||||
worker_cell.set_failed(err.to_string());
|
||||
// Don't leave a failed slot cached.
|
||||
server.inner.cache.lock().unwrap().remove(&key);
|
||||
}
|
||||
});
|
||||
Handle::from_cell(cell)
|
||||
}
|
||||
|
||||
/// Adds an already-constructed, in-memory asset and returns a handle to it.
|
||||
/// In-memory assets have no source path and are not cached for dedup.
|
||||
pub fn add<T: Send + Sync + 'static>(&self, value: T) -> Handle<T> {
|
||||
Handle::from_cell(AssetCell::new_loaded(self.next_id(), None, value))
|
||||
}
|
||||
|
||||
/// Returns a handle to an already-loaded asset of this path+type, if one is
|
||||
/// still live, without triggering a load.
|
||||
pub fn get<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Option<Handle<T>> {
|
||||
let key = (TypeId::of::<T>(), path.as_ref().to_path_buf());
|
||||
self.cached::<T>(&key)
|
||||
}
|
||||
|
||||
/// Re-runs the loader for `path` and updates the existing asset in place, so
|
||||
/// every live handle observes the new contents. If no handle is currently
|
||||
/// live, behaves like [`load`](Self::load). This is the foundation the
|
||||
/// live-reload stage builds on.
|
||||
pub fn reload<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Handle<T> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let key = (TypeId::of::<T>(), path.clone());
|
||||
let existing = self.cached::<T>(&key);
|
||||
match self.run_loader::<T>(&path) {
|
||||
Ok(value) => match existing {
|
||||
Some(handle) => {
|
||||
handle.set_loaded(value);
|
||||
handle
|
||||
}
|
||||
None => {
|
||||
let cell = AssetCell::new_loaded(self.next_id(), Some(path), value);
|
||||
self.insert_cache(key, &cell);
|
||||
Handle::from_cell(cell)
|
||||
}
|
||||
},
|
||||
Err(err) => match existing {
|
||||
Some(handle) => {
|
||||
handle.set_failed(err.to_string());
|
||||
handle
|
||||
}
|
||||
None => Handle::from_cell(AssetCell::new_failed(
|
||||
self.next_id(),
|
||||
Some(path),
|
||||
err.to_string(),
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of distinct assets still alive (have at least one live
|
||||
/// handle). Prunes collected entries as a side effect.
|
||||
pub fn live_asset_count(&self) -> usize {
|
||||
let mut cache = self.inner.cache.lock().unwrap();
|
||||
cache.retain(|_, entry| entry.weak.strong_count() > 0);
|
||||
cache.len()
|
||||
}
|
||||
|
||||
/// Reruns the loader for every cached asset whose source path is `path`,
|
||||
/// updating each existing handle in place. Returns the number of assets
|
||||
/// reloaded.
|
||||
///
|
||||
/// Unlike [`reload`](Self::reload) this does **not** need `T` at the call
|
||||
/// site — it dispatches on what types are actually cached for `path`. The
|
||||
/// file-watcher uses this to react to disk changes without knowing every
|
||||
/// asset type at compile time. Paths that are not currently cached return
|
||||
/// `0`; they will be loaded fresh by the next [`load`](Self::load) call.
|
||||
pub fn reload_path(&self, path: &Path) -> usize {
|
||||
// Snapshot the set of typed reload fns to call so we don't hold the
|
||||
// cache lock while re-running loaders (which would deadlock — `reload`
|
||||
// takes the lock too).
|
||||
let reloaders: Vec<fn(&AssetServer, &Path)> = {
|
||||
let cache = self.inner.cache.lock().unwrap();
|
||||
cache
|
||||
.iter()
|
||||
.filter_map(|(key, entry)| {
|
||||
if key.1 == path && entry.weak.strong_count() > 0 {
|
||||
Some(entry.reload_in_place)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let n = reloaders.len();
|
||||
for f in reloaders {
|
||||
f(self, path);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
// --- internals ---------------------------------------------------------
|
||||
|
||||
fn next_id(&self) -> AssetId {
|
||||
AssetId(self.inner.next_id.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn cached<T: Send + Sync + 'static>(&self, key: &CacheKey) -> Option<Handle<T>> {
|
||||
let cache = self.inner.cache.lock().unwrap();
|
||||
let arc = cache.get(key)?.weak.upgrade()?;
|
||||
let cell = arc.downcast::<AssetCell<T>>().ok()?;
|
||||
Some(Handle::from_cell(cell))
|
||||
}
|
||||
|
||||
fn insert_cache<T: Send + Sync + 'static>(&self, key: CacheKey, cell: &Arc<AssetCell<T>>) {
|
||||
let erased: Arc<dyn Any + Send + Sync> = cell.clone();
|
||||
// `reload_in_place` keeps the concrete `T` in its signature, so the
|
||||
// path-keyed `reload_path` can rebuild the typed handle without
|
||||
// knowing `T` at the call site.
|
||||
let entry = CacheEntry {
|
||||
weak: Arc::downgrade(&erased),
|
||||
reload_in_place: |server, path| {
|
||||
server.reload::<T>(path);
|
||||
},
|
||||
};
|
||||
self.inner.cache.lock().unwrap().insert(key, entry);
|
||||
}
|
||||
|
||||
fn run_loader<T: Send + Sync + 'static>(&self, path: &Path) -> Result<T, AssetError> {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.ok_or_else(|| AssetError::NoExtension(path.to_path_buf()))?
|
||||
.to_lowercase();
|
||||
let loader = self
|
||||
.inner
|
||||
.loaders
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(&ext)
|
||||
.cloned()
|
||||
.ok_or_else(|| AssetError::NoLoader(ext.clone()))?;
|
||||
if loader.output_type() != TypeId::of::<T>() {
|
||||
return Err(AssetError::TypeMismatch { ext });
|
||||
}
|
||||
let boxed = loader.load(path)?;
|
||||
Ok(*boxed
|
||||
.downcast::<T>()
|
||||
.expect("loader output_type matched the request but downcast failed"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AssetServer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::asset::LoadState;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
// A trivial asset + loader: each "load" reads a file's text and counts how
|
||||
// many times the loader actually ran, so dedup can be observed.
|
||||
struct Counter(Arc<AtomicU32>);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct TextAsset(String);
|
||||
|
||||
struct TextLoader(Arc<AtomicU32>);
|
||||
impl AssetLoader for TextLoader {
|
||||
type Asset = TextAsset;
|
||||
fn extensions(&self) -> &'static [&'static str] {
|
||||
&["txt"]
|
||||
}
|
||||
fn load(&self, path: &Path) -> Result<TextAsset, AssetError> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
let text = std::fs::read_to_string(path).map_err(|e| AssetError::Load {
|
||||
path: path.to_path_buf(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
Ok(TextAsset(text.trim().to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_file(name: &str, contents: &str) -> PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"oxide_asset_test_{}_{name}.txt",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::write(&path, contents).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn server() -> (AssetServer, Counter) {
|
||||
let counter = Arc::new(AtomicU32::new(0));
|
||||
let server = AssetServer::empty();
|
||||
server.register_loader(TextLoader(counter.clone()));
|
||||
(server, Counter(counter))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_and_reads_an_asset() {
|
||||
let (server, _c) = server();
|
||||
let path = temp_file("hello", " hello world ");
|
||||
let handle = server.load::<TextAsset>(&path);
|
||||
assert_eq!(handle.state(), LoadState::Loaded);
|
||||
assert_eq!(handle.get().unwrap().0, "hello world");
|
||||
assert_eq!(handle.source(), Some(path.as_path()));
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_twice_yields_one_resource() {
|
||||
let (server, c) = server();
|
||||
let path = temp_file("dedup", "data");
|
||||
let a = server.load::<TextAsset>(&path);
|
||||
let b = server.load::<TextAsset>(&path);
|
||||
// Same allocation: loader ran once, ids match, two handles share it.
|
||||
assert_eq!(c.0.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(a.id(), b.id());
|
||||
assert_eq!(a.ref_count(), 2);
|
||||
assert_eq!(server.live_asset_count(), 1);
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_all_handles_frees_the_asset() {
|
||||
let (server, _c) = server();
|
||||
let path = temp_file("free", "data");
|
||||
let handle = server.load::<TextAsset>(&path);
|
||||
assert_eq!(server.live_asset_count(), 1);
|
||||
drop(handle);
|
||||
// With no live handles, the weak cache entry is dead and pruned.
|
||||
assert_eq!(server.live_asset_count(), 0);
|
||||
assert!(server.get::<TextAsset>(&path).is_none());
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_loader_and_type_mismatch_are_distinct_errors() {
|
||||
let (server, _c) = server();
|
||||
let path = temp_file("x", "data");
|
||||
|
||||
// No loader for ".dat".
|
||||
let bad_ext = path.with_extension("dat");
|
||||
std::fs::write(&bad_ext, "data").unwrap();
|
||||
let h = server.load::<TextAsset>(&bad_ext);
|
||||
assert_eq!(h.state(), LoadState::Failed);
|
||||
assert!(h.error().unwrap().contains("no loader"));
|
||||
|
||||
// A ".txt" loader exists but produces TextAsset, not String.
|
||||
let renamed = path.with_extension("txt");
|
||||
std::fs::write(&renamed, "data").unwrap();
|
||||
let h2 = server.load::<String>(&renamed);
|
||||
assert!(h2.error().unwrap().contains("different asset type"));
|
||||
|
||||
std::fs::remove_file(path).ok();
|
||||
std::fs::remove_file(bad_ext).ok();
|
||||
std::fs::remove_file(renamed).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_load_completes_and_dedups() {
|
||||
let (server, c) = server();
|
||||
let path = temp_file("async", "background");
|
||||
let handle = server.load_async::<TextAsset>(&path);
|
||||
let value = handle.wait().expect("async load should succeed");
|
||||
assert_eq!(value.0, "background");
|
||||
// A second request dedups onto the same now-loaded asset.
|
||||
let again = server.load::<TextAsset>(&path);
|
||||
assert_eq!(again.id(), handle.id());
|
||||
assert_eq!(c.0.load(Ordering::SeqCst), 1);
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_updates_in_place_for_existing_handles() {
|
||||
let (server, _c) = server();
|
||||
let path = temp_file("reload", "before");
|
||||
let handle = server.load::<TextAsset>(&path);
|
||||
assert_eq!(handle.get().unwrap().0, "before");
|
||||
|
||||
// Change the file on disk and reload: the SAME handle sees new contents.
|
||||
std::fs::write(&path, "after").unwrap();
|
||||
let reloaded = server.reload::<TextAsset>(&path);
|
||||
assert_eq!(reloaded.id(), handle.id());
|
||||
assert_eq!(handle.get().unwrap().0, "after");
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_stores_in_memory_assets() {
|
||||
let (server, _c) = server();
|
||||
let handle = server.add(TextAsset("in-memory".to_string()));
|
||||
assert_eq!(handle.get().unwrap().0, "in-memory");
|
||||
assert!(handle.source().is_none());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
//! [`AxisBinding`] and [`Axis2DBinding`] — directional inputs composed from
|
||||
//! [`Binding`]s into floats and [`Vec2`]s.
|
||||
//!
|
||||
//! A 1D axis pairs a "positive" binding set with a "negative" binding set;
|
||||
//! each direction held contributes ±1. If both directions are held the
|
||||
//! contributions cancel and the axis reads 0 — a "soft brake" any third-
|
||||
//! person camera or twin-stick character controller needs out of the box.
|
||||
//! Each direction supports several bindings (a WASD axis can also accept
|
||||
//! arrow keys), and the same physical key can appear in many axes' direction
|
||||
//! sets.
|
||||
//!
|
||||
//! A 2D axis is just a pair of 1D axes (X then Y). Diagonals are
|
||||
//! intentionally **not** normalized at this layer — some games want
|
||||
//! Quake-style diagonal speedup, others want unit-length input. Whichever
|
||||
//! convention a game wants, applying it once at the call site is clearer
|
||||
//! than having to undo a default at every site that disagrees.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::math::Vec2;
|
||||
|
||||
use super::{Binding, InputState};
|
||||
|
||||
/// One direction of an axis — typically positive (right / forward / up) or
|
||||
/// negative (left / back / down) — bound to one or more physical inputs.
|
||||
/// Any binding held contributes a full unit; multiple held bindings on the
|
||||
/// same direction do not stack.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AxisBinding {
|
||||
/// Bindings that pull the axis toward +1.
|
||||
pub positive: Vec<Binding>,
|
||||
/// Bindings that pull the axis toward -1.
|
||||
pub negative: Vec<Binding>,
|
||||
}
|
||||
|
||||
impl AxisBinding {
|
||||
/// A new axis with the given direction binding lists.
|
||||
pub fn new(
|
||||
positive: impl IntoIterator<Item = Binding>,
|
||||
negative: impl IntoIterator<Item = Binding>,
|
||||
) -> Self {
|
||||
Self {
|
||||
positive: positive.into_iter().collect(),
|
||||
negative: negative.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates the axis against `input`. Returns -1, 0, or +1 (the
|
||||
/// directions OR'd together — multiple held bindings on the same side
|
||||
/// don't stack).
|
||||
pub fn value(&self, input: &InputState) -> f32 {
|
||||
let pos = self.positive.iter().any(|b| b.held(input));
|
||||
let neg = self.negative.iter().any(|b| b.held(input));
|
||||
match (pos, neg) {
|
||||
(true, false) => 1.0,
|
||||
(false, true) => -1.0,
|
||||
// Both held → mutual cancel; neither → idle. Same result.
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A 2D axis composed of two [`AxisBinding`]s (X and Y).
|
||||
///
|
||||
/// Output is the unmodified vector `(x.value, y.value)` — diagonals are
|
||||
/// `(±1, ±1)`, magnitude √2. Normalize at the call site if your game wants
|
||||
/// unit-length movement.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Axis2DBinding {
|
||||
/// The X (right − left) axis.
|
||||
pub x: AxisBinding,
|
||||
/// The Y (up − down) axis.
|
||||
pub y: AxisBinding,
|
||||
}
|
||||
|
||||
impl Axis2DBinding {
|
||||
/// A 2D axis from four direction binding lists in the usual order
|
||||
/// (`right`, `left`, `up`, `down`).
|
||||
pub fn new(
|
||||
right: impl IntoIterator<Item = Binding>,
|
||||
left: impl IntoIterator<Item = Binding>,
|
||||
up: impl IntoIterator<Item = Binding>,
|
||||
down: impl IntoIterator<Item = Binding>,
|
||||
) -> Self {
|
||||
Self {
|
||||
x: AxisBinding::new(right, left),
|
||||
y: AxisBinding::new(up, down),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates the axis against `input`, returning the raw `(x, y)` value
|
||||
/// without normalization.
|
||||
pub fn value(&self, input: &InputState) -> Vec2 {
|
||||
Vec2::new(self.x.value(input), self.y.value(input))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
fn ad_axis() -> AxisBinding {
|
||||
AxisBinding::new([Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_axis_is_zero() {
|
||||
let input = InputState::new();
|
||||
assert_eq!(ad_axis().value(&input), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_direction_returns_plus_one() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyD);
|
||||
assert_eq!(ad_axis().value(&input), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_direction_returns_minus_one() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyA);
|
||||
assert_eq!(ad_axis().value(&input), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_directions_held_cancel_to_zero() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyA);
|
||||
input.press_key(KeyCode::KeyD);
|
||||
assert_eq!(
|
||||
ad_axis().value(&input),
|
||||
0.0,
|
||||
"left+right held simultaneously must read as idle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_bindings_on_same_direction_do_not_stack() {
|
||||
// WASD + arrow keys both contribute, but holding two positives is
|
||||
// still +1 (not +2). The axis is a directional indicator, not an
|
||||
// accumulator.
|
||||
let axis = AxisBinding::new(
|
||||
[
|
||||
Binding::Key(KeyCode::KeyD),
|
||||
Binding::Key(KeyCode::ArrowRight),
|
||||
],
|
||||
[
|
||||
Binding::Key(KeyCode::KeyA),
|
||||
Binding::Key(KeyCode::ArrowLeft),
|
||||
],
|
||||
);
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyD);
|
||||
input.press_key(KeyCode::ArrowRight);
|
||||
assert_eq!(axis.value(&input), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_2d_returns_vector_components_independently() {
|
||||
let axis = Axis2DBinding::new(
|
||||
[Binding::Key(KeyCode::KeyD)],
|
||||
[Binding::Key(KeyCode::KeyA)],
|
||||
[Binding::Key(KeyCode::KeyW)],
|
||||
[Binding::Key(KeyCode::KeyS)],
|
||||
);
|
||||
let mut input = InputState::new();
|
||||
|
||||
input.press_key(KeyCode::KeyD);
|
||||
input.press_key(KeyCode::KeyW);
|
||||
assert_eq!(axis.value(&input), Vec2::new(1.0, 1.0));
|
||||
|
||||
input.release_key(KeyCode::KeyD);
|
||||
input.press_key(KeyCode::KeyA);
|
||||
// Now A + W held.
|
||||
assert_eq!(axis.value(&input), Vec2::new(-1.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_2d_diagonal_is_unnormalized() {
|
||||
// Diagonals are (±1, ±1) — caller normalizes if it cares.
|
||||
let axis = Axis2DBinding::new(
|
||||
[Binding::Key(KeyCode::KeyD)],
|
||||
[Binding::Key(KeyCode::KeyA)],
|
||||
[Binding::Key(KeyCode::KeyW)],
|
||||
[Binding::Key(KeyCode::KeyS)],
|
||||
);
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyD);
|
||||
input.press_key(KeyCode::KeyW);
|
||||
let v = axis.value(&input);
|
||||
assert!(
|
||||
(v.length() - 2_f32.sqrt()).abs() < 1e-6,
|
||||
"diagonal must be sqrt(2), got {}",
|
||||
v.length()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_ron_round_trip() {
|
||||
let axis = Axis2DBinding::new(
|
||||
[Binding::Key(KeyCode::KeyD)],
|
||||
[Binding::Key(KeyCode::KeyA)],
|
||||
[Binding::Key(KeyCode::KeyW)],
|
||||
[Binding::Key(KeyCode::KeyS)],
|
||||
);
|
||||
let s = ron::to_string(&axis).unwrap();
|
||||
let parsed: Axis2DBinding = ron::from_str(&s).unwrap();
|
||||
assert_eq!(parsed, axis);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! [`Binding`] — one physical input that can drive a named action.
|
||||
//!
|
||||
//! A binding is the smallest unit an [`ActionMap`](super::ActionMap) maps
|
||||
//! action names to. The enum is intentionally small (keys and mouse buttons
|
||||
//! today; gamepad / pointer-axis variants will be added without breaking
|
||||
//! existing serialized maps as long as new variants are appended).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use winit::event::MouseButton;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
use super::InputState;
|
||||
|
||||
/// One physical input that can be bound to a named action.
|
||||
///
|
||||
/// Two bindings compare equal only if they refer to the exact same physical
|
||||
/// input — the enum derives `Hash`/`Eq` so a `HashSet<Binding>` can be used
|
||||
/// to deduplicate a key's contribution to multiple actions without
|
||||
/// allocating per-action sets.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Binding {
|
||||
/// A keyboard key, identified by layout-independent physical position
|
||||
/// (the same `KeyCode` an [`InputState`] query takes).
|
||||
Key(KeyCode),
|
||||
/// A mouse button.
|
||||
Mouse(MouseButton),
|
||||
}
|
||||
|
||||
impl Binding {
|
||||
/// `true` if this binding's `pressed` edge fired in `input` this frame.
|
||||
pub fn pressed(&self, input: &InputState) -> bool {
|
||||
match *self {
|
||||
Binding::Key(k) => input.pressed(k),
|
||||
Binding::Mouse(b) => input.mouse_pressed(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if this binding's `released` edge fired in `input` this frame.
|
||||
pub fn released(&self, input: &InputState) -> bool {
|
||||
match *self {
|
||||
Binding::Key(k) => input.released(k),
|
||||
Binding::Mouse(b) => input.mouse_released(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if this binding is currently held down in `input`.
|
||||
pub fn held(&self, input: &InputState) -> bool {
|
||||
match *self {
|
||||
Binding::Key(k) => input.held(k),
|
||||
Binding::Mouse(b) => input.mouse_held(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if this binding was held *going into* this frame — i.e. it was
|
||||
/// held continuously from before the current frame's events arrived.
|
||||
/// Used by [`ActionMap`](super::ActionMap) to recover prior-frame state
|
||||
/// from the current frame's snapshot alone, without storing a previous
|
||||
/// `InputState`.
|
||||
///
|
||||
/// Derivation: a binding was held before the frame iff it is currently
|
||||
/// held or was released this frame (either way it was down going in),
|
||||
/// **except** when it was also pressed this frame — a same-frame tap
|
||||
/// goes idle → pressed → released, so it was not held going in.
|
||||
pub(crate) fn held_before_frame(&self, input: &InputState) -> bool {
|
||||
(self.held(input) || self.released(input)) && !self.pressed(input)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn key_binding_routes_to_keyboard_queries() {
|
||||
let mut input = InputState::new();
|
||||
let b = Binding::Key(KeyCode::Space);
|
||||
|
||||
input.press_key(KeyCode::Space);
|
||||
assert!(b.pressed(&input));
|
||||
assert!(b.held(&input));
|
||||
assert!(!b.released(&input));
|
||||
|
||||
input.end_frame();
|
||||
assert!(!b.pressed(&input));
|
||||
assert!(b.held(&input));
|
||||
|
||||
input.release_key(KeyCode::Space);
|
||||
assert!(b.released(&input));
|
||||
assert!(!b.held(&input));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_binding_routes_to_mouse_queries() {
|
||||
let mut input = InputState::new();
|
||||
let b = Binding::Mouse(MouseButton::Right);
|
||||
|
||||
input.press_mouse(MouseButton::Right);
|
||||
assert!(b.pressed(&input));
|
||||
assert!(b.held(&input));
|
||||
|
||||
input.end_frame();
|
||||
input.release_mouse(MouseButton::Right);
|
||||
assert!(b.released(&input));
|
||||
assert!(!b.held(&input));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_before_frame_distinguishes_press_release_tap() {
|
||||
let b = Binding::Key(KeyCode::KeyJ);
|
||||
|
||||
// Idle → pressed this frame. Not held before.
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyJ);
|
||||
assert!(!b.held_before_frame(&input));
|
||||
|
||||
// Held continuously. Held before.
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyJ);
|
||||
input.end_frame();
|
||||
assert!(b.held_before_frame(&input));
|
||||
|
||||
// Held → released this frame. Held before.
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyJ);
|
||||
input.end_frame();
|
||||
input.release_key(KeyCode::KeyJ);
|
||||
assert!(b.held_before_frame(&input));
|
||||
|
||||
// Same-frame tap (idle → pressed → released). Not held before.
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyJ);
|
||||
input.release_key(KeyCode::KeyJ);
|
||||
assert!(!b.held_before_frame(&input));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ron_round_trip_preserves_key_and_mouse_variants() {
|
||||
let bindings = vec![
|
||||
Binding::Key(KeyCode::Space),
|
||||
Binding::Mouse(MouseButton::Left),
|
||||
Binding::Key(KeyCode::ShiftLeft),
|
||||
];
|
||||
let s = ron::to_string(&bindings).unwrap();
|
||||
let parsed: Vec<Binding> = ron::from_str(&s).unwrap();
|
||||
assert_eq!(parsed, bindings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Per-frame input — raw state, edges, and remappable named actions.
|
||||
//!
|
||||
//! Stage 7 builds the engine's input abstraction in three layers:
|
||||
//!
|
||||
//! 1. [`InputState`] (piece 1) — the per-frame snapshot of keyboard, mouse,
|
||||
//! cursor, and scroll, with `pressed` / `released` edge detection and a
|
||||
//! persistent `held` state. The windowing runner pumps raw `WindowEvent`s
|
||||
//! into it and clears edges between frames; game/editor code reads it via
|
||||
//! [`AppCtx::input`](crate::window::AppCtx::input).
|
||||
//! 2. [`Binding`] + [`ActionMap`] (piece 2) — named actions like `"Jump"`
|
||||
//! bound to one or more physical inputs, each carrying a **default**
|
||||
//! binding and a (possibly remapped) **current** binding. Game code
|
||||
//! queries actions by name, so a user-facing remap never touches game
|
||||
//! code. Current bindings round-trip through RON for persistence
|
||||
//! (typically via the [`Settings`](crate::settings::Settings) framework).
|
||||
//! 3. [`AxisBinding`] + [`Axis2DBinding`] (piece 3) — directional inputs
|
||||
//! composed from `Binding` direction sets (e.g. `WASD` → `Vec2 "Move"`),
|
||||
//! stored alongside button actions in the same [`ActionMap`] and
|
||||
//! persisted through the same [`ActionOverrides`] payload.
|
||||
//!
|
||||
//! # Why edges and state are tracked separately
|
||||
//!
|
||||
//! Game logic typically wants three distinct things from a physical input:
|
||||
//! the moment it became pressed (a jump fires once on key-down, never on
|
||||
//! subsequent frames while held), the moment it was released (a charged
|
||||
//! shot fires on key-up), and whether it is currently down (a sprint key
|
||||
//! accelerates while held). Tracking all three explicitly makes the
|
||||
//! semantics robust against OS key auto-repeat — a held key produces a
|
||||
//! single `pressed` edge no matter how many times the OS re-sends the
|
||||
//! event — and avoids the per-callsite bookkeeping every action would
|
||||
//! otherwise need.
|
||||
//!
|
||||
//! # Quick reference
|
||||
//!
|
||||
//! ```
|
||||
//! use oxide_engine::input::{ActionMap, Binding, InputState};
|
||||
//! use oxide_engine::winit::keyboard::KeyCode;
|
||||
//!
|
||||
//! let mut input = InputState::new();
|
||||
//! input.press_key(KeyCode::Space);
|
||||
//! assert!(input.pressed(KeyCode::Space)); // edge — true only this frame
|
||||
//! assert!(input.held(KeyCode::Space)); // state — true while held
|
||||
//!
|
||||
//! // Layer named actions on top — game code never names the physical key.
|
||||
//! let mut actions = ActionMap::new();
|
||||
//! actions.register("Jump", [Binding::Key(KeyCode::Space)]);
|
||||
//! assert!(actions.action_pressed("Jump", &input));
|
||||
//!
|
||||
//! input.end_frame();
|
||||
//! assert!(!input.pressed(KeyCode::Space)); // edge cleared
|
||||
//! assert!(input.held(KeyCode::Space)); // held persists
|
||||
//! ```
|
||||
//!
|
||||
//! # Synthesized-event API
|
||||
//!
|
||||
//! The mutators on [`InputState`] (`press_key`, `release_mouse`,
|
||||
//! `set_cursor`, `add_mouse_delta`, `add_scroll`, `forget_cursor`,
|
||||
//! `release_all_held`) are the same path `handle_event` uses, and are
|
||||
//! intentionally public so tests can drive input directly without
|
||||
//! constructing `winit` events (winit 0.30's `DeviceId` cannot be
|
||||
//! fabricated outside an event loop, so most `WindowEvent` variants are
|
||||
//! unreachable from synthesized events).
|
||||
|
||||
mod action;
|
||||
mod axis;
|
||||
mod binding;
|
||||
mod state;
|
||||
|
||||
pub use action::{ActionMap, ActionOverrides};
|
||||
pub use axis::{Axis2DBinding, AxisBinding};
|
||||
pub use binding::Binding;
|
||||
pub use state::InputState;
|
||||
@@ -0,0 +1,472 @@
|
||||
//! The per-frame [`InputState`] — keyboard, mouse, cursor, and scroll with
|
||||
//! edge detection. The module-level documentation lives in
|
||||
//! [`crate::input`](super); this file is the implementation.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
|
||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
|
||||
use crate::math::Vec2;
|
||||
|
||||
/// Pixels-per-line factor used to normalize trackpad pixel scroll deltas into
|
||||
/// the same units as wheel-notch [`MouseScrollDelta::LineDelta`]. Matches the
|
||||
/// convention the editor's orbit-camera zoom already uses, so behavior is
|
||||
/// consistent whether the user has a mouse wheel or a touchpad.
|
||||
const SCROLL_PIXELS_PER_LINE: f32 = 40.0;
|
||||
|
||||
/// Per-frame snapshot of keyboard, mouse, and pointer state.
|
||||
///
|
||||
/// Built up across the frame from raw events and queried by game / editor
|
||||
/// code. All edge sets (pressed / released, mouse delta, scroll) are cleared
|
||||
/// by [`end_frame`](Self::end_frame); held state and cursor position persist
|
||||
/// across frames.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct InputState {
|
||||
keys_held: HashSet<KeyCode>,
|
||||
keys_pressed: HashSet<KeyCode>,
|
||||
keys_released: HashSet<KeyCode>,
|
||||
|
||||
mouse_held: HashSet<MouseButton>,
|
||||
mouse_pressed: HashSet<MouseButton>,
|
||||
mouse_released: HashSet<MouseButton>,
|
||||
|
||||
cursor: Option<Vec2>,
|
||||
mouse_delta: Vec2,
|
||||
scroll: Vec2,
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
/// A new state with nothing pressed and no cursor known.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// --- Queries: keyboard -------------------------------------------------
|
||||
|
||||
/// `true` if `key` became pressed this frame (edge — true for exactly the
|
||||
/// frame of the key-down, regardless of OS auto-repeat).
|
||||
pub fn pressed(&self, key: KeyCode) -> bool {
|
||||
self.keys_pressed.contains(&key)
|
||||
}
|
||||
|
||||
/// `true` if `key` was released this frame (edge — true for exactly the
|
||||
/// frame of the key-up).
|
||||
pub fn released(&self, key: KeyCode) -> bool {
|
||||
self.keys_released.contains(&key)
|
||||
}
|
||||
|
||||
/// `true` if `key` is currently held down (state — true every frame until
|
||||
/// the key-up arrives).
|
||||
pub fn held(&self, key: KeyCode) -> bool {
|
||||
self.keys_held.contains(&key)
|
||||
}
|
||||
|
||||
/// All currently-held keys. Useful for debug overlays.
|
||||
pub fn keys_held(&self) -> impl Iterator<Item = KeyCode> + '_ {
|
||||
self.keys_held.iter().copied()
|
||||
}
|
||||
|
||||
// --- Queries: mouse ----------------------------------------------------
|
||||
|
||||
/// `true` if `button` became pressed this frame (edge).
|
||||
pub fn mouse_pressed(&self, button: MouseButton) -> bool {
|
||||
self.mouse_pressed.contains(&button)
|
||||
}
|
||||
|
||||
/// `true` if `button` was released this frame (edge).
|
||||
pub fn mouse_released(&self, button: MouseButton) -> bool {
|
||||
self.mouse_released.contains(&button)
|
||||
}
|
||||
|
||||
/// `true` if `button` is currently held down (state).
|
||||
pub fn mouse_held(&self, button: MouseButton) -> bool {
|
||||
self.mouse_held.contains(&button)
|
||||
}
|
||||
|
||||
/// All currently-held mouse buttons.
|
||||
pub fn mouse_buttons_held(&self) -> impl Iterator<Item = MouseButton> + '_ {
|
||||
self.mouse_held.iter().copied()
|
||||
}
|
||||
|
||||
/// Current cursor position in physical pixels, or `None` if the cursor
|
||||
/// has not entered the window yet (or just left it).
|
||||
pub fn cursor(&self) -> Option<Vec2> {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
/// Cursor movement since the last [`end_frame`](Self::end_frame), in
|
||||
/// physical pixels. The first cursor event of a session (or after a
|
||||
/// [`CursorLeft`](WindowEvent::CursorLeft)) seeds the position **without**
|
||||
/// producing a delta, so consumers never see a phantom jump on the first
|
||||
/// frame the cursor appears.
|
||||
pub fn mouse_delta(&self) -> Vec2 {
|
||||
self.mouse_delta
|
||||
}
|
||||
|
||||
/// Scroll accumulated since the last [`end_frame`](Self::end_frame), in
|
||||
/// line-equivalent units (pixel deltas are divided by a fixed pixels-per-
|
||||
/// line constant so wheels and touchpads report on the same scale).
|
||||
pub fn scroll(&self) -> Vec2 {
|
||||
self.scroll
|
||||
}
|
||||
|
||||
// --- Event pump --------------------------------------------------------
|
||||
|
||||
/// Folds one raw [`WindowEvent`] into the state.
|
||||
///
|
||||
/// Non-input events (resize, redraw, focus, …) are ignored, so the runner
|
||||
/// can pump every event without filtering. Auto-repeat key-down events
|
||||
/// from the OS do not re-fire the [`pressed`](Self::pressed) edge: a held
|
||||
/// key only produces an edge on the first down.
|
||||
pub fn handle_event(&mut self, event: &WindowEvent) {
|
||||
match event {
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
if let PhysicalKey::Code(code) = event.physical_key {
|
||||
match event.state {
|
||||
ElementState::Pressed => self.press_key(code),
|
||||
ElementState::Released => self.release_key(code),
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => match state {
|
||||
ElementState::Pressed => self.press_mouse(*button),
|
||||
ElementState::Released => self.release_mouse(*button),
|
||||
},
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
self.set_cursor(Vec2::new(position.x as f32, position.y as f32));
|
||||
}
|
||||
WindowEvent::CursorLeft { .. } => self.forget_cursor(),
|
||||
WindowEvent::MouseWheel { delta, .. } => match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => self.add_scroll(*x, *y),
|
||||
MouseScrollDelta::PixelDelta(p) => self.add_scroll(
|
||||
p.x as f32 / SCROLL_PIXELS_PER_LINE,
|
||||
p.y as f32 / SCROLL_PIXELS_PER_LINE,
|
||||
),
|
||||
},
|
||||
WindowEvent::Focused(false) => self.release_all_held(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Synthesized mutators (used by both handle_event and tests) --------
|
||||
|
||||
/// Records that `key` was pressed. The [`pressed`](Self::pressed) edge
|
||||
/// fires only when the key was not already held, so OS auto-repeat does
|
||||
/// not retrigger one-shot actions.
|
||||
pub fn press_key(&mut self, key: KeyCode) {
|
||||
if self.keys_held.insert(key) {
|
||||
self.keys_pressed.insert(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Records that `key` was released. The [`released`](Self::released)
|
||||
/// edge fires whether or not the key was previously tracked as held —
|
||||
/// the OS occasionally sends a release without a matching press (e.g.
|
||||
/// the window gained focus mid-press).
|
||||
pub fn release_key(&mut self, key: KeyCode) {
|
||||
self.keys_held.remove(&key);
|
||||
self.keys_released.insert(key);
|
||||
}
|
||||
|
||||
/// Records that `button` was pressed (with the same edge semantics as
|
||||
/// [`press_key`]).
|
||||
pub fn press_mouse(&mut self, button: MouseButton) {
|
||||
if self.mouse_held.insert(button) {
|
||||
self.mouse_pressed.insert(button);
|
||||
}
|
||||
}
|
||||
|
||||
/// Records that `button` was released.
|
||||
pub fn release_mouse(&mut self, button: MouseButton) {
|
||||
self.mouse_held.remove(&button);
|
||||
self.mouse_released.insert(button);
|
||||
}
|
||||
|
||||
/// Sets the cursor position. The delta is accumulated **only** relative
|
||||
/// to a previously-known cursor; the very first set (or the first set
|
||||
/// after a [`CursorLeft`](WindowEvent::CursorLeft) event) seeds the
|
||||
/// position without contributing to [`mouse_delta`](Self::mouse_delta).
|
||||
pub fn set_cursor(&mut self, position: Vec2) {
|
||||
if let Some(prev) = self.cursor {
|
||||
self.mouse_delta += position - prev;
|
||||
}
|
||||
self.cursor = Some(position);
|
||||
}
|
||||
|
||||
/// Adds a raw mouse delta in physical pixels. Useful for relative-motion
|
||||
/// sources (`DeviceEvent::MouseMotion`, future pointer-lock) and for tests.
|
||||
pub fn add_mouse_delta(&mut self, dx: f32, dy: f32) {
|
||||
self.mouse_delta += Vec2::new(dx, dy);
|
||||
}
|
||||
|
||||
/// Adds a scroll increment in line-equivalent units.
|
||||
pub fn add_scroll(&mut self, x: f32, y: f32) {
|
||||
self.scroll += Vec2::new(x, y);
|
||||
}
|
||||
|
||||
// --- Frame boundary ----------------------------------------------------
|
||||
|
||||
/// Clears per-frame edge state and accumulated deltas; held state and
|
||||
/// cursor position persist. The runner calls this after game logic has
|
||||
/// read the edges for the current frame.
|
||||
pub fn end_frame(&mut self) {
|
||||
self.keys_pressed.clear();
|
||||
self.keys_released.clear();
|
||||
self.mouse_pressed.clear();
|
||||
self.mouse_released.clear();
|
||||
self.mouse_delta = Vec2::ZERO;
|
||||
self.scroll = Vec2::ZERO;
|
||||
}
|
||||
|
||||
/// Forgets the cursor anchor so the next [`set_cursor`](Self::set_cursor)
|
||||
/// re-seeds without producing a phantom delta. The event pump calls this
|
||||
/// on [`CursorLeft`](WindowEvent::CursorLeft); the public exposure lets
|
||||
/// hosts that drive `InputState` directly (e.g. tests, or a future
|
||||
/// pointer-lock toggle) re-anchor without simulating a window event.
|
||||
pub fn forget_cursor(&mut self) {
|
||||
self.cursor = None;
|
||||
}
|
||||
|
||||
/// Releases every currently-held key and mouse button (firing each
|
||||
/// `released` edge once). The event pump calls this when the window
|
||||
/// loses focus, since the OS will never deliver the matching releases
|
||||
/// for keys held at that moment, and stuck-key bugs would otherwise
|
||||
/// follow the window across alt-tab cycles.
|
||||
pub fn release_all_held(&mut self) {
|
||||
for key in self.keys_held.drain() {
|
||||
self.keys_released.insert(key);
|
||||
}
|
||||
for button in self.mouse_held.drain() {
|
||||
self.mouse_released.insert(button);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn key_press_sets_edge_and_state() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::Space);
|
||||
|
||||
assert!(input.pressed(KeyCode::Space));
|
||||
assert!(input.held(KeyCode::Space));
|
||||
assert!(!input.released(KeyCode::Space));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_clears_edges_but_not_held() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::Space);
|
||||
input.end_frame();
|
||||
|
||||
assert!(!input.pressed(KeyCode::Space), "edge must clear");
|
||||
assert!(input.held(KeyCode::Space), "state must persist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_release_sets_edge_and_clears_held() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyA);
|
||||
input.end_frame();
|
||||
|
||||
input.release_key(KeyCode::KeyA);
|
||||
assert!(input.released(KeyCode::KeyA));
|
||||
assert!(!input.held(KeyCode::KeyA));
|
||||
assert!(!input.pressed(KeyCode::KeyA));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn os_auto_repeat_does_not_refire_pressed_edge() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyW);
|
||||
input.end_frame(); // pressed edge consumed
|
||||
|
||||
// The OS resends Pressed for the same key while it's held.
|
||||
input.press_key(KeyCode::KeyW);
|
||||
assert!(
|
||||
!input.pressed(KeyCode::KeyW),
|
||||
"auto-repeat must not retrigger pressed"
|
||||
);
|
||||
assert!(input.held(KeyCode::KeyW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_without_prior_press_still_emits_edge() {
|
||||
// The OS occasionally delivers a release with no matching press (e.g.
|
||||
// window focused mid-press). The released edge still fires so consumers
|
||||
// can react.
|
||||
let mut input = InputState::new();
|
||||
input.release_key(KeyCode::Escape);
|
||||
assert!(input.released(KeyCode::Escape));
|
||||
assert!(!input.held(KeyCode::Escape));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressed_and_released_in_same_frame_both_fire() {
|
||||
// Within a single frame a quick tap should register both edges so
|
||||
// logic that wants a "click on release" pattern is reachable from
|
||||
// the synthesized input path.
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::Enter);
|
||||
input.release_key(KeyCode::Enter);
|
||||
|
||||
assert!(input.pressed(KeyCode::Enter));
|
||||
assert!(input.released(KeyCode::Enter));
|
||||
assert!(!input.held(KeyCode::Enter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_button_edges_parallel_keyboard() {
|
||||
let mut input = InputState::new();
|
||||
input.press_mouse(MouseButton::Left);
|
||||
assert!(input.mouse_pressed(MouseButton::Left));
|
||||
assert!(input.mouse_held(MouseButton::Left));
|
||||
|
||||
input.end_frame();
|
||||
assert!(!input.mouse_pressed(MouseButton::Left));
|
||||
assert!(input.mouse_held(MouseButton::Left));
|
||||
|
||||
input.release_mouse(MouseButton::Left);
|
||||
assert!(input.mouse_released(MouseButton::Left));
|
||||
assert!(!input.mouse_held(MouseButton::Left));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_cursor_move_produces_no_delta() {
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(100.0, 200.0));
|
||||
assert_eq!(input.mouse_delta(), Vec2::ZERO);
|
||||
assert_eq!(input.cursor(), Some(Vec2::new(100.0, 200.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subsequent_cursor_moves_accumulate_delta() {
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(100.0, 200.0));
|
||||
input.set_cursor(Vec2::new(110.0, 195.0));
|
||||
input.set_cursor(Vec2::new(115.0, 190.0));
|
||||
|
||||
// (110-100) + (115-110), (195-200) + (190-195) = (15, -10)
|
||||
assert_eq!(input.mouse_delta(), Vec2::new(15.0, -10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_resets_delta_but_preserves_cursor() {
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(0.0, 0.0));
|
||||
input.set_cursor(Vec2::new(10.0, 10.0));
|
||||
input.end_frame();
|
||||
|
||||
assert_eq!(input.mouse_delta(), Vec2::ZERO);
|
||||
assert_eq!(input.cursor(), Some(Vec2::new(10.0, 10.0)));
|
||||
|
||||
// Next move accumulates from the persisted cursor, not from zero.
|
||||
input.set_cursor(Vec2::new(13.0, 11.0));
|
||||
assert_eq!(input.mouse_delta(), Vec2::new(3.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_mouse_delta_layers_on_top_of_cursor_motion() {
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(0.0, 0.0));
|
||||
input.set_cursor(Vec2::new(5.0, 0.0));
|
||||
input.add_mouse_delta(2.0, 3.0); // e.g. raw DeviceEvent motion
|
||||
|
||||
assert_eq!(input.mouse_delta(), Vec2::new(7.0, 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_accumulates_and_resets() {
|
||||
let mut input = InputState::new();
|
||||
input.add_scroll(0.0, 1.0);
|
||||
input.add_scroll(0.0, 2.5);
|
||||
assert_eq!(input.scroll(), Vec2::new(0.0, 3.5));
|
||||
|
||||
input.end_frame();
|
||||
assert_eq!(input.scroll(), Vec2::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_loss_via_handle_event_releases_held() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyW);
|
||||
input.press_mouse(MouseButton::Left);
|
||||
input.end_frame();
|
||||
|
||||
// Focused(false) is one of the WindowEvent variants with no DeviceId,
|
||||
// so the routing through handle_event itself is exercised here.
|
||||
input.handle_event(&WindowEvent::Focused(false));
|
||||
|
||||
assert!(!input.held(KeyCode::KeyW), "key must not stay stuck");
|
||||
assert!(!input.mouse_held(MouseButton::Left));
|
||||
assert!(input.released(KeyCode::KeyW));
|
||||
assert!(input.mouse_released(MouseButton::Left));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_all_held_drops_state_and_fires_edges() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyW);
|
||||
input.press_key(KeyCode::ShiftLeft);
|
||||
input.press_mouse(MouseButton::Right);
|
||||
input.end_frame();
|
||||
|
||||
input.release_all_held();
|
||||
|
||||
assert!(!input.held(KeyCode::KeyW));
|
||||
assert!(!input.held(KeyCode::ShiftLeft));
|
||||
assert!(!input.mouse_held(MouseButton::Right));
|
||||
assert!(input.released(KeyCode::KeyW));
|
||||
assert!(input.released(KeyCode::ShiftLeft));
|
||||
assert!(input.mouse_released(MouseButton::Right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forget_cursor_resets_anchor_so_next_move_has_no_delta() {
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(0.0, 0.0));
|
||||
input.set_cursor(Vec2::new(10.0, 10.0));
|
||||
input.end_frame();
|
||||
|
||||
input.forget_cursor();
|
||||
assert!(input.cursor().is_none());
|
||||
|
||||
// First move back in reseeds without contributing a delta.
|
||||
input.set_cursor(Vec2::new(200.0, 50.0));
|
||||
assert_eq!(input.mouse_delta(), Vec2::ZERO);
|
||||
assert_eq!(input.cursor(), Some(Vec2::new(200.0, 50.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_event_ignores_unrelated_window_events() {
|
||||
// These three WindowEvent variants don't carry a DeviceId, so they
|
||||
// can be constructed in tests — the routing through handle_event is
|
||||
// exercised end-to-end here.
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::Space);
|
||||
|
||||
input.handle_event(&WindowEvent::Resized(winit::dpi::PhysicalSize::new(
|
||||
800, 600,
|
||||
)));
|
||||
input.handle_event(&WindowEvent::CloseRequested);
|
||||
input.handle_event(&WindowEvent::RedrawRequested);
|
||||
|
||||
assert!(input.pressed(KeyCode::Space));
|
||||
assert!(input.held(KeyCode::Space));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_held_iterates_currently_held_keys() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyW);
|
||||
input.press_key(KeyCode::KeyA);
|
||||
input.release_key(KeyCode::KeyA);
|
||||
|
||||
let held: HashSet<KeyCode> = input.keys_held().collect();
|
||||
assert_eq!(held, HashSet::from([KeyCode::KeyW]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Per-entity [`Layer`] membership and gameplay [`Tags`].
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::LayerMask;
|
||||
|
||||
/// Component: the **single** layer an entity is on.
|
||||
///
|
||||
/// Each entity belongs to exactly one of 32 logical layers (index `0..32`).
|
||||
/// Filters elsewhere — a camera's visibility mask, a physics collision filter,
|
||||
/// a raycast's layer filter — carry [`LayerMask`]s and select an entity by
|
||||
/// testing `mask.contains_layer(entity.layer.index)` (see [`Self::matches`]).
|
||||
///
|
||||
/// This matches the Unity model: **per-entity membership is single, filters
|
||||
/// are masks.** If you need an entity to be "in" multiple categories
|
||||
/// simultaneously, use [`Tags`] (gameplay tags) — tagging is the multi-valued
|
||||
/// concept; layers are the single-valued one.
|
||||
///
|
||||
/// Every freshly spawned entity is on [`DEFAULT`](Self::DEFAULT) (the layer
|
||||
/// named `"Default"` at index 0) unless changed, so it's visible to "see
|
||||
/// everything" filters out of the box.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, crate::reflect::Reflect,
|
||||
)]
|
||||
pub struct Layer {
|
||||
/// Layer index (`0..32`). Use the
|
||||
/// [`LayerRegistry`](super::LayerRegistry) to translate between this and a
|
||||
/// human-readable name.
|
||||
pub index: u32,
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
/// The "Default" layer (index 0). Every freshly spawned entity starts here.
|
||||
pub const DEFAULT: Layer = Layer { index: 0 };
|
||||
|
||||
/// Builds a `Layer` on the given `index` (`0..32`).
|
||||
pub const fn on(index: u32) -> Self {
|
||||
Self { index }
|
||||
}
|
||||
|
||||
/// Whether this layer is selected by the given filter mask.
|
||||
pub const fn matches(self, filter: LayerMask) -> bool {
|
||||
filter.contains_layer(self.index)
|
||||
}
|
||||
|
||||
/// A [`LayerMask`] containing exactly this layer — useful when an API
|
||||
/// expects a mask (e.g. a one-layer camera visibility filter).
|
||||
pub const fn mask(self) -> LayerMask {
|
||||
LayerMask::layer(self.index)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Layer {
|
||||
fn default() -> Self {
|
||||
Layer::DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
/// Component: free-form gameplay tags on an entity.
|
||||
///
|
||||
/// Tags are the lightweight, string-keyed counterpart to [`Layer`]. Where a
|
||||
/// [`LayerMask`] is a fixed 32-slot bitset for hot-path *filtering*, tags are an
|
||||
/// open-ended set for *identification* — `"Enemy"`, `"Interactable"`,
|
||||
/// `"Checkpoint"` — that game code and scripts query by name. Stored sorted so
|
||||
/// serialization is deterministic.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Tags(BTreeSet<String>);
|
||||
|
||||
impl Tags {
|
||||
/// An empty tag set.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// A tag set containing the single tag `tag`.
|
||||
pub fn single(tag: impl Into<String>) -> Self {
|
||||
let mut set = BTreeSet::new();
|
||||
set.insert(tag.into());
|
||||
Tags(set)
|
||||
}
|
||||
|
||||
/// Adds `tag`. Returns `true` if it was not already present.
|
||||
pub fn insert(&mut self, tag: impl Into<String>) -> bool {
|
||||
self.0.insert(tag.into())
|
||||
}
|
||||
|
||||
/// Removes `tag`. Returns `true` if it was present.
|
||||
pub fn remove(&mut self, tag: &str) -> bool {
|
||||
self.0.remove(tag)
|
||||
}
|
||||
|
||||
/// Whether `tag` is present.
|
||||
pub fn contains(&self, tag: &str) -> bool {
|
||||
self.0.contains(tag)
|
||||
}
|
||||
|
||||
/// Iterates the tags in sorted order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &str> {
|
||||
self.0.iter().map(String::as_str)
|
||||
}
|
||||
|
||||
/// The number of tags.
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
/// Whether there are no tags.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Into<String>> FromIterator<S> for Tags {
|
||||
fn from_iter<I: IntoIterator<Item = S>>(iter: I) -> Self {
|
||||
Tags(iter.into_iter().map(Into::into).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_layer_is_zero() {
|
||||
let l = Layer::default();
|
||||
assert_eq!(l.index, 0);
|
||||
// A "see everything" filter selects a default entity.
|
||||
assert!(l.matches(LayerMask::ALL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_matches_filter_when_index_is_in_the_mask() {
|
||||
let on_npc = Layer::on(2);
|
||||
let npc_or_player = LayerMask::NONE.with(1).with(2);
|
||||
assert!(on_npc.matches(npc_or_player));
|
||||
assert!(!on_npc.matches(LayerMask::layer(5)));
|
||||
assert_eq!(on_npc.mask(), LayerMask::layer(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_round_trips_through_ron() {
|
||||
let l = Layer::on(7);
|
||||
let ron = ron::to_string(&l).unwrap();
|
||||
let back: Layer = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(l, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_insert_remove_contains() {
|
||||
let mut tags = Tags::new();
|
||||
assert!(tags.insert("Enemy"));
|
||||
assert!(!tags.insert("Enemy")); // already present
|
||||
assert!(tags.insert("Flying"));
|
||||
assert!(tags.contains("Enemy"));
|
||||
assert_eq!(tags.len(), 2);
|
||||
assert!(tags.remove("Enemy"));
|
||||
assert!(!tags.contains("Enemy"));
|
||||
assert!(!tags.remove("Enemy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_iterate_sorted_and_round_trip() {
|
||||
let tags: Tags = ["Zebra", "Apple", "Mango"].into_iter().collect();
|
||||
assert_eq!(
|
||||
tags.iter().collect::<Vec<_>>(),
|
||||
vec!["Apple", "Mango", "Zebra"]
|
||||
);
|
||||
let ron = ron::to_string(&tags).unwrap();
|
||||
let back: Tags = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(tags, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! [`GroupRegistry`]: project-defined gameplay group names.
|
||||
//!
|
||||
//! Groups are the **multi-valued** counterpart to the single-valued
|
||||
//! [`Layer`](super::Layer). Where an entity is on exactly one layer (its
|
||||
//! render/physics filter slot), it can belong to *any number* of groups —
|
||||
//! `"Enemies"`, `"Interactables"`, `"SaveOnExit"` — which game code and scripts
|
||||
//! query by name. This mirrors the Unity model: one Layer + many tags/groups.
|
||||
//!
|
||||
//! Per-entity membership is stored in the [`Tags`](super::Tags) component. The
|
||||
//! registry is the project-level list of *which group names exist*, so the
|
||||
//! editor can offer a fixed set to pick from (predefined, not free-typed) and a
|
||||
//! team shares one vocabulary. Defining or deleting a group only changes that
|
||||
//! vocabulary; it never touches the tags already on entities (a deleted group
|
||||
//! simply becomes an "ungrouped" tag until removed).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The set of project-defined group names.
|
||||
///
|
||||
/// Stored sorted (a [`BTreeSet`]) so the editor's dropdown order and serialized
|
||||
/// form are deterministic. Names are the identity used in data and UI, so they
|
||||
/// should be stable across a project's life.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct GroupRegistry {
|
||||
names: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl GroupRegistry {
|
||||
/// An empty registry — no groups defined yet.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Defines `name` as a group. Returns `true` if it was newly added.
|
||||
pub fn define(&mut self, name: impl Into<String>) -> bool {
|
||||
self.names.insert(name.into())
|
||||
}
|
||||
|
||||
/// Removes `name` from the defined groups. Returns `true` if it existed.
|
||||
///
|
||||
/// Entities already tagged with `name` keep the tag — only the project's
|
||||
/// list of valid groups shrinks.
|
||||
pub fn undefine(&mut self, name: &str) -> bool {
|
||||
self.names.remove(name)
|
||||
}
|
||||
|
||||
/// Whether `name` is a defined group.
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.names.contains(name)
|
||||
}
|
||||
|
||||
/// Iterates the defined group names in sorted order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &str> {
|
||||
self.names.iter().map(String::as_str)
|
||||
}
|
||||
|
||||
/// The number of defined groups.
|
||||
pub fn len(&self) -> usize {
|
||||
self.names.len()
|
||||
}
|
||||
|
||||
/// Whether no groups are defined.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.names.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Into<String>> FromIterator<S> for GroupRegistry {
|
||||
fn from_iter<I: IntoIterator<Item = S>>(iter: I) -> Self {
|
||||
GroupRegistry {
|
||||
names: iter.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn define_is_idempotent_and_reports_newness() {
|
||||
let mut reg = GroupRegistry::new();
|
||||
assert!(reg.is_empty());
|
||||
assert!(reg.define("Enemies"));
|
||||
assert!(!reg.define("Enemies")); // already defined
|
||||
assert!(reg.define("Pickups"));
|
||||
assert!(reg.contains("Enemies"));
|
||||
assert_eq!(reg.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undefine_removes_only_from_the_vocabulary() {
|
||||
let mut reg: GroupRegistry = ["Enemies", "Pickups"].into_iter().collect();
|
||||
assert!(reg.undefine("Enemies"));
|
||||
assert!(!reg.undefine("Enemies"));
|
||||
assert!(!reg.contains("Enemies"));
|
||||
assert!(reg.contains("Pickups"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_is_sorted() {
|
||||
let reg: GroupRegistry = ["Zed", "Alpha", "Mid"].into_iter().collect();
|
||||
assert_eq!(reg.iter().collect::<Vec<_>>(), vec!["Alpha", "Mid", "Zed"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_ron() {
|
||||
let reg: GroupRegistry = ["Enemies", "Interactables"].into_iter().collect();
|
||||
let ron = ron::to_string(®).unwrap();
|
||||
let back: GroupRegistry = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(reg, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! [`LayerMask`]: a 32-slot bitset used to include/exclude entities.
|
||||
|
||||
use std::ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, BitXor, BitXorAssign, Not};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The number of distinct layers a [`LayerMask`] can represent.
|
||||
///
|
||||
/// Fixed at 32 so a mask is a single `u32` — cheap to copy, store on a
|
||||
/// component, and test in hot paths (physics filtering, render visibility,
|
||||
/// scene queries).
|
||||
pub const MAX_LAYERS: u32 = 32;
|
||||
|
||||
/// A set of layers, packed into the bits of a `u32`.
|
||||
///
|
||||
/// A `LayerMask` is the one shared primitive behind every "which layers does
|
||||
/// this interact with?" question in the engine. It plays two roles:
|
||||
///
|
||||
/// - **Membership** — the layers an entity *belongs to* (see
|
||||
/// [`Layer`](super::Layer)).
|
||||
/// - **Filter** — the layers a camera, query, or collision rule *cares about*.
|
||||
///
|
||||
/// Two masks interact when they share any layer: [`intersects`](Self::intersects)
|
||||
/// is the universal test (`(a & b) != 0`). Layer indices run `0..32`; passing an
|
||||
/// index `>= 32` panics (in every build), catching mistakes early rather than
|
||||
/// silently wrapping.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct LayerMask(u32);
|
||||
|
||||
impl LayerMask {
|
||||
/// The empty mask — interacts with nothing.
|
||||
pub const NONE: LayerMask = LayerMask(0);
|
||||
|
||||
/// Every layer set — interacts with everything.
|
||||
pub const ALL: LayerMask = LayerMask(u32::MAX);
|
||||
|
||||
/// A mask from a raw bit pattern.
|
||||
pub const fn from_bits(bits: u32) -> Self {
|
||||
LayerMask(bits)
|
||||
}
|
||||
|
||||
/// The raw bit pattern.
|
||||
pub const fn bits(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// A mask containing only the single layer `index` (`0..32`).
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `index >= 32`.
|
||||
pub const fn layer(index: u32) -> Self {
|
||||
assert!(
|
||||
index < MAX_LAYERS,
|
||||
"layer index out of range (must be 0..32)"
|
||||
);
|
||||
LayerMask(1u32 << index)
|
||||
}
|
||||
|
||||
/// This mask with layer `index` added.
|
||||
pub const fn with(self, index: u32) -> Self {
|
||||
LayerMask(self.0 | Self::layer(index).0)
|
||||
}
|
||||
|
||||
/// This mask with layer `index` removed.
|
||||
pub const fn without(self, index: u32) -> Self {
|
||||
LayerMask(self.0 & !Self::layer(index).0)
|
||||
}
|
||||
|
||||
/// This mask with layer `index` flipped.
|
||||
pub const fn toggled(self, index: u32) -> Self {
|
||||
LayerMask(self.0 ^ Self::layer(index).0)
|
||||
}
|
||||
|
||||
/// Whether layer `index` is present.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `index >= 32`.
|
||||
pub const fn contains_layer(self, index: u32) -> bool {
|
||||
self.0 & Self::layer(index).0 != 0
|
||||
}
|
||||
|
||||
/// Whether this mask and `other` share at least one layer.
|
||||
///
|
||||
/// This is the canonical interaction test — a body on the masks it belongs
|
||||
/// to "interacts with" a filter that selects any of those layers.
|
||||
pub const fn intersects(self, other: LayerMask) -> bool {
|
||||
self.0 & other.0 != 0
|
||||
}
|
||||
|
||||
/// Whether every layer in `other` is also in this mask.
|
||||
pub const fn contains(self, other: LayerMask) -> bool {
|
||||
self.0 & other.0 == other.0
|
||||
}
|
||||
|
||||
/// The union (bitwise OR) of two masks.
|
||||
pub const fn union(self, other: LayerMask) -> Self {
|
||||
LayerMask(self.0 | other.0)
|
||||
}
|
||||
|
||||
/// The intersection (bitwise AND) of two masks.
|
||||
pub const fn intersection(self, other: LayerMask) -> Self {
|
||||
LayerMask(self.0 & other.0)
|
||||
}
|
||||
|
||||
/// The layers in this mask that are not in `other`.
|
||||
pub const fn difference(self, other: LayerMask) -> Self {
|
||||
LayerMask(self.0 & !other.0)
|
||||
}
|
||||
|
||||
/// The complement — every layer not in this mask.
|
||||
pub const fn complement(self) -> Self {
|
||||
LayerMask(!self.0)
|
||||
}
|
||||
|
||||
/// Whether no layers are set.
|
||||
pub const fn is_empty(self) -> bool {
|
||||
self.0 == 0
|
||||
}
|
||||
|
||||
/// The number of layers set.
|
||||
pub const fn len(self) -> u32 {
|
||||
self.0.count_ones()
|
||||
}
|
||||
|
||||
/// Iterates the indices (`0..32`) of the set layers, ascending.
|
||||
pub fn iter(self) -> impl Iterator<Item = u32> {
|
||||
(0..MAX_LAYERS).filter(move |&i| self.0 & (1u32 << i) != 0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LayerMask {
|
||||
/// The empty mask. Filters that should default to "see everything" must opt
|
||||
/// into [`LayerMask::ALL`] explicitly rather than rely on this.
|
||||
fn default() -> Self {
|
||||
LayerMask::NONE
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<u32> for LayerMask {
|
||||
/// Builds a mask from layer indices. Each index must be `0..32`.
|
||||
fn from_iter<I: IntoIterator<Item = u32>>(iter: I) -> Self {
|
||||
iter.into_iter().fold(LayerMask::NONE, LayerMask::with)
|
||||
}
|
||||
}
|
||||
|
||||
impl BitOr for LayerMask {
|
||||
type Output = LayerMask;
|
||||
fn bitor(self, rhs: LayerMask) -> LayerMask {
|
||||
self.union(rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl BitOrAssign for LayerMask {
|
||||
fn bitor_assign(&mut self, rhs: LayerMask) {
|
||||
self.0 |= rhs.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl BitAnd for LayerMask {
|
||||
type Output = LayerMask;
|
||||
fn bitand(self, rhs: LayerMask) -> LayerMask {
|
||||
self.intersection(rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl BitAndAssign for LayerMask {
|
||||
fn bitand_assign(&mut self, rhs: LayerMask) {
|
||||
self.0 &= rhs.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl BitXor for LayerMask {
|
||||
type Output = LayerMask;
|
||||
fn bitxor(self, rhs: LayerMask) -> LayerMask {
|
||||
LayerMask(self.0 ^ rhs.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl BitXorAssign for LayerMask {
|
||||
fn bitxor_assign(&mut self, rhs: LayerMask) {
|
||||
self.0 ^= rhs.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Not for LayerMask {
|
||||
type Output = LayerMask;
|
||||
fn not(self) -> LayerMask {
|
||||
self.complement()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_sets_a_single_bit() {
|
||||
assert_eq!(LayerMask::layer(0).bits(), 0b1);
|
||||
assert_eq!(LayerMask::layer(3).bits(), 0b1000);
|
||||
assert_eq!(LayerMask::layer(31).bits(), 1 << 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn layer_index_out_of_range_panics() {
|
||||
let _ = LayerMask::layer(32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builders_add_and_remove_layers() {
|
||||
let m = LayerMask::NONE.with(1).with(4);
|
||||
assert!(m.contains_layer(1));
|
||||
assert!(m.contains_layer(4));
|
||||
assert!(!m.contains_layer(0));
|
||||
assert_eq!(m.len(), 2);
|
||||
|
||||
let m = m.without(1);
|
||||
assert!(!m.contains_layer(1));
|
||||
assert!(m.contains_layer(4));
|
||||
|
||||
let m = m.toggled(4).toggled(7);
|
||||
assert!(!m.contains_layer(4));
|
||||
assert!(m.contains_layer(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersects_is_the_interaction_test() {
|
||||
let player = LayerMask::layer(1);
|
||||
let npc = LayerMask::layer(2);
|
||||
// A trigger that only fires for the player or npc layers.
|
||||
let trigger_filter = player.union(npc);
|
||||
assert!(player.intersects(trigger_filter));
|
||||
assert!(npc.intersects(trigger_filter));
|
||||
// A wall on layer 5 does not trip the trigger.
|
||||
assert!(!LayerMask::layer(5).intersects(trigger_filter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_algebra() {
|
||||
let a = LayerMask::NONE.with(0).with(1).with(2);
|
||||
let b = LayerMask::NONE.with(1).with(2).with(3);
|
||||
assert_eq!(a.union(b), LayerMask::NONE.with(0).with(1).with(2).with(3));
|
||||
assert_eq!(a.intersection(b), LayerMask::NONE.with(1).with(2));
|
||||
assert_eq!(a.difference(b), LayerMask::layer(0));
|
||||
assert!(a.contains(LayerMask::NONE.with(0).with(1)));
|
||||
assert!(!a.contains(b));
|
||||
assert_eq!(LayerMask::ALL.complement(), LayerMask::NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bit_operators_match_named_methods() {
|
||||
let a = LayerMask::layer(1);
|
||||
let b = LayerMask::layer(2);
|
||||
assert_eq!(a | b, a.union(b));
|
||||
assert_eq!((a | b) & a, a);
|
||||
assert_eq!(a ^ a, LayerMask::NONE);
|
||||
assert_eq!(!LayerMask::NONE, LayerMask::ALL);
|
||||
|
||||
let mut m = LayerMask::NONE;
|
||||
m |= a;
|
||||
m |= b;
|
||||
assert!(m.intersects(a) && m.intersects(b));
|
||||
m &= a;
|
||||
assert_eq!(m, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_yields_ascending_indices() {
|
||||
let m = LayerMask::NONE.with(0).with(5).with(31);
|
||||
assert_eq!(m.iter().collect::<Vec<_>>(), vec![0, 5, 31]);
|
||||
assert!(LayerMask::NONE.iter().next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_iter_collects_indices() {
|
||||
let m: LayerMask = [1u32, 3, 5].into_iter().collect();
|
||||
assert_eq!(m, LayerMask::NONE.with(1).with(3).with(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_ron() {
|
||||
let m = LayerMask::NONE.with(2).with(9).with(30);
|
||||
let ron = ron::to_string(&m).unwrap();
|
||||
let back: LayerMask = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(m, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Layer & tags — the engine's filtering primitives.
|
||||
//!
|
||||
//! Stage 5 introduces one shared way to answer "which things interact with
|
||||
//! which?", so physics, rendering, and scene queries all speak the same
|
||||
//! language instead of each inventing its own:
|
||||
//!
|
||||
//! - [`LayerMask`] — a 32-slot bitset. The single primitive used both for an
|
||||
//! entity's **membership** and for the **filters** that select entities.
|
||||
//! Two masks interact when they share any layer ([`LayerMask::intersects`]).
|
||||
//! - [`LayerRegistry`] — project-level human-readable names for the 32 layers
|
||||
//! (e.g. layer 1 = `"Player"`), so masks can be authored and displayed by
|
||||
//! name. Layer 0 is `"Default"`.
|
||||
//! - [`Layer`] — the per-entity component holding its membership mask. Defaults
|
||||
//! to the `Default` layer so new entities are visible to broad filters.
|
||||
//! - [`Tags`] — a per-entity set of free-form string tags for gameplay
|
||||
//! *identification* (`"Enemy"`, `"Interactable"`), distinct from the
|
||||
//! hot-path [`LayerMask`]. This is the **multi-valued** membership concept
|
||||
//! (an entity is in many groups) paired with the single-valued [`Layer`].
|
||||
//! - [`GroupRegistry`] — project-level list of defined group names, so the
|
||||
//! editor offers a fixed vocabulary to tag entities with (predefined, like
|
||||
//! layers) rather than free-typed strings.
|
||||
//!
|
||||
//! How consumers use it (built out in later stages):
|
||||
//! - **Physics** (Stage 9): a collider's membership + filter masks drive
|
||||
//! collision groups and sensor/trigger filtering.
|
||||
//! - **Rendering** (Stage 5 pipeline): a camera holds a visibility filter; only
|
||||
//! entities whose [`Layer`] intersect it are drawn.
|
||||
//! - **Scene queries**: a raycast carries a filter mask tested against
|
||||
//! candidate entities' membership.
|
||||
//!
|
||||
//! All four types are serializable, so layer data is dual-editable (editor +
|
||||
//! scripts/AI) like every other engine component.
|
||||
|
||||
mod components;
|
||||
mod groups;
|
||||
mod mask;
|
||||
mod registry;
|
||||
|
||||
pub use components::{Layer, Tags};
|
||||
pub use groups::GroupRegistry;
|
||||
pub use mask::{LayerMask, MAX_LAYERS};
|
||||
pub use registry::LayerRegistry;
|
||||
@@ -0,0 +1,163 @@
|
||||
//! [`LayerRegistry`]: human-readable names for the 32 layers.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{LayerMask, MAX_LAYERS};
|
||||
|
||||
/// Maps layer indices (`0..32`) to project-defined names.
|
||||
///
|
||||
/// A [`LayerMask`] is just bits; the registry is what lets a project, the
|
||||
/// editor, and scripts talk about layer **3** as `"Enemy"` instead of a magic
|
||||
/// number. It is project-level data (serialized with the project, later stages)
|
||||
/// and changing a name never moves an entity between layers — only the label
|
||||
/// changes.
|
||||
///
|
||||
/// Index `0` is seeded with the name `"Default"`, the layer every entity starts
|
||||
/// on (see [`Layer`](super::Layer)). The remaining slots are unnamed until a
|
||||
/// project assigns them.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LayerRegistry {
|
||||
/// One slot per layer; `None` means the layer has no assigned name.
|
||||
names: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
impl LayerRegistry {
|
||||
/// A registry with only layer 0 named (`"Default"`).
|
||||
pub fn new() -> Self {
|
||||
let mut names = vec![None; MAX_LAYERS as usize];
|
||||
names[0] = Some("Default".to_string());
|
||||
Self { names }
|
||||
}
|
||||
|
||||
/// Assigns `name` to layer `index`.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `index >= 32`.
|
||||
pub fn set(&mut self, index: u32, name: impl Into<String>) {
|
||||
assert!(
|
||||
index < MAX_LAYERS,
|
||||
"layer index out of range (must be 0..32)"
|
||||
);
|
||||
self.names[index as usize] = Some(name.into());
|
||||
}
|
||||
|
||||
/// Clears the name of layer `index`, leaving it unnamed.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `index >= 32`.
|
||||
pub fn clear(&mut self, index: u32) {
|
||||
assert!(
|
||||
index < MAX_LAYERS,
|
||||
"layer index out of range (must be 0..32)"
|
||||
);
|
||||
self.names[index as usize] = None;
|
||||
}
|
||||
|
||||
/// The name of layer `index`, or `None` if it is out of range or unnamed.
|
||||
pub fn name(&self, index: u32) -> Option<&str> {
|
||||
self.names.get(index as usize).and_then(|n| n.as_deref())
|
||||
}
|
||||
|
||||
/// The index of the layer named `name`, or `None` if no layer has it.
|
||||
///
|
||||
/// Names are not required to be unique; the lowest matching index wins.
|
||||
pub fn index_of(&self, name: &str) -> Option<u32> {
|
||||
self.names
|
||||
.iter()
|
||||
.position(|n| n.as_deref() == Some(name))
|
||||
.map(|i| i as u32)
|
||||
}
|
||||
|
||||
/// A [`LayerMask`] built from layer names, skipping any that are unknown.
|
||||
///
|
||||
/// Convenient for authoring filters by name, e.g.
|
||||
/// `registry.mask_of(["Player", "NPC"])`.
|
||||
pub fn mask_of<'a, I>(&self, names: I) -> LayerMask
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
names.into_iter().filter_map(|n| self.index_of(n)).collect()
|
||||
}
|
||||
|
||||
/// Iterates `(index, name)` for every *named* layer, ascending by index.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (u32, &str)> {
|
||||
self.names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, n)| n.as_deref().map(|name| (i as u32, name)))
|
||||
}
|
||||
|
||||
/// The number of named layers.
|
||||
pub fn named_count(&self) -> usize {
|
||||
self.names.iter().filter(|n| n.is_some()).count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LayerRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_names_only_layer_zero() {
|
||||
let reg = LayerRegistry::new();
|
||||
assert_eq!(reg.name(0), Some("Default"));
|
||||
assert_eq!(reg.name(1), None);
|
||||
assert_eq!(reg.named_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_look_up_by_name() {
|
||||
let mut reg = LayerRegistry::new();
|
||||
reg.set(1, "Player");
|
||||
reg.set(2, "NPC");
|
||||
reg.set(5, "Water");
|
||||
assert_eq!(reg.name(2), Some("NPC"));
|
||||
assert_eq!(reg.index_of("Water"), Some(5));
|
||||
assert_eq!(reg.index_of("Missing"), None);
|
||||
assert_eq!(reg.named_count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_of_names_builds_a_filter() {
|
||||
let mut reg = LayerRegistry::new();
|
||||
reg.set(1, "Player");
|
||||
reg.set(2, "NPC");
|
||||
let mask = reg.mask_of(["Player", "NPC", "Unknown"]);
|
||||
assert_eq!(mask, LayerMask::NONE.with(1).with(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_unsets_a_name() {
|
||||
let mut reg = LayerRegistry::new();
|
||||
reg.set(3, "Trigger");
|
||||
assert_eq!(reg.index_of("Trigger"), Some(3));
|
||||
reg.clear(3);
|
||||
assert_eq!(reg.name(3), None);
|
||||
assert_eq!(reg.index_of("Trigger"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_visits_named_layers_in_order() {
|
||||
let mut reg = LayerRegistry::new();
|
||||
reg.set(4, "B");
|
||||
reg.set(2, "A");
|
||||
let pairs: Vec<_> = reg.iter().collect();
|
||||
assert_eq!(pairs, vec![(0, "Default"), (2, "A"), (4, "B")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_ron() {
|
||||
let mut reg = LayerRegistry::new();
|
||||
reg.set(1, "Player");
|
||||
reg.set(7, "Foliage");
|
||||
let ron = ron::to_string(®).unwrap();
|
||||
let back: LayerRegistry = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(reg, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Oxide Engine — core library.
|
||||
//!
|
||||
//! Each system lives in its own module and is independently usable.
|
||||
//! Systems are enabled progressively as stages are completed.
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
// Lets `#[derive(Reflect)]` emit `::oxide_engine::reflect::…` paths that
|
||||
// resolve even when the derive is used *inside* this crate (e.g. on the
|
||||
// engine's own component types). Standard proc-macro self-reference trick.
|
||||
extern crate self as oxide_engine;
|
||||
|
||||
pub mod app;
|
||||
pub mod asset;
|
||||
pub mod input;
|
||||
pub mod layer;
|
||||
pub mod math;
|
||||
pub mod prefab;
|
||||
pub mod project;
|
||||
pub mod reflect;
|
||||
pub mod render;
|
||||
pub mod scene;
|
||||
pub mod settings;
|
||||
pub mod ui;
|
||||
pub mod watch;
|
||||
pub mod window;
|
||||
|
||||
// Re-exported so engine consumers can use GPU/windowing/ECS types without
|
||||
// declaring (and version-matching) their own direct dependency.
|
||||
pub use hecs;
|
||||
pub use wgpu;
|
||||
pub use winit;
|
||||
|
||||
pub mod prelude {
|
||||
//! Common imports for engine consumers.
|
||||
//!
|
||||
//! The core engine container ([`App`](crate::app::App)) and the windowing
|
||||
//! event-handler trait ([`WindowApp`](crate::window::WindowApp)) both live
|
||||
//! here — they cover different roles and no longer share a name (Stage 6
|
||||
//! resolved the Stage-5 naming clash).
|
||||
pub use crate::app::{App, DefaultModules, Module, Schedule};
|
||||
pub use crate::asset::{
|
||||
load_gltf, AssetDatabase, AssetKind, AssetRef, AssetServer, AssetUid, GltfModel, Handle,
|
||||
};
|
||||
pub use crate::input::{
|
||||
ActionMap, ActionOverrides, Axis2DBinding, AxisBinding, Binding, InputState,
|
||||
};
|
||||
pub use crate::layer::{GroupRegistry, Layer, LayerMask, LayerRegistry, Tags};
|
||||
pub use crate::math::{
|
||||
Aabb, Color, EulerRot, Frustum, Mat3, Mat4, Plane, Quat, Range3, Ray, Rect, Transform,
|
||||
Vec2, Vec3, Vec4,
|
||||
};
|
||||
pub use crate::prefab::{ComponentSpec, Prefab, PrefabRegistry};
|
||||
pub use crate::project::{Project, RecentProjects};
|
||||
pub use crate::reflect::TypeRegistry;
|
||||
pub use crate::render::{
|
||||
Camera, ClearPass, DirectionalLight, ForwardPass, ForwardRenderer, FrameContext, Gpu,
|
||||
GpuMesh, Lighting, Material, Mesh, MeshRenderer, PrimitiveShape, RenderContext,
|
||||
RenderObject, RenderPass, RenderPipeline, UiBatch, UiOverlayPass, Vertex,
|
||||
};
|
||||
pub use crate::scene::{DespawnPolicy, Entity, Node, Scene, SceneError, SceneSnapshot};
|
||||
pub use crate::settings::Settings;
|
||||
pub use crate::ui::hit_test as ui_hit_test;
|
||||
pub use crate::ui::{
|
||||
layout as ui_layout, paint as ui_paint, shape as ui_shape, shape_runs as ui_shape_runs,
|
||||
Align as UiAlign, Anchor as UiAnchor, AnchorGroup as UiAnchorGroup,
|
||||
AtlasEntry as UiAtlasEntry, Border as UiBorder, DrawCommand as UiDrawCommand,
|
||||
Font as UiFont, FontId as UiFontId, FontRef as UiFontRef, FontStore as UiFontStore,
|
||||
FontWeight as UiFontWeight, GlyphAtlas as UiGlyphAtlas, GlyphId as UiGlyphId,
|
||||
GlyphKey as UiGlyphKey, Grid as UiGrid, Insets as UiInsets, LayoutNode as UiLayoutNode,
|
||||
LayoutStyle as UiLayoutStyle, LayoutTree as UiLayoutTree, PaintedFrame as UiPaintedFrame,
|
||||
Router as UiRouter, RouterEvent as UiRouterEvent, RouterFrame as UiRouterFrame,
|
||||
ShapeParams as UiShapeParams, ShapedGlyph as UiShapedGlyph, ShapedLine as UiShapedLine,
|
||||
ShapedText as UiShapedText, Sizing as UiSizing, Stack as UiStack,
|
||||
StackDirection as UiStackDirection, TextAlign as UiTextAlign, TextRun as UiTextRun,
|
||||
TextStyle as UiTextStyle, Theme as UiTheme, UiPanel, VisualStyle as UiVisualStyle, Widget,
|
||||
WidgetId, WidgetKind, WidgetPath, WidgetValue,
|
||||
};
|
||||
pub use crate::watch::{reload_changed_assets, ChangeEvent, ChangeKind, FileWatcher};
|
||||
pub use crate::window::{run, AppCtx, WindowApp, WindowConfig};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Axis-aligned bounding box ([`Aabb`]).
|
||||
//!
|
||||
//! Stored as `min`/`max` corners. An AABB with any `min` component greater than
|
||||
//! the corresponding `max` is considered *empty* (contains no points), which is
|
||||
//! the natural identity for union operations.
|
||||
|
||||
use crate::math::Ray;
|
||||
use glam::Vec3;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An axis-aligned bounding box defined by its minimum and maximum corners.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Aabb {
|
||||
/// Minimum corner (smallest x, y, z).
|
||||
pub min: Vec3,
|
||||
/// Maximum corner (largest x, y, z).
|
||||
pub max: Vec3,
|
||||
}
|
||||
|
||||
impl Aabb {
|
||||
/// An empty box: `min` is `+inf`, `max` is `-inf`. Unioning any point with
|
||||
/// this yields a box tightly bounding that point.
|
||||
pub const EMPTY: Self = Self {
|
||||
min: Vec3::splat(f32::INFINITY),
|
||||
max: Vec3::splat(f32::NEG_INFINITY),
|
||||
};
|
||||
|
||||
/// Creates an AABB from two corners, sorting components so `min <= max`.
|
||||
#[inline]
|
||||
pub fn new(a: Vec3, b: Vec3) -> Self {
|
||||
Self {
|
||||
min: a.min(b),
|
||||
max: a.max(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an AABB from a center point and half-extents.
|
||||
#[inline]
|
||||
pub fn from_center_half_extents(center: Vec3, half_extents: Vec3) -> Self {
|
||||
Self {
|
||||
min: center - half_extents,
|
||||
max: center + half_extents,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the tightest AABB containing all `points`. Returns [`Aabb::EMPTY`]
|
||||
/// if the iterator is empty.
|
||||
pub fn from_points(points: impl IntoIterator<Item = Vec3>) -> Self {
|
||||
let mut bb = Self::EMPTY;
|
||||
for p in points {
|
||||
bb.expand_to_include(p);
|
||||
}
|
||||
bb
|
||||
}
|
||||
|
||||
/// Returns `true` if this box contains no points (any axis inverted).
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.min.x > self.max.x || self.min.y > self.max.y || self.min.z > self.max.z
|
||||
}
|
||||
|
||||
/// The center point of the box. Meaningless for an empty box.
|
||||
#[inline]
|
||||
pub fn center(&self) -> Vec3 {
|
||||
(self.min + self.max) * 0.5
|
||||
}
|
||||
|
||||
/// The full size (max - min) along each axis.
|
||||
#[inline]
|
||||
pub fn size(&self) -> Vec3 {
|
||||
(self.max - self.min).max(Vec3::ZERO)
|
||||
}
|
||||
|
||||
/// Half of [`Aabb::size`].
|
||||
#[inline]
|
||||
pub fn half_extents(&self) -> Vec3 {
|
||||
self.size() * 0.5
|
||||
}
|
||||
|
||||
/// The surface area of the box (used by spatial acceleration heuristics).
|
||||
#[inline]
|
||||
pub fn surface_area(&self) -> f32 {
|
||||
let s = self.size();
|
||||
2.0 * (s.x * s.y + s.y * s.z + s.z * s.x)
|
||||
}
|
||||
|
||||
/// The volume of the box.
|
||||
#[inline]
|
||||
pub fn volume(&self) -> f32 {
|
||||
let s = self.size();
|
||||
s.x * s.y * s.z
|
||||
}
|
||||
|
||||
/// Grows the box (in place) to include `point`.
|
||||
#[inline]
|
||||
pub fn expand_to_include(&mut self, point: Vec3) {
|
||||
self.min = self.min.min(point);
|
||||
self.max = self.max.max(point);
|
||||
}
|
||||
|
||||
/// Returns the union of this box and `other` (smallest box containing both).
|
||||
#[inline]
|
||||
pub fn union(&self, other: &Aabb) -> Aabb {
|
||||
Aabb {
|
||||
min: self.min.min(other.min),
|
||||
max: self.max.max(other.max),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the intersection of two boxes, or [`Aabb::EMPTY`] if disjoint.
|
||||
#[inline]
|
||||
pub fn intersection(&self, other: &Aabb) -> Aabb {
|
||||
let min = self.min.max(other.min);
|
||||
let max = self.max.min(other.max);
|
||||
if min.x > max.x || min.y > max.y || min.z > max.z {
|
||||
Aabb::EMPTY
|
||||
} else {
|
||||
Aabb { min, max }
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if `point` is inside or on the boundary of the box.
|
||||
#[inline]
|
||||
pub fn contains_point(&self, point: Vec3) -> bool {
|
||||
point.cmpge(self.min).all() && point.cmple(self.max).all()
|
||||
}
|
||||
|
||||
/// Returns `true` if the two boxes overlap (touching counts as overlap).
|
||||
#[inline]
|
||||
pub fn intersects(&self, other: &Aabb) -> bool {
|
||||
self.min.cmple(other.max).all() && self.max.cmpge(other.min).all()
|
||||
}
|
||||
|
||||
/// Returns the point on or inside the box closest to `point`.
|
||||
#[inline]
|
||||
pub fn closest_point(&self, point: Vec3) -> Vec3 {
|
||||
point.clamp(self.min, self.max)
|
||||
}
|
||||
|
||||
/// The eight corner vertices of the box.
|
||||
pub fn corners(&self) -> [Vec3; 8] {
|
||||
let (lo, hi) = (self.min, self.max);
|
||||
[
|
||||
Vec3::new(lo.x, lo.y, lo.z),
|
||||
Vec3::new(hi.x, lo.y, lo.z),
|
||||
Vec3::new(lo.x, hi.y, lo.z),
|
||||
Vec3::new(hi.x, hi.y, lo.z),
|
||||
Vec3::new(lo.x, lo.y, hi.z),
|
||||
Vec3::new(hi.x, lo.y, hi.z),
|
||||
Vec3::new(lo.x, hi.y, hi.z),
|
||||
Vec3::new(hi.x, hi.y, hi.z),
|
||||
]
|
||||
}
|
||||
|
||||
/// Slab-method ray/box intersection. Returns the entry distance `t` along
|
||||
/// the ray if it hits (including when the origin is inside, where `t` is the
|
||||
/// clamped near distance), otherwise `None`.
|
||||
pub fn ray_intersection(&self, ray: &Ray) -> Option<f32> {
|
||||
let inv_dir = Vec3::ONE / ray.direction;
|
||||
let t0 = (self.min - ray.origin) * inv_dir;
|
||||
let t1 = (self.max - ray.origin) * inv_dir;
|
||||
let t_near = t0.min(t1);
|
||||
let t_far = t0.max(t1);
|
||||
let t_enter = t_near.max_element();
|
||||
let t_exit = t_far.min_element();
|
||||
if t_enter <= t_exit && t_exit >= 0.0 {
|
||||
Some(t_enter.max(0.0))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_box_contains_nothing() {
|
||||
assert!(Aabb::EMPTY.is_empty());
|
||||
assert!(!Aabb::EMPTY.contains_point(Vec3::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_sorts_corners() {
|
||||
let bb = Aabb::new(Vec3::new(1.0, 5.0, -2.0), Vec3::new(-1.0, 0.0, 3.0));
|
||||
assert_eq!(bb.min, Vec3::new(-1.0, 0.0, -2.0));
|
||||
assert_eq!(bb.max, Vec3::new(1.0, 5.0, 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_size_extents() {
|
||||
let bb = Aabb::from_center_half_extents(Vec3::new(1.0, 2.0, 3.0), Vec3::splat(2.0));
|
||||
assert_eq!(bb.center(), Vec3::new(1.0, 2.0, 3.0));
|
||||
assert_eq!(bb.size(), Vec3::splat(4.0));
|
||||
assert_eq!(bb.half_extents(), Vec3::splat(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_points_bounds_all() {
|
||||
let bb = Aabb::from_points([
|
||||
Vec3::new(0.0, 0.0, 0.0),
|
||||
Vec3::new(2.0, -1.0, 4.0),
|
||||
Vec3::new(-3.0, 5.0, 1.0),
|
||||
]);
|
||||
assert_eq!(bb.min, Vec3::new(-3.0, -1.0, 0.0));
|
||||
assert_eq!(bb.max, Vec3::new(2.0, 5.0, 4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_points_empty_is_empty() {
|
||||
assert!(Aabb::from_points([]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_and_closest() {
|
||||
let bb = Aabb::new(Vec3::ZERO, Vec3::splat(2.0));
|
||||
assert!(bb.contains_point(Vec3::ONE));
|
||||
assert!(bb.contains_point(Vec3::ZERO)); // boundary
|
||||
assert!(!bb.contains_point(Vec3::new(3.0, 1.0, 1.0)));
|
||||
assert_eq!(
|
||||
bb.closest_point(Vec3::new(5.0, -1.0, 1.0)),
|
||||
Vec3::new(2.0, 0.0, 1.0)
|
||||
);
|
||||
assert_eq!(bb.closest_point(Vec3::ONE), Vec3::ONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_and_intersection() {
|
||||
let a = Aabb::new(Vec3::ZERO, Vec3::splat(2.0));
|
||||
let b = Aabb::new(Vec3::ONE, Vec3::splat(3.0));
|
||||
assert_eq!(a.union(&b), Aabb::new(Vec3::ZERO, Vec3::splat(3.0)));
|
||||
assert_eq!(a.intersection(&b), Aabb::new(Vec3::ONE, Vec3::splat(2.0)));
|
||||
|
||||
let c = Aabb::new(Vec3::splat(5.0), Vec3::splat(6.0));
|
||||
assert!(a.intersection(&c).is_empty());
|
||||
assert!(!a.intersects(&c));
|
||||
assert!(a.intersects(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_area_and_volume() {
|
||||
let bb = Aabb::new(Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0));
|
||||
assert_eq!(bb.volume(), 6.0);
|
||||
assert_eq!(bb.surface_area(), 2.0 * (2.0 + 6.0 + 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_count_and_span() {
|
||||
let bb = Aabb::new(Vec3::ZERO, Vec3::ONE);
|
||||
let corners = bb.corners();
|
||||
assert_eq!(corners.len(), 8);
|
||||
assert!(corners.contains(&Vec3::ZERO));
|
||||
assert!(corners.contains(&Vec3::ONE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_hits_from_outside() {
|
||||
let bb = Aabb::new(Vec3::splat(-1.0), Vec3::splat(1.0));
|
||||
let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::X);
|
||||
let t = bb.ray_intersection(&ray).expect("should hit");
|
||||
assert!((t - 4.0).abs() <= 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_from_inside_returns_zero() {
|
||||
let bb = Aabb::new(Vec3::splat(-1.0), Vec3::splat(1.0));
|
||||
let ray = Ray::new(Vec3::ZERO, Vec3::X);
|
||||
assert_eq!(bb.ray_intersection(&ray), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_misses() {
|
||||
let bb = Aabb::new(Vec3::splat(-1.0), Vec3::splat(1.0));
|
||||
let ray = Ray::new(Vec3::new(-5.0, 5.0, 0.0), Vec3::X);
|
||||
assert_eq!(bb.ray_intersection(&ray), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_pointing_away_misses() {
|
||||
let bb = Aabb::new(Vec3::splat(-1.0), Vec3::splat(1.0));
|
||||
let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::NEG_X);
|
||||
assert_eq!(bb.ray_intersection(&ray), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Linear RGBA [`Color`].
|
||||
//!
|
||||
//! Colors are stored as `f32` components in **linear** space (the space shaders
|
||||
//! and lighting math expect). Helpers convert to/from 8-bit sRGB for I/O.
|
||||
|
||||
use glam::{Vec3, Vec4};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An RGBA color with linear `f32` components, nominally in `[0, 1]` but not
|
||||
/// clamped (values above 1.0 represent HDR / emissive intensity).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Color {
|
||||
/// Red channel (linear).
|
||||
pub r: f32,
|
||||
/// Green channel (linear).
|
||||
pub g: f32,
|
||||
/// Blue channel (linear).
|
||||
pub b: f32,
|
||||
/// Alpha (opacity); `1.0` is fully opaque.
|
||||
pub a: f32,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
/// Opaque black.
|
||||
pub const BLACK: Self = Self::rgb(0.0, 0.0, 0.0);
|
||||
/// Opaque white.
|
||||
pub const WHITE: Self = Self::rgb(1.0, 1.0, 1.0);
|
||||
/// Opaque red.
|
||||
pub const RED: Self = Self::rgb(1.0, 0.0, 0.0);
|
||||
/// Opaque green.
|
||||
pub const GREEN: Self = Self::rgb(0.0, 1.0, 0.0);
|
||||
/// Opaque blue.
|
||||
pub const BLUE: Self = Self::rgb(0.0, 0.0, 1.0);
|
||||
/// Fully transparent (all channels zero).
|
||||
pub const TRANSPARENT: Self = Self::rgba(0.0, 0.0, 0.0, 0.0);
|
||||
|
||||
/// Creates a color from linear RGBA components.
|
||||
#[inline]
|
||||
pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
|
||||
Self { r, g, b, a }
|
||||
}
|
||||
|
||||
/// Creates an opaque color from linear RGB components.
|
||||
#[inline]
|
||||
pub const fn rgb(r: f32, g: f32, b: f32) -> Self {
|
||||
Self { r, g, b, a: 1.0 }
|
||||
}
|
||||
|
||||
/// Creates a linear color from 8-bit **sRGB** components (the usual format
|
||||
/// of color pickers and image files), with full opacity.
|
||||
#[inline]
|
||||
pub fn from_srgb_u8(r: u8, g: u8, b: u8) -> Self {
|
||||
Self::rgb(
|
||||
srgb_to_linear(r as f32 / 255.0),
|
||||
srgb_to_linear(g as f32 / 255.0),
|
||||
srgb_to_linear(b as f32 / 255.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a linear color from a packed `0xRRGGBB` hex value.
|
||||
#[inline]
|
||||
pub fn from_hex(hex: u32) -> Self {
|
||||
Self::from_srgb_u8(
|
||||
((hex >> 16) & 0xFF) as u8,
|
||||
((hex >> 8) & 0xFF) as u8,
|
||||
(hex & 0xFF) as u8,
|
||||
)
|
||||
}
|
||||
|
||||
/// Converts to 8-bit sRGB `(r, g, b, a)`, clamping to `[0, 1]` first.
|
||||
#[inline]
|
||||
pub fn to_srgb_u8(&self) -> [u8; 4] {
|
||||
[
|
||||
(linear_to_srgb(self.r.clamp(0.0, 1.0)) * 255.0).round() as u8,
|
||||
(linear_to_srgb(self.g.clamp(0.0, 1.0)) * 255.0).round() as u8,
|
||||
(linear_to_srgb(self.b.clamp(0.0, 1.0)) * 255.0).round() as u8,
|
||||
(self.a.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns the color as a `Vec4` (`[r, g, b, a]`).
|
||||
#[inline]
|
||||
pub fn to_vec4(&self) -> Vec4 {
|
||||
Vec4::new(self.r, self.g, self.b, self.a)
|
||||
}
|
||||
|
||||
/// Returns the RGB channels as a `Vec3`.
|
||||
#[inline]
|
||||
pub fn to_vec3(&self) -> Vec3 {
|
||||
Vec3::new(self.r, self.g, self.b)
|
||||
}
|
||||
|
||||
/// Returns a copy with the alpha replaced.
|
||||
#[inline]
|
||||
pub fn with_alpha(&self, a: f32) -> Self {
|
||||
Self { a, ..*self }
|
||||
}
|
||||
|
||||
/// Linearly interpolates between two colors. `t` is clamped to `[0, 1]`.
|
||||
#[inline]
|
||||
pub fn lerp(&self, other: Color, t: f32) -> Color {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
Color {
|
||||
r: self.r + (other.r - self.r) * t,
|
||||
g: self.g + (other.g - self.g) * t,
|
||||
b: self.b + (other.b - self.b) * t,
|
||||
a: self.a + (other.a - self.a) * t,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a single sRGB channel value in `[0, 1]` to linear space.
|
||||
#[inline]
|
||||
fn srgb_to_linear(c: f32) -> f32 {
|
||||
if c <= 0.04045 {
|
||||
c / 12.92
|
||||
} else {
|
||||
((c + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a single linear channel value in `[0, 1]` to sRGB space.
|
||||
#[inline]
|
||||
fn linear_to_srgb(c: f32) -> f32 {
|
||||
if c <= 0.003_130_8 {
|
||||
c * 12.92
|
||||
} else {
|
||||
1.055 * c.powf(1.0 / 2.4) - 0.055
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EPS: f32 = 1e-4;
|
||||
|
||||
#[test]
|
||||
fn constants() {
|
||||
assert_eq!(Color::WHITE, Color::rgb(1.0, 1.0, 1.0));
|
||||
assert_eq!(Color::TRANSPARENT.a, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_round_trip() {
|
||||
let original = [10u8, 128, 240];
|
||||
let c = Color::from_srgb_u8(original[0], original[1], original[2]);
|
||||
let back = c.to_srgb_u8();
|
||||
assert_eq!([back[0], back[1], back[2]], original);
|
||||
assert_eq!(back[3], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_endpoints_are_exact() {
|
||||
assert!(srgb_to_linear(0.0).abs() <= EPS);
|
||||
assert!((srgb_to_linear(1.0) - 1.0).abs() <= EPS);
|
||||
assert!((linear_to_srgb(1.0) - 1.0).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_parsing() {
|
||||
let c = Color::from_hex(0xFF0000);
|
||||
assert_eq!(c.to_srgb_u8()[0], 255);
|
||||
assert_eq!(c.to_srgb_u8()[1], 0);
|
||||
assert_eq!(c.to_srgb_u8()[2], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lerp_endpoints_and_midpoint() {
|
||||
let a = Color::rgba(0.0, 0.0, 0.0, 0.0);
|
||||
let b = Color::rgba(1.0, 1.0, 1.0, 1.0);
|
||||
assert_eq!(a.lerp(b, 0.0), a);
|
||||
assert_eq!(a.lerp(b, 1.0), b);
|
||||
assert_eq!(a.lerp(b, 0.5), Color::rgba(0.5, 0.5, 0.5, 0.5));
|
||||
// Clamps out-of-range t.
|
||||
assert_eq!(a.lerp(b, 2.0), b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vec_conversions_and_alpha() {
|
||||
let c = Color::rgba(0.1, 0.2, 0.3, 0.4);
|
||||
assert_eq!(c.to_vec4(), Vec4::new(0.1, 0.2, 0.3, 0.4));
|
||||
assert_eq!(c.to_vec3(), Vec3::new(0.1, 0.2, 0.3));
|
||||
assert_eq!(c.with_alpha(1.0).a, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_values_not_clamped_in_storage() {
|
||||
let c = Color::rgb(4.0, 0.0, 0.0);
|
||||
assert_eq!(c.r, 4.0);
|
||||
// But output is clamped.
|
||||
assert_eq!(c.to_srgb_u8()[0], 255);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! A view [`Frustum`]: six planes used for visibility culling.
|
||||
|
||||
use crate::math::{Aabb, Plane};
|
||||
use glam::{Mat4, Vec3, Vec4, Vec4Swizzles};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A frustum represented by six bounding planes, each with its normal pointing
|
||||
/// *inward*. A point is inside the frustum when it lies in the positive
|
||||
/// half-space of every plane.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Frustum {
|
||||
/// Planes ordered: left, right, bottom, top, near, far.
|
||||
pub planes: [Plane; 6],
|
||||
}
|
||||
|
||||
impl Frustum {
|
||||
/// Extracts the six frustum planes from a combined view-projection matrix
|
||||
/// using the Gribb–Hartmann method. Works for both perspective and
|
||||
/// orthographic projections.
|
||||
pub fn from_view_projection(view_projection: Mat4) -> Self {
|
||||
// Rows of the matrix (glam is column-major, so build rows explicitly).
|
||||
let m = view_projection;
|
||||
let row0 = Vec4::new(m.x_axis.x, m.y_axis.x, m.z_axis.x, m.w_axis.x);
|
||||
let row1 = Vec4::new(m.x_axis.y, m.y_axis.y, m.z_axis.y, m.w_axis.y);
|
||||
let row2 = Vec4::new(m.x_axis.z, m.y_axis.z, m.z_axis.z, m.w_axis.z);
|
||||
let row3 = Vec4::new(m.x_axis.w, m.y_axis.w, m.z_axis.w, m.w_axis.w);
|
||||
|
||||
let plane_from = |v: Vec4| Plane::new(v.xyz(), v.w);
|
||||
|
||||
let planes = [
|
||||
plane_from(row3 + row0), // left
|
||||
plane_from(row3 - row0), // right
|
||||
plane_from(row3 + row1), // bottom
|
||||
plane_from(row3 - row1), // top
|
||||
plane_from(row3 + row2), // near
|
||||
plane_from(row3 - row2), // far
|
||||
];
|
||||
Self { planes }
|
||||
}
|
||||
|
||||
/// Returns `true` if `point` is inside (or on the boundary of) the frustum.
|
||||
pub fn contains_point(&self, point: Vec3) -> bool {
|
||||
self.planes
|
||||
.iter()
|
||||
.all(|plane| plane.signed_distance(point) >= 0.0)
|
||||
}
|
||||
|
||||
/// Returns `true` if any part of `aabb` is inside the frustum.
|
||||
///
|
||||
/// This is a conservative test: it may very rarely report a box as visible
|
||||
/// when it is just outside a corner, but never culls a visible box. That is
|
||||
/// the correct trade-off for rendering.
|
||||
pub fn intersects_aabb(&self, aabb: &Aabb) -> bool {
|
||||
for plane in &self.planes {
|
||||
// The "positive vertex": the AABB corner farthest along the normal.
|
||||
let p = Vec3::new(
|
||||
if plane.normal.x >= 0.0 {
|
||||
aabb.max.x
|
||||
} else {
|
||||
aabb.min.x
|
||||
},
|
||||
if plane.normal.y >= 0.0 {
|
||||
aabb.max.y
|
||||
} else {
|
||||
aabb.min.y
|
||||
},
|
||||
if plane.normal.z >= 0.0 {
|
||||
aabb.max.z
|
||||
} else {
|
||||
aabb.min.z
|
||||
},
|
||||
);
|
||||
// If the farthest corner is behind a plane, the box is fully outside.
|
||||
if plane.signed_distance(p) < 0.0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns `true` if the sphere at `center` with `radius` is at least
|
||||
/// partially inside the frustum.
|
||||
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
|
||||
self.planes
|
||||
.iter()
|
||||
.all(|plane| plane.signed_distance(center) >= -radius)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn perspective_vp() -> Mat4 {
|
||||
let proj = Mat4::perspective_rh(60_f32.to_radians(), 1.0, 1.0, 100.0);
|
||||
let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 0.0), Vec3::NEG_Z, Vec3::Y);
|
||||
proj * view
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_front_is_inside() {
|
||||
let f = Frustum::from_view_projection(perspective_vp());
|
||||
assert!(f.contains_point(Vec3::new(0.0, 0.0, -10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_behind_camera_is_outside() {
|
||||
let f = Frustum::from_view_projection(perspective_vp());
|
||||
assert!(!f.contains_point(Vec3::new(0.0, 0.0, 10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_beyond_far_is_outside() {
|
||||
let f = Frustum::from_view_projection(perspective_vp());
|
||||
assert!(!f.contains_point(Vec3::new(0.0, 0.0, -500.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_way_off_to_side_is_outside() {
|
||||
let f = Frustum::from_view_projection(perspective_vp());
|
||||
assert!(!f.contains_point(Vec3::new(500.0, 0.0, -10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aabb_in_view_intersects() {
|
||||
let f = Frustum::from_view_projection(perspective_vp());
|
||||
let bb = Aabb::from_center_half_extents(Vec3::new(0.0, 0.0, -10.0), Vec3::splat(1.0));
|
||||
assert!(f.intersects_aabb(&bb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aabb_behind_camera_is_culled() {
|
||||
let f = Frustum::from_view_projection(perspective_vp());
|
||||
let bb = Aabb::from_center_half_extents(Vec3::new(0.0, 0.0, 50.0), Vec3::splat(1.0));
|
||||
assert!(!f.intersects_aabb(&bb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sphere_culling() {
|
||||
let f = Frustum::from_view_projection(perspective_vp());
|
||||
assert!(f.intersects_sphere(Vec3::new(0.0, 0.0, -10.0), 1.0));
|
||||
// Just behind the camera but large enough to poke into the near plane.
|
||||
assert!(!f.intersects_sphere(Vec3::new(0.0, 0.0, 50.0), 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orthographic_frustum_works() {
|
||||
let proj = Mat4::orthographic_rh(-10.0, 10.0, -10.0, 10.0, 1.0, 100.0);
|
||||
let view = Mat4::look_at_rh(Vec3::ZERO, Vec3::NEG_Z, Vec3::Y);
|
||||
let f = Frustum::from_view_projection(proj * view);
|
||||
assert!(f.contains_point(Vec3::new(5.0, 5.0, -10.0)));
|
||||
assert!(!f.contains_point(Vec3::new(50.0, 0.0, -10.0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Math and core geometric primitives.
|
||||
//!
|
||||
//! This module is the foundation every other Oxide system depends on. It builds
|
||||
//! on [`glam`] for vectors, quaternions, and matrices, and adds the engine's own
|
||||
//! higher-level types:
|
||||
//!
|
||||
//! - [`Transform`] — decomposed translation/rotation/scale, the unit of placement
|
||||
//! - [`Aabb`] — axis-aligned bounding box for bounds and culling
|
||||
//! - [`Ray`] — origin + direction, for picking and queries
|
||||
//! - [`Plane`] — infinite plane in Hessian normal form
|
||||
//! - [`Frustum`] — six-plane view volume for visibility culling
|
||||
//! - [`Color`] — linear RGBA color with sRGB conversion
|
||||
//! - [`Rect`] — 2D rectangle for UI and viewports
|
||||
//! - [`Range3`] — 3D value range for clamping and remapping
|
||||
//!
|
||||
//! `glam`'s own types are re-exported so downstream crates have a single import
|
||||
//! site for all math.
|
||||
|
||||
mod aabb;
|
||||
mod color;
|
||||
mod frustum;
|
||||
mod plane;
|
||||
mod range3;
|
||||
mod ray;
|
||||
mod rect;
|
||||
mod transform;
|
||||
|
||||
pub use aabb::Aabb;
|
||||
pub use color::Color;
|
||||
pub use frustum::Frustum;
|
||||
pub use plane::Plane;
|
||||
pub use range3::Range3;
|
||||
pub use ray::Ray;
|
||||
pub use rect::Rect;
|
||||
pub use transform::Transform;
|
||||
|
||||
// Re-export the most commonly used `glam` types so consumers don't need a
|
||||
// separate dependency on `glam` for everyday math.
|
||||
pub use glam::{EulerRot, Mat3, Mat4, Quat, Vec2, Vec3, Vec4};
|
||||
@@ -0,0 +1,154 @@
|
||||
//! An infinite [`Plane`] in Hessian normal form.
|
||||
|
||||
use crate::math::Ray;
|
||||
use glam::Vec3;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An infinite plane defined by a unit `normal` and a signed distance `d` from
|
||||
/// the origin, such that every point `p` on the plane satisfies
|
||||
/// `normal · p + d = 0`.
|
||||
///
|
||||
/// The positive half-space is the side the normal points toward.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Plane {
|
||||
/// Unit-length plane normal.
|
||||
pub normal: Vec3,
|
||||
/// Signed distance from the origin along `-normal`.
|
||||
pub d: f32,
|
||||
}
|
||||
|
||||
impl Plane {
|
||||
/// Creates a plane from a normal and signed distance, normalizing the input
|
||||
/// (and scaling `d` to match) so the result is in Hessian normal form.
|
||||
#[inline]
|
||||
pub fn new(normal: Vec3, d: f32) -> Self {
|
||||
let len = normal.length();
|
||||
if len > 0.0 {
|
||||
Self {
|
||||
normal: normal / len,
|
||||
d: d / len,
|
||||
}
|
||||
} else {
|
||||
Self { normal, d }
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a plane from a point on it and a normal direction.
|
||||
#[inline]
|
||||
pub fn from_point_normal(point: Vec3, normal: Vec3) -> Self {
|
||||
let n = normal.normalize_or_zero();
|
||||
Self {
|
||||
normal: n,
|
||||
d: -n.dot(point),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a plane through three points. Winding `a → b → c` determines the
|
||||
/// normal direction (right-hand rule).
|
||||
#[inline]
|
||||
pub fn from_points(a: Vec3, b: Vec3, c: Vec3) -> Self {
|
||||
let normal = (b - a).cross(c - a);
|
||||
Self::from_point_normal(a, normal)
|
||||
}
|
||||
|
||||
/// The signed distance from `point` to the plane. Positive on the side the
|
||||
/// normal points toward, negative behind it, zero on the plane.
|
||||
#[inline]
|
||||
pub fn signed_distance(&self, point: Vec3) -> f32 {
|
||||
self.normal.dot(point) + self.d
|
||||
}
|
||||
|
||||
/// Projects `point` orthogonally onto the plane.
|
||||
#[inline]
|
||||
pub fn project_point(&self, point: Vec3) -> Vec3 {
|
||||
point - self.normal * self.signed_distance(point)
|
||||
}
|
||||
|
||||
/// Returns the intersection distance `t` along `ray`, or `None` if the ray
|
||||
/// is parallel to the plane (or points away from it).
|
||||
pub fn ray_intersection(&self, ray: &Ray) -> Option<f32> {
|
||||
let denom = self.normal.dot(ray.direction);
|
||||
if denom.abs() <= f32::EPSILON {
|
||||
return None; // Parallel.
|
||||
}
|
||||
let t = -(self.normal.dot(ray.origin) + self.d) / denom;
|
||||
(t >= 0.0).then_some(t)
|
||||
}
|
||||
|
||||
/// Returns a plane facing the opposite direction (same geometric plane).
|
||||
#[inline]
|
||||
pub fn flipped(&self) -> Plane {
|
||||
Plane {
|
||||
normal: -self.normal,
|
||||
d: -self.d,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EPS: f32 = 1e-4;
|
||||
|
||||
#[test]
|
||||
fn from_point_normal_passes_through_point() {
|
||||
let p = Plane::from_point_normal(Vec3::new(0.0, 2.0, 0.0), Vec3::Y);
|
||||
assert!(p.signed_distance(Vec3::new(5.0, 2.0, -3.0)).abs() <= EPS);
|
||||
assert!((p.signed_distance(Vec3::new(0.0, 5.0, 0.0)) - 3.0).abs() <= EPS);
|
||||
assert!((p.signed_distance(Vec3::ZERO) + 2.0).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_normalizes() {
|
||||
let p = Plane::new(Vec3::new(0.0, 0.0, 4.0), 8.0);
|
||||
assert!((p.normal - Vec3::Z).length() <= EPS);
|
||||
assert!((p.d - 2.0).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_points_winding() {
|
||||
let p = Plane::from_points(Vec3::ZERO, Vec3::X, Vec3::Y);
|
||||
// X cross Y = Z.
|
||||
assert!((p.normal - Vec3::Z).length() <= EPS);
|
||||
assert!(p.d.abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_lands_on_plane() {
|
||||
let p = Plane::from_point_normal(Vec3::ZERO, Vec3::Y);
|
||||
let proj = p.project_point(Vec3::new(3.0, 7.0, -2.0));
|
||||
assert!((proj - Vec3::new(3.0, 0.0, -2.0)).length() <= EPS);
|
||||
assert!(p.signed_distance(proj).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_intersects_plane() {
|
||||
let p = Plane::from_point_normal(Vec3::ZERO, Vec3::Y);
|
||||
let ray = Ray::new(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y);
|
||||
let t = p.ray_intersection(&ray).expect("should hit");
|
||||
assert!((t - 5.0).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_parallel_misses() {
|
||||
let p = Plane::from_point_normal(Vec3::ZERO, Vec3::Y);
|
||||
let ray = Ray::new(Vec3::new(0.0, 5.0, 0.0), Vec3::X);
|
||||
assert_eq!(p.ray_intersection(&ray), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_pointing_away_misses() {
|
||||
let p = Plane::from_point_normal(Vec3::ZERO, Vec3::Y);
|
||||
let ray = Ray::new(Vec3::new(0.0, 5.0, 0.0), Vec3::Y);
|
||||
assert_eq!(p.ray_intersection(&ray), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flipped_reverses_sign() {
|
||||
let p = Plane::from_point_normal(Vec3::new(0.0, 2.0, 0.0), Vec3::Y);
|
||||
let f = p.flipped();
|
||||
let pt = Vec3::new(0.0, 5.0, 0.0);
|
||||
assert!((p.signed_distance(pt) + f.signed_distance(pt)).abs() <= EPS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! A 3D value [`Range3`]: an inclusive `[min, max]` interval per axis.
|
||||
//!
|
||||
//! Unlike [`Aabb`](crate::math::Aabb), which models geometry, `Range3` models a
|
||||
//! *value range* — clamping configuration values, remapping parameters, and
|
||||
//! describing generation bounds. It provides interpolation and remapping that
|
||||
//! an AABB intentionally does not.
|
||||
|
||||
use glam::Vec3;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An inclusive per-axis range `[min, max]`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Range3 {
|
||||
/// Lower bound on each axis.
|
||||
pub min: Vec3,
|
||||
/// Upper bound on each axis.
|
||||
pub max: Vec3,
|
||||
}
|
||||
|
||||
impl Range3 {
|
||||
/// The unit range `[0, 1]` on every axis.
|
||||
pub const UNIT: Self = Self {
|
||||
min: Vec3::ZERO,
|
||||
max: Vec3::ONE,
|
||||
};
|
||||
|
||||
/// Creates a range from two bounds, sorting so `min <= max` per axis.
|
||||
#[inline]
|
||||
pub fn new(a: Vec3, b: Vec3) -> Self {
|
||||
Self {
|
||||
min: a.min(b),
|
||||
max: a.max(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a range spanning `[-extent, +extent]` on each axis.
|
||||
#[inline]
|
||||
pub fn symmetric(extent: Vec3) -> Self {
|
||||
Self {
|
||||
min: -extent,
|
||||
max: extent,
|
||||
}
|
||||
}
|
||||
|
||||
/// The width of the range on each axis (`max - min`).
|
||||
#[inline]
|
||||
pub fn span(&self) -> Vec3 {
|
||||
self.max - self.min
|
||||
}
|
||||
|
||||
/// The midpoint of the range.
|
||||
#[inline]
|
||||
pub fn center(&self) -> Vec3 {
|
||||
(self.min + self.max) * 0.5
|
||||
}
|
||||
|
||||
/// Clamps `value` into the range per axis.
|
||||
#[inline]
|
||||
pub fn clamp(&self, value: Vec3) -> Vec3 {
|
||||
value.clamp(self.min, self.max)
|
||||
}
|
||||
|
||||
/// Returns `true` if `value` lies within the range (inclusive).
|
||||
#[inline]
|
||||
pub fn contains(&self, value: Vec3) -> bool {
|
||||
value.cmpge(self.min).all() && value.cmple(self.max).all()
|
||||
}
|
||||
|
||||
/// Linearly interpolates from `min` to `max` by `t` per axis. `t` is **not**
|
||||
/// clamped, so values outside `[0, 1]` extrapolate.
|
||||
#[inline]
|
||||
pub fn lerp(&self, t: Vec3) -> Vec3 {
|
||||
self.min + self.span() * t
|
||||
}
|
||||
|
||||
/// The inverse of [`Range3::lerp`]: returns where `value` sits in `[0, 1]`
|
||||
/// within the range, per axis. Axes with zero span yield `0.0`.
|
||||
#[inline]
|
||||
pub fn inverse_lerp(&self, value: Vec3) -> Vec3 {
|
||||
let span = self.span();
|
||||
let raw = (value - self.min) / span;
|
||||
// Guard against division by zero on degenerate axes.
|
||||
Vec3::select(span.cmpeq(Vec3::ZERO), Vec3::ZERO, raw)
|
||||
}
|
||||
|
||||
/// Remaps `value` from this range into `target`, preserving its relative
|
||||
/// position per axis.
|
||||
#[inline]
|
||||
pub fn remap(&self, value: Vec3, target: &Range3) -> Vec3 {
|
||||
target.lerp(self.inverse_lerp(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EPS: f32 = 1e-4;
|
||||
|
||||
fn approx(a: Vec3, b: Vec3) -> bool {
|
||||
(a - b).length() <= EPS
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_sorts_bounds() {
|
||||
let r = Range3::new(Vec3::new(5.0, 0.0, -2.0), Vec3::new(1.0, 3.0, 4.0));
|
||||
assert_eq!(r.min, Vec3::new(1.0, 0.0, -2.0));
|
||||
assert_eq!(r.max, Vec3::new(5.0, 3.0, 4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_and_span_center() {
|
||||
let r = Range3::symmetric(Vec3::splat(2.0));
|
||||
assert_eq!(r.min, Vec3::splat(-2.0));
|
||||
assert_eq!(r.span(), Vec3::splat(4.0));
|
||||
assert_eq!(r.center(), Vec3::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_and_contains() {
|
||||
let r = Range3::new(Vec3::ZERO, Vec3::splat(10.0));
|
||||
assert_eq!(
|
||||
r.clamp(Vec3::new(-5.0, 5.0, 20.0)),
|
||||
Vec3::new(0.0, 5.0, 10.0)
|
||||
);
|
||||
assert!(r.contains(Vec3::splat(5.0)));
|
||||
assert!(!r.contains(Vec3::new(11.0, 5.0, 5.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lerp_and_inverse_round_trip() {
|
||||
let r = Range3::new(Vec3::new(2.0, 4.0, 6.0), Vec3::new(4.0, 8.0, 12.0));
|
||||
let t = Vec3::new(0.5, 0.25, 0.75);
|
||||
let v = r.lerp(t);
|
||||
assert!(approx(v, Vec3::new(3.0, 5.0, 10.5)));
|
||||
assert!(approx(r.inverse_lerp(v), t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lerp_extrapolates() {
|
||||
let r = Range3::UNIT;
|
||||
assert!(approx(r.lerp(Vec3::splat(2.0)), Vec3::splat(2.0)));
|
||||
assert!(approx(r.lerp(Vec3::splat(-1.0)), Vec3::splat(-1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_lerp_degenerate_axis_is_zero() {
|
||||
let r = Range3::new(Vec3::new(5.0, 0.0, 0.0), Vec3::new(5.0, 10.0, 10.0));
|
||||
// x axis has zero span → 0.0 rather than NaN/inf.
|
||||
let result = r.inverse_lerp(Vec3::new(5.0, 5.0, 5.0));
|
||||
assert!(result.x.is_finite());
|
||||
assert_eq!(result.x, 0.0);
|
||||
assert!((result.y - 0.5).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_between_ranges() {
|
||||
let from = Range3::new(Vec3::ZERO, Vec3::splat(100.0));
|
||||
let to = Range3::new(Vec3::ZERO, Vec3::ONE);
|
||||
assert!(approx(from.remap(Vec3::splat(50.0), &to), Vec3::splat(0.5)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! A half-line [`Ray`] with an origin and a normalized direction.
|
||||
|
||||
use glam::Vec3;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A ray: a point plus a direction, extending to infinity in one direction.
|
||||
///
|
||||
/// The direction is normalized on construction so that the parameter `t` in
|
||||
/// [`Ray::at`] is a true distance.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Ray {
|
||||
/// The starting point of the ray.
|
||||
pub origin: Vec3,
|
||||
/// The (normalized) direction of travel.
|
||||
pub direction: Vec3,
|
||||
}
|
||||
|
||||
impl Ray {
|
||||
/// Creates a ray, normalizing `direction`.
|
||||
///
|
||||
/// If `direction` is zero-length it is left as-is (degenerate ray); callers
|
||||
/// that care should validate with [`Ray::is_valid`].
|
||||
#[inline]
|
||||
pub fn new(origin: Vec3, direction: Vec3) -> Self {
|
||||
Self {
|
||||
origin,
|
||||
direction: direction.normalize_or_zero(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a ray from an origin toward a target point.
|
||||
#[inline]
|
||||
pub fn from_to(origin: Vec3, target: Vec3) -> Self {
|
||||
Self::new(origin, target - origin)
|
||||
}
|
||||
|
||||
/// Returns the point at distance `t` along the ray.
|
||||
#[inline]
|
||||
pub fn at(&self, t: f32) -> Vec3 {
|
||||
self.origin + self.direction * t
|
||||
}
|
||||
|
||||
/// Returns `true` if the direction is a valid (non-zero, finite) unit vector.
|
||||
#[inline]
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.direction.is_finite() && (self.direction.length_squared() - 1.0).abs() <= 1e-4
|
||||
}
|
||||
|
||||
/// Returns the point on the ray closest to `point`, clamped to `t >= 0`.
|
||||
#[inline]
|
||||
pub fn closest_point(&self, point: Vec3) -> Vec3 {
|
||||
let t = (point - self.origin).dot(self.direction).max(0.0);
|
||||
self.at(t)
|
||||
}
|
||||
|
||||
/// Returns the shortest distance from `point` to the ray.
|
||||
#[inline]
|
||||
pub fn distance_to_point(&self, point: Vec3) -> f32 {
|
||||
self.closest_point(point).distance(point)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EPS: f32 = 1e-4;
|
||||
|
||||
#[test]
|
||||
fn new_normalizes_direction() {
|
||||
let ray = Ray::new(Vec3::ZERO, Vec3::new(0.0, 5.0, 0.0));
|
||||
assert!((ray.direction.length() - 1.0).abs() <= EPS);
|
||||
assert!(ray.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_direction_is_invalid() {
|
||||
let ray = Ray::new(Vec3::ZERO, Vec3::ZERO);
|
||||
assert!(!ray.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_returns_distance_point() {
|
||||
let ray = Ray::new(Vec3::new(1.0, 0.0, 0.0), Vec3::X);
|
||||
assert!((ray.at(4.0) - Vec3::new(5.0, 0.0, 0.0)).length() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_to_points_at_target() {
|
||||
let ray = Ray::from_to(Vec3::ZERO, Vec3::new(0.0, 0.0, 10.0));
|
||||
assert!((ray.direction - Vec3::Z).length() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closest_point_and_distance() {
|
||||
let ray = Ray::new(Vec3::ZERO, Vec3::X);
|
||||
// Point off to the side.
|
||||
let p = Vec3::new(3.0, 4.0, 0.0);
|
||||
assert!((ray.closest_point(p) - Vec3::new(3.0, 0.0, 0.0)).length() <= EPS);
|
||||
assert!((ray.distance_to_point(p) - 4.0).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closest_point_clamps_behind_origin() {
|
||||
let ray = Ray::new(Vec3::ZERO, Vec3::X);
|
||||
let p = Vec3::new(-5.0, 2.0, 0.0);
|
||||
// Behind the origin → clamps to the origin.
|
||||
assert!((ray.closest_point(p) - Vec3::ZERO).length() <= EPS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! A 2D axis-aligned [`Rect`]angle, used for UI, viewports, and texture regions.
|
||||
|
||||
use glam::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An axis-aligned rectangle defined by its `min` (top-left in a y-down UI
|
||||
/// space, or bottom-left in y-up) and `max` corners.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Rect {
|
||||
/// Minimum corner (smallest x and y).
|
||||
pub min: Vec2,
|
||||
/// Maximum corner (largest x and y).
|
||||
pub max: Vec2,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
/// A zero-area rectangle at the origin.
|
||||
pub const ZERO: Self = Self {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Creates a rectangle from two corners, sorting so `min <= max`.
|
||||
#[inline]
|
||||
pub fn new(a: Vec2, b: Vec2) -> Self {
|
||||
Self {
|
||||
min: a.min(b),
|
||||
max: a.max(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a rectangle from a `min` corner and a size.
|
||||
#[inline]
|
||||
pub fn from_min_size(min: Vec2, size: Vec2) -> Self {
|
||||
Self {
|
||||
min,
|
||||
max: min + size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a rectangle from a center point and full size.
|
||||
#[inline]
|
||||
pub fn from_center_size(center: Vec2, size: Vec2) -> Self {
|
||||
let half = size * 0.5;
|
||||
Self {
|
||||
min: center - half,
|
||||
max: center + half,
|
||||
}
|
||||
}
|
||||
|
||||
/// The width and height as a vector.
|
||||
#[inline]
|
||||
pub fn size(&self) -> Vec2 {
|
||||
(self.max - self.min).max(Vec2::ZERO)
|
||||
}
|
||||
|
||||
/// The width (x extent).
|
||||
#[inline]
|
||||
pub fn width(&self) -> f32 {
|
||||
self.size().x
|
||||
}
|
||||
|
||||
/// The height (y extent).
|
||||
#[inline]
|
||||
pub fn height(&self) -> f32 {
|
||||
self.size().y
|
||||
}
|
||||
|
||||
/// The center point.
|
||||
#[inline]
|
||||
pub fn center(&self) -> Vec2 {
|
||||
(self.min + self.max) * 0.5
|
||||
}
|
||||
|
||||
/// The area (`width * height`).
|
||||
#[inline]
|
||||
pub fn area(&self) -> f32 {
|
||||
let s = self.size();
|
||||
s.x * s.y
|
||||
}
|
||||
|
||||
/// Returns `true` if the rectangle has zero (or inverted) area.
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.min.x >= self.max.x || self.min.y >= self.max.y
|
||||
}
|
||||
|
||||
/// Returns `true` if `point` is inside or on the boundary.
|
||||
#[inline]
|
||||
pub fn contains_point(&self, point: Vec2) -> bool {
|
||||
point.cmpge(self.min).all() && point.cmple(self.max).all()
|
||||
}
|
||||
|
||||
/// Returns `true` if the two rectangles overlap (touching counts).
|
||||
#[inline]
|
||||
pub fn intersects(&self, other: &Rect) -> bool {
|
||||
self.min.cmple(other.max).all() && self.max.cmpge(other.min).all()
|
||||
}
|
||||
|
||||
/// Returns the overlapping region, or [`Rect::ZERO`] if disjoint.
|
||||
#[inline]
|
||||
pub fn intersection(&self, other: &Rect) -> Rect {
|
||||
let min = self.min.max(other.min);
|
||||
let max = self.max.min(other.max);
|
||||
if min.x > max.x || min.y > max.y {
|
||||
Rect::ZERO
|
||||
} else {
|
||||
Rect { min, max }
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the smallest rectangle containing both.
|
||||
#[inline]
|
||||
pub fn union(&self, other: &Rect) -> Rect {
|
||||
Rect {
|
||||
min: self.min.min(other.min),
|
||||
max: self.max.max(other.max),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the point inside the rectangle closest to `point`.
|
||||
#[inline]
|
||||
pub fn closest_point(&self, point: Vec2) -> Vec2 {
|
||||
point.clamp(self.min, self.max)
|
||||
}
|
||||
|
||||
/// Returns a copy expanded outward by `amount` on every side (negative
|
||||
/// shrinks).
|
||||
#[inline]
|
||||
pub fn expanded(&self, amount: f32) -> Rect {
|
||||
Rect {
|
||||
min: self.min - Vec2::splat(amount),
|
||||
max: self.max + Vec2::splat(amount),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_sorts_corners() {
|
||||
let r = Rect::new(Vec2::new(4.0, 1.0), Vec2::new(0.0, 5.0));
|
||||
assert_eq!(r.min, Vec2::new(0.0, 1.0));
|
||||
assert_eq!(r.max, Vec2::new(4.0, 5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_size_and_center_size() {
|
||||
let r = Rect::from_min_size(Vec2::new(1.0, 2.0), Vec2::new(4.0, 6.0));
|
||||
assert_eq!(r.size(), Vec2::new(4.0, 6.0));
|
||||
assert_eq!(r.center(), Vec2::new(3.0, 5.0));
|
||||
|
||||
let c = Rect::from_center_size(Vec2::ZERO, Vec2::new(2.0, 2.0));
|
||||
assert_eq!(c.min, Vec2::new(-1.0, -1.0));
|
||||
assert_eq!(c.max, Vec2::new(1.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_and_area() {
|
||||
let r = Rect::from_min_size(Vec2::ZERO, Vec2::new(3.0, 4.0));
|
||||
assert_eq!(r.width(), 3.0);
|
||||
assert_eq!(r.height(), 4.0);
|
||||
assert_eq!(r.area(), 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_detection() {
|
||||
assert!(Rect::ZERO.is_empty());
|
||||
assert!(Rect::new(Vec2::ZERO, Vec2::new(0.0, 5.0)).is_empty());
|
||||
assert!(!Rect::from_min_size(Vec2::ZERO, Vec2::ONE).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_and_closest() {
|
||||
let r = Rect::from_min_size(Vec2::ZERO, Vec2::splat(2.0));
|
||||
assert!(r.contains_point(Vec2::ONE));
|
||||
assert!(!r.contains_point(Vec2::new(3.0, 1.0)));
|
||||
assert_eq!(r.closest_point(Vec2::new(5.0, -1.0)), Vec2::new(2.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersection_and_union() {
|
||||
let a = Rect::from_min_size(Vec2::ZERO, Vec2::splat(2.0));
|
||||
let b = Rect::from_min_size(Vec2::ONE, Vec2::splat(2.0));
|
||||
assert!(a.intersects(&b));
|
||||
assert_eq!(a.intersection(&b), Rect::new(Vec2::ONE, Vec2::splat(2.0)));
|
||||
assert_eq!(a.union(&b), Rect::new(Vec2::ZERO, Vec2::splat(3.0)));
|
||||
|
||||
let c = Rect::from_min_size(Vec2::splat(10.0), Vec2::ONE);
|
||||
assert!(!a.intersects(&c));
|
||||
assert_eq!(a.intersection(&c), Rect::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_grows_and_shrinks() {
|
||||
let r = Rect::from_min_size(Vec2::ZERO, Vec2::splat(4.0));
|
||||
assert_eq!(
|
||||
r.expanded(1.0),
|
||||
Rect::new(Vec2::splat(-1.0), Vec2::splat(5.0))
|
||||
);
|
||||
assert_eq!(r.expanded(-1.0), Rect::new(Vec2::ONE, Vec2::splat(3.0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Affine [`Transform`]: translation, rotation, and (non-uniform) scale.
|
||||
//!
|
||||
//! A `Transform` is the canonical way to place an object in space. It composes
|
||||
//! as `parent * child`, matching the convention used by the scene graph in
|
||||
//! later stages. Internally it is stored in decomposed (TRS) form so that
|
||||
//! individual components stay editable without matrix round-trips.
|
||||
|
||||
use glam::{Affine3A, Mat4, Quat, Vec3};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A 3D affine transform stored as translation, rotation, and scale.
|
||||
///
|
||||
/// The effective matrix is `T * R * S` (scale applied first, then rotation,
|
||||
/// then translation), which is the standard convention for scene hierarchies.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, crate::reflect::Reflect)]
|
||||
pub struct Transform {
|
||||
/// World/local-space position.
|
||||
pub translation: Vec3,
|
||||
/// Orientation as a unit quaternion.
|
||||
pub rotation: Quat,
|
||||
/// Per-axis scale. May be non-uniform; zero or negative components are
|
||||
/// permitted but make the transform non-invertible / mirror-inducing.
|
||||
pub scale: Vec3,
|
||||
}
|
||||
|
||||
impl Default for Transform {
|
||||
/// The identity transform: no translation, no rotation, unit scale.
|
||||
fn default() -> Self {
|
||||
Self::IDENTITY
|
||||
}
|
||||
}
|
||||
|
||||
impl Transform {
|
||||
/// The identity transform.
|
||||
pub const IDENTITY: Self = Self {
|
||||
translation: Vec3::ZERO,
|
||||
rotation: Quat::IDENTITY,
|
||||
scale: Vec3::ONE,
|
||||
};
|
||||
|
||||
/// Creates a transform from a translation only (identity rotation, unit scale).
|
||||
#[inline]
|
||||
pub const fn from_translation(translation: Vec3) -> Self {
|
||||
Self {
|
||||
translation,
|
||||
rotation: Quat::IDENTITY,
|
||||
scale: Vec3::ONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a transform from a rotation only.
|
||||
#[inline]
|
||||
pub const fn from_rotation(rotation: Quat) -> Self {
|
||||
Self {
|
||||
translation: Vec3::ZERO,
|
||||
rotation,
|
||||
scale: Vec3::ONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a transform from a uniform scale.
|
||||
#[inline]
|
||||
pub const fn from_scale(scale: Vec3) -> Self {
|
||||
Self {
|
||||
translation: Vec3::ZERO,
|
||||
rotation: Quat::IDENTITY,
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a transform from all three components.
|
||||
#[inline]
|
||||
pub const fn from_trs(translation: Vec3, rotation: Quat, scale: Vec3) -> Self {
|
||||
Self {
|
||||
translation,
|
||||
rotation,
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decomposes a 4x4 matrix back into a TRS transform.
|
||||
///
|
||||
/// Negative determinants (mirrored matrices) are handled by `glam`'s
|
||||
/// decomposition, which folds the sign into the scale.
|
||||
#[inline]
|
||||
pub fn from_matrix(matrix: Mat4) -> Self {
|
||||
let (scale, rotation, translation) = matrix.to_scale_rotation_translation();
|
||||
Self {
|
||||
translation,
|
||||
rotation,
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the equivalent 4x4 homogeneous matrix.
|
||||
#[inline]
|
||||
pub fn to_matrix(&self) -> Mat4 {
|
||||
Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
|
||||
}
|
||||
|
||||
/// Returns the equivalent [`Affine3A`], which is cheaper to compose than a
|
||||
/// full `Mat4` and is what the renderer/scene graph use internally.
|
||||
#[inline]
|
||||
pub fn to_affine(&self) -> Affine3A {
|
||||
Affine3A::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
|
||||
}
|
||||
|
||||
/// Composes two transforms: `self * rhs` applies `rhs` first, then `self`.
|
||||
///
|
||||
/// This is exact for the translation and rotation channels. When either
|
||||
/// operand carries non-uniform scale combined with rotation, the true
|
||||
/// product is no longer a pure TRS transform; in that case the result is
|
||||
/// re-decomposed from the composed matrix so the returned `Transform`
|
||||
/// remains the closest TRS approximation. For uniform scale (the common
|
||||
/// scene-graph case) the composition is exact.
|
||||
#[inline]
|
||||
pub fn mul_transform(&self, rhs: &Transform) -> Transform {
|
||||
// Fast path: uniform scale composes exactly in TRS form.
|
||||
if is_uniform(self.scale) {
|
||||
let scale = self.scale * rhs.scale;
|
||||
let rotation = self.rotation * rhs.rotation;
|
||||
let translation = self.translation + self.rotation * (self.scale * rhs.translation);
|
||||
Transform {
|
||||
translation,
|
||||
rotation,
|
||||
scale,
|
||||
}
|
||||
} else {
|
||||
Transform::from_matrix(self.to_matrix() * rhs.to_matrix())
|
||||
}
|
||||
}
|
||||
|
||||
/// Transforms a point (affected by translation, rotation, and scale).
|
||||
#[inline]
|
||||
pub fn transform_point(&self, point: Vec3) -> Vec3 {
|
||||
self.translation + self.rotation * (self.scale * point)
|
||||
}
|
||||
|
||||
/// Transforms a direction vector (rotation and scale only, no translation).
|
||||
#[inline]
|
||||
pub fn transform_vector(&self, vector: Vec3) -> Vec3 {
|
||||
self.rotation * (self.scale * vector)
|
||||
}
|
||||
|
||||
/// Returns the inverse transform, such that
|
||||
/// `t.mul_transform(&t.inverse())` is approximately the identity.
|
||||
///
|
||||
/// # Panics
|
||||
/// Does not panic, but if any scale component is zero the inverse scale
|
||||
/// will contain infinities — the transform is not invertible in that case.
|
||||
#[inline]
|
||||
pub fn inverse(&self) -> Transform {
|
||||
let inv_scale = Vec3::ONE / self.scale;
|
||||
let inv_rotation = self.rotation.inverse();
|
||||
let inv_translation = inv_rotation * (inv_scale * -self.translation);
|
||||
Transform {
|
||||
translation: inv_translation,
|
||||
rotation: inv_rotation,
|
||||
scale: inv_scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// The local forward direction (`-Z`) rotated into this transform's space.
|
||||
#[inline]
|
||||
pub fn forward(&self) -> Vec3 {
|
||||
self.rotation * Vec3::NEG_Z
|
||||
}
|
||||
|
||||
/// The local up direction (`+Y`) rotated into this transform's space.
|
||||
#[inline]
|
||||
pub fn up(&self) -> Vec3 {
|
||||
self.rotation * Vec3::Y
|
||||
}
|
||||
|
||||
/// The local right direction (`+X`) rotated into this transform's space.
|
||||
#[inline]
|
||||
pub fn right(&self) -> Vec3 {
|
||||
self.rotation * Vec3::X
|
||||
}
|
||||
|
||||
/// Builds a transform positioned at `eye` looking toward `target`.
|
||||
///
|
||||
/// `up` is the reference up vector. Returns the identity rotation if `eye`
|
||||
/// and `target` coincide.
|
||||
pub fn looking_at(eye: Vec3, target: Vec3, up: Vec3) -> Transform {
|
||||
let forward = target - eye;
|
||||
let rotation = if forward.length_squared() <= f32::EPSILON {
|
||||
Quat::IDENTITY
|
||||
} else {
|
||||
// glam's look_to is right-handed with -Z forward; invert the view
|
||||
// rotation to get an object-space orientation.
|
||||
Quat::from_mat4(&Mat4::look_to_rh(eye, forward.normalize(), up)).inverse()
|
||||
};
|
||||
Transform {
|
||||
translation: eye,
|
||||
rotation,
|
||||
scale: Vec3::ONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if every component is finite (no NaN/inf).
|
||||
#[inline]
|
||||
pub fn is_finite(&self) -> bool {
|
||||
self.translation.is_finite() && self.rotation.is_finite() && self.scale.is_finite()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if all three components of `scale` are equal.
|
||||
#[inline]
|
||||
fn is_uniform(scale: Vec3) -> bool {
|
||||
(scale.x - scale.y).abs() <= f32::EPSILON && (scale.y - scale.z).abs() <= f32::EPSILON
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::f32::consts::{FRAC_PI_2, PI};
|
||||
|
||||
const EPS: f32 = 1e-4;
|
||||
|
||||
fn approx_vec(a: Vec3, b: Vec3) -> bool {
|
||||
(a - b).length() <= EPS
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_is_default() {
|
||||
assert_eq!(Transform::default(), Transform::IDENTITY);
|
||||
let p = Vec3::new(1.0, 2.0, 3.0);
|
||||
assert_eq!(Transform::IDENTITY.transform_point(p), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translation_moves_points() {
|
||||
let t = Transform::from_translation(Vec3::new(1.0, 2.0, 3.0));
|
||||
assert!(approx_vec(
|
||||
t.transform_point(Vec3::ZERO),
|
||||
Vec3::new(1.0, 2.0, 3.0)
|
||||
));
|
||||
// Vectors ignore translation.
|
||||
assert!(approx_vec(t.transform_vector(Vec3::X), Vec3::X));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_rotates_points() {
|
||||
let t = Transform::from_rotation(Quat::from_rotation_z(FRAC_PI_2));
|
||||
assert!(approx_vec(t.transform_point(Vec3::X), Vec3::Y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_scales_points() {
|
||||
let t = Transform::from_scale(Vec3::new(2.0, 3.0, 4.0));
|
||||
assert!(approx_vec(
|
||||
t.transform_point(Vec3::ONE),
|
||||
Vec3::new(2.0, 3.0, 4.0)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrix_round_trip() {
|
||||
let t = Transform::from_trs(
|
||||
Vec3::new(5.0, -2.0, 1.0),
|
||||
Quat::from_euler(glam::EulerRot::XYZ, 0.3, -0.7, 1.1),
|
||||
Vec3::new(2.0, 2.0, 2.0),
|
||||
);
|
||||
let back = Transform::from_matrix(t.to_matrix());
|
||||
assert!(approx_vec(t.translation, back.translation));
|
||||
assert!(approx_vec(t.scale, back.scale));
|
||||
// Quaternions q and -q represent the same rotation.
|
||||
let dot = t.rotation.dot(back.rotation).abs();
|
||||
assert!((dot - 1.0).abs() <= EPS, "rotation mismatch: dot={dot}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_cancels() {
|
||||
let t = Transform::from_trs(
|
||||
Vec3::new(3.0, 4.0, 5.0),
|
||||
Quat::from_rotation_y(0.9),
|
||||
Vec3::splat(2.0),
|
||||
);
|
||||
let id = t.mul_transform(&t.inverse());
|
||||
assert!(approx_vec(id.translation, Vec3::ZERO));
|
||||
assert!(approx_vec(id.scale, Vec3::ONE));
|
||||
assert!(approx_vec(
|
||||
id.transform_point(Vec3::new(7.0, 8.0, 9.0)),
|
||||
Vec3::new(7.0, 8.0, 9.0)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_matches_matrix() {
|
||||
let a = Transform::from_trs(
|
||||
Vec3::new(1.0, 0.0, -2.0),
|
||||
Quat::from_rotation_x(0.4),
|
||||
Vec3::splat(1.5),
|
||||
);
|
||||
let b = Transform::from_trs(
|
||||
Vec3::new(-3.0, 2.0, 1.0),
|
||||
Quat::from_rotation_z(-0.8),
|
||||
Vec3::splat(0.5),
|
||||
);
|
||||
let composed = a.mul_transform(&b);
|
||||
let p = Vec3::new(2.0, -1.0, 3.0);
|
||||
let via_transform = composed.transform_point(p);
|
||||
let via_matrix = (a.to_matrix() * b.to_matrix()).transform_point3(p);
|
||||
assert!(approx_vec(via_transform, via_matrix));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonuniform_composition_falls_back_to_matrix() {
|
||||
let a = Transform::from_trs(
|
||||
Vec3::new(0.0, 1.0, 0.0),
|
||||
Quat::from_rotation_z(FRAC_PI_2),
|
||||
Vec3::new(2.0, 1.0, 1.0),
|
||||
);
|
||||
let b = Transform::from_trs(
|
||||
Vec3::new(1.0, 0.0, 0.0),
|
||||
Quat::IDENTITY,
|
||||
Vec3::new(1.0, 3.0, 1.0),
|
||||
);
|
||||
let composed = a.mul_transform(&b);
|
||||
let p = Vec3::new(1.0, 2.0, -1.0);
|
||||
let via_transform = composed.transform_point(p);
|
||||
let via_matrix = (a.to_matrix() * b.to_matrix()).transform_point3(p);
|
||||
// Re-decomposition keeps this close even with non-uniform scale.
|
||||
assert!(
|
||||
approx_vec(via_transform, via_matrix),
|
||||
"{via_transform} vs {via_matrix}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_scale_is_non_invertible() {
|
||||
let t = Transform::from_scale(Vec3::new(0.0, 1.0, 1.0));
|
||||
let inv = t.inverse();
|
||||
assert!(!inv.scale.x.is_finite());
|
||||
assert!(t.is_finite()); // the forward transform itself is still finite
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gimbal_lock_path_stays_stable() {
|
||||
// Pitch to +90° (a classic gimbal-lock orientation) and confirm the
|
||||
// basis vectors remain orthonormal after round-tripping through a matrix.
|
||||
let t =
|
||||
Transform::from_rotation(Quat::from_euler(glam::EulerRot::YXZ, 0.0, FRAC_PI_2, 0.0));
|
||||
let back = Transform::from_matrix(t.to_matrix());
|
||||
assert!(approx_vec(back.forward(), t.forward()));
|
||||
assert!(approx_vec(back.up(), t.up()));
|
||||
// Orthonormality.
|
||||
assert!(t.forward().dot(t.up()).abs() <= EPS);
|
||||
assert!(t.right().dot(t.up()).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looking_at_faces_target() {
|
||||
let t = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||
// Forward should point toward the target (-Z world direction).
|
||||
assert!(approx_vec(t.forward(), Vec3::NEG_Z));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looking_at_degenerate_is_identity_rotation() {
|
||||
let t = Transform::looking_at(Vec3::ONE, Vec3::ONE, Vec3::Y);
|
||||
assert_eq!(t.rotation, Quat::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_vectors_for_half_turn() {
|
||||
let t = Transform::from_rotation(Quat::from_rotation_y(PI));
|
||||
assert!(approx_vec(t.forward(), Vec3::Z));
|
||||
assert!(approx_vec(t.right(), Vec3::NEG_X));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Prefabs — named templates that spawn an entity already carrying a set of
|
||||
//! components.
|
||||
//!
|
||||
//! The engine deliberately has **no parallel "object type" system**: an entity
|
||||
//! *is* its set of components. A [`Prefab`] is therefore nothing more than a
|
||||
//! named bundle of **(component name, value)** specs, applied on spawn through
|
||||
//! the [`TypeRegistry`]. "Spawn a Cube" means "spawn an entity, then set its
|
||||
//! `MeshRenderer` to a cube" — the same name-keyed path the editor and scripts
|
||||
//! already use, so prefabs are pure data (serializable, dual-editable) rather
|
||||
//! than code.
|
||||
//!
|
||||
//! This is what makes the editor's add-menu **data-driven**: the menu lists the
|
||||
//! prefabs in a [`PrefabRegistry`] instead of hard-coding one button per type.
|
||||
//!
|
||||
//! ```
|
||||
//! use oxide_engine::prelude::*;
|
||||
//! use oxide_engine::prefab::{ComponentSpec, Prefab, PrefabRegistry};
|
||||
//! use oxide_engine::reflect::TypeRegistry;
|
||||
//!
|
||||
//! // A registry that knows how to round-trip MeshRenderer by name.
|
||||
//! let mut types = TypeRegistry::new();
|
||||
//! types.register_reflected::<MeshRenderer>("MeshRenderer");
|
||||
//!
|
||||
//! // A "Cube" prefab: an entity carrying a default MeshRenderer (shape = Cube).
|
||||
//! let mut prefabs = PrefabRegistry::new();
|
||||
//! prefabs.register(
|
||||
//! Prefab::new("Cube")
|
||||
//! .with(ComponentSpec::of("MeshRenderer", &MeshRenderer::default()).unwrap()),
|
||||
//! );
|
||||
//!
|
||||
//! let mut scene = Scene::new();
|
||||
//! let cube = prefabs.spawn("Cube", &mut scene, &types).unwrap();
|
||||
//! assert!(types.has(scene.world(), cube, "MeshRenderer").unwrap());
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use hecs::Entity;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::math::Transform;
|
||||
use crate::reflect::TypeRegistry;
|
||||
use crate::scene::Scene;
|
||||
|
||||
/// One component a prefab attaches: a registered type **name** plus its value
|
||||
/// serialized as **RON** — the same representation [`TypeRegistry::set_ron`]
|
||||
/// consumes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ComponentSpec {
|
||||
/// The component's registered name in the [`TypeRegistry`].
|
||||
pub type_name: String,
|
||||
/// The component value as RON.
|
||||
pub ron: String,
|
||||
}
|
||||
|
||||
impl ComponentSpec {
|
||||
/// A spec from a name and an already-serialized RON string.
|
||||
pub fn new(type_name: impl Into<String>, ron: impl Into<String>) -> Self {
|
||||
Self {
|
||||
type_name: type_name.into(),
|
||||
ron: ron.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A spec built by serializing a concrete component `value`. Returns `None`
|
||||
/// if it cannot be serialized to RON.
|
||||
pub fn of<T: Serialize>(type_name: impl Into<String>, value: &T) -> Option<Self> {
|
||||
ron::to_string(value)
|
||||
.ok()
|
||||
.map(|ron| Self::new(type_name, ron))
|
||||
}
|
||||
}
|
||||
|
||||
/// A named spawn template: a node name plus the components to attach beyond the
|
||||
/// node-baked ones.
|
||||
///
|
||||
/// Every spawned entity already carries `Node`, `Transform`, and `Layer`
|
||||
/// (auto-attached by [`Scene::spawn`]); a prefab's [`components`](Self::components)
|
||||
/// are layered on top. A spec named `"Transform"` overrides the identity
|
||||
/// transform `spawn` starts with, so a prefab can place itself.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Prefab {
|
||||
/// The name given to the spawned node (also the registry key).
|
||||
pub name: String,
|
||||
/// Components attached on spawn, applied in order.
|
||||
pub components: Vec<ComponentSpec>,
|
||||
}
|
||||
|
||||
impl Prefab {
|
||||
/// An empty prefab (spawns a bare node with just the node-baked components).
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
components: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a component spec (builder style).
|
||||
pub fn with(mut self, spec: ComponentSpec) -> Self {
|
||||
self.components.push(spec);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A registry of prefabs keyed by name — the data-driven source for the
|
||||
/// editor's "add an entity that already carries these components" menu.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PrefabRegistry {
|
||||
prefabs: BTreeMap<String, Prefab>,
|
||||
}
|
||||
|
||||
impl PrefabRegistry {
|
||||
/// An empty registry.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registers `prefab` under its [`name`](Prefab::name). Re-registering the
|
||||
/// same name replaces the entry.
|
||||
pub fn register(&mut self, prefab: Prefab) {
|
||||
self.prefabs.insert(prefab.name.clone(), prefab);
|
||||
}
|
||||
|
||||
/// The prefab registered under `name`, if any.
|
||||
pub fn get(&self, name: &str) -> Option<&Prefab> {
|
||||
self.prefabs.get(name)
|
||||
}
|
||||
|
||||
/// Whether a prefab is registered under `name`.
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.prefabs.contains_key(name)
|
||||
}
|
||||
|
||||
/// The registered prefab names, sorted — what an add-menu lists.
|
||||
pub fn names(&self) -> impl Iterator<Item = &str> + '_ {
|
||||
self.prefabs.keys().map(String::as_str)
|
||||
}
|
||||
|
||||
/// The number of registered prefabs.
|
||||
pub fn len(&self) -> usize {
|
||||
self.prefabs.len()
|
||||
}
|
||||
|
||||
/// Whether no prefabs are registered.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.prefabs.is_empty()
|
||||
}
|
||||
|
||||
/// Spawns the named prefab as a **root** entity, applying its component
|
||||
/// specs through `registry`. Returns the new entity, or `None` if `name`
|
||||
/// isn't registered.
|
||||
///
|
||||
/// Application is best-effort: a spec whose type isn't registered or whose
|
||||
/// RON doesn't parse is skipped (the entity is still created with whatever
|
||||
/// applied). Use [`unknown_specs`](Self::unknown_specs) to validate a prefab
|
||||
/// against a registry up front.
|
||||
pub fn spawn(&self, name: &str, scene: &mut Scene, registry: &TypeRegistry) -> Option<Entity> {
|
||||
let prefab = self.prefabs.get(name)?;
|
||||
let entity = scene.spawn(prefab.name.clone(), Transform::IDENTITY);
|
||||
apply(prefab, entity, scene, registry);
|
||||
Some(entity)
|
||||
}
|
||||
|
||||
/// Like [`spawn`](Self::spawn) but parents the new entity under `parent`.
|
||||
pub fn spawn_child(
|
||||
&self,
|
||||
name: &str,
|
||||
parent: Entity,
|
||||
scene: &mut Scene,
|
||||
registry: &TypeRegistry,
|
||||
) -> Option<Entity> {
|
||||
let prefab = self.prefabs.get(name)?;
|
||||
let entity = scene.spawn_child(parent, prefab.name.clone(), Transform::IDENTITY);
|
||||
apply(prefab, entity, scene, registry);
|
||||
Some(entity)
|
||||
}
|
||||
|
||||
/// The type names a prefab references that `registry` doesn't know — empty
|
||||
/// when the prefab will spawn fully. Handy for surfacing authoring typos.
|
||||
pub fn unknown_specs(&self, name: &str, registry: &TypeRegistry) -> Vec<String> {
|
||||
self.prefabs
|
||||
.get(name)
|
||||
.map(|p| {
|
||||
p.components
|
||||
.iter()
|
||||
.filter(|s| !registry.is_registered(&s.type_name))
|
||||
.map(|s| s.type_name.clone())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a prefab's component specs onto an already-spawned `entity`.
|
||||
fn apply(prefab: &Prefab, entity: Entity, scene: &mut Scene, registry: &TypeRegistry) {
|
||||
for spec in &prefab.components {
|
||||
// Best-effort: an unknown type or malformed RON simply doesn't apply,
|
||||
// leaving the rest of the prefab intact.
|
||||
let _ = registry.set_ron(scene.world_mut(), entity, &spec.type_name, &spec.ron);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::render::{MeshRenderer, PrimitiveShape};
|
||||
use crate::scene::Node;
|
||||
|
||||
fn types() -> TypeRegistry {
|
||||
let mut r = TypeRegistry::new();
|
||||
r.register_reflected::<Transform>("Transform");
|
||||
r.register_reflected::<MeshRenderer>("MeshRenderer");
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_lists_names_sorted_and_looks_up() {
|
||||
let mut prefabs = PrefabRegistry::new();
|
||||
prefabs.register(Prefab::new("Sphere"));
|
||||
prefabs.register(Prefab::new("Cube"));
|
||||
assert_eq!(prefabs.names().collect::<Vec<_>>(), vec!["Cube", "Sphere"]);
|
||||
assert!(prefabs.contains("Cube"));
|
||||
assert!(prefabs.get("Cube").is_some());
|
||||
assert_eq!(prefabs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_attaches_specced_components() {
|
||||
let types = types();
|
||||
let mut prefabs = PrefabRegistry::new();
|
||||
let mesh = MeshRenderer {
|
||||
shape: PrimitiveShape::Sphere,
|
||||
..MeshRenderer::default()
|
||||
};
|
||||
prefabs
|
||||
.register(Prefab::new("Ball").with(ComponentSpec::of("MeshRenderer", &mesh).unwrap()));
|
||||
|
||||
let mut scene = Scene::new();
|
||||
let e = prefabs.spawn("Ball", &mut scene, &types).unwrap();
|
||||
|
||||
// Node name comes from the prefab; the spec'd component is attached.
|
||||
assert_eq!(scene.world().get::<&Node>(e).unwrap().name, "Ball");
|
||||
let got = scene.world().get::<&MeshRenderer>(e).unwrap();
|
||||
assert_eq!(got.shape, PrimitiveShape::Sphere);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_child_parents_under_the_target() {
|
||||
let types = types();
|
||||
let mut prefabs = PrefabRegistry::new();
|
||||
prefabs.register(Prefab::new("Child"));
|
||||
|
||||
let mut scene = Scene::new();
|
||||
let parent = scene.spawn("parent", Transform::IDENTITY);
|
||||
let child = prefabs
|
||||
.spawn_child("Child", parent, &mut scene, &types)
|
||||
.unwrap();
|
||||
assert_eq!(scene.parent(child), Some(parent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_spec_overrides_the_identity_spawn() {
|
||||
let types = types();
|
||||
let mut prefabs = PrefabRegistry::new();
|
||||
let placed = Transform::from_translation(crate::math::Vec3::new(1.0, 2.0, 3.0));
|
||||
prefabs
|
||||
.register(Prefab::new("Placed").with(ComponentSpec::of("Transform", &placed).unwrap()));
|
||||
|
||||
let mut scene = Scene::new();
|
||||
let e = prefabs.spawn("Placed", &mut scene, &types).unwrap();
|
||||
let t = scene.local_transform(e).unwrap();
|
||||
assert!((t.translation - crate::math::Vec3::new(1.0, 2.0, 3.0)).length() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_prefab_name_spawns_nothing() {
|
||||
let types = types();
|
||||
let prefabs = PrefabRegistry::new();
|
||||
let mut scene = Scene::new();
|
||||
assert!(prefabs.spawn("Nope", &mut scene, &types).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_specs_are_reported_and_skipped() {
|
||||
let types = types(); // knows Transform + MeshRenderer
|
||||
let mut prefabs = PrefabRegistry::new();
|
||||
prefabs.register(
|
||||
Prefab::new("Mixed")
|
||||
.with(ComponentSpec::of("MeshRenderer", &MeshRenderer::default()).unwrap())
|
||||
.with(ComponentSpec::new("Ghost", "()")),
|
||||
);
|
||||
assert_eq!(prefabs.unknown_specs("Mixed", &types), vec!["Ghost"]);
|
||||
|
||||
// Spawn still succeeds; the known component applies, the ghost is skipped.
|
||||
let mut scene = Scene::new();
|
||||
let e = prefabs.spawn("Mixed", &mut scene, &types).unwrap();
|
||||
assert!(types.has(scene.world(), e, "MeshRenderer").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefab_round_trips_through_ron() {
|
||||
let mut prefabs = PrefabRegistry::new();
|
||||
prefabs.register(
|
||||
Prefab::new("Cube")
|
||||
.with(ComponentSpec::of("MeshRenderer", &MeshRenderer::default()).unwrap()),
|
||||
);
|
||||
let ron = ron::to_string(&prefabs).unwrap();
|
||||
let back: PrefabRegistry = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(prefabs, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Projects: the on-disk unit a game is authored as.
|
||||
//!
|
||||
//! A **project** is a root directory containing a project file plus a defined
|
||||
//! folder layout (scenes, assets, scripts). The project file (RON) records the
|
||||
//! project name, the engine version it was made with, the set of enabled
|
||||
//! [modules](crate::app::Module), and per-project settings. The format lives in
|
||||
//! the engine — not the editor — because the exported runtime and the Stage-16
|
||||
//! packer read it too; the editor adds the create/open/save UI on top.
|
||||
//!
|
||||
//! Per-project settings are stored as **opaque per-section RON blobs**
|
||||
//! (`section name → RON`), so this module stays independent of the typed
|
||||
//! settings framework: that framework serializes its typed sections to these
|
||||
//! strings and back.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The project file's name within the project root.
|
||||
pub const PROJECT_FILE_NAME: &str = "project.oxide";
|
||||
|
||||
/// The subdirectory holding scene files.
|
||||
pub const SCENES_DIR: &str = "scenes";
|
||||
/// The subdirectory holding asset files (meshes, textures, audio, …).
|
||||
pub const ASSETS_DIR: &str = "assets";
|
||||
/// The subdirectory holding game scripts.
|
||||
pub const SCRIPTS_DIR: &str = "scripts";
|
||||
|
||||
/// Errors from project operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProjectError {
|
||||
/// A project file already exists where a new project was to be created.
|
||||
#[error("a project already exists at {0}")]
|
||||
AlreadyExists(PathBuf),
|
||||
|
||||
/// No project file was found at the given location.
|
||||
#[error("no project file found at {0}")]
|
||||
NotFound(PathBuf),
|
||||
|
||||
/// Filesystem I/O failed.
|
||||
#[error("project i/o error at {path}: {source}")]
|
||||
Io {
|
||||
/// The path involved.
|
||||
path: PathBuf,
|
||||
/// The underlying error.
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
/// The project file could not be parsed.
|
||||
#[error("malformed project file at {path}: {message}")]
|
||||
Parse {
|
||||
/// The project file path.
|
||||
path: PathBuf,
|
||||
/// The parser message.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// The project file could not be serialized.
|
||||
#[error("failed to serialize project: {0}")]
|
||||
Serialize(String),
|
||||
}
|
||||
|
||||
/// The serialized contents of a project file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProjectMeta {
|
||||
/// Human-readable project name.
|
||||
pub name: String,
|
||||
/// The engine version this project was last saved with.
|
||||
pub engine_version: String,
|
||||
/// Names of the modules enabled for this project.
|
||||
pub enabled_modules: Vec<String>,
|
||||
/// Per-project settings as opaque RON blobs, keyed by section name. The
|
||||
/// typed settings framework round-trips its sections through here.
|
||||
pub settings: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ProjectMeta {
|
||||
fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
engine_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
enabled_modules: Vec::new(),
|
||||
settings: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An open project: its root directory plus the loaded [`ProjectMeta`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Project {
|
||||
root: PathBuf,
|
||||
meta: ProjectMeta,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Creates a new project rooted at `root` (created if missing), scaffolding
|
||||
/// the `scenes`/`assets`/`scripts` folders and writing the project file.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AlreadyExists`](ProjectError::AlreadyExists) if a project file is
|
||||
/// already present, or [`Io`](ProjectError::Io) on filesystem failure.
|
||||
pub fn create(root: impl AsRef<Path>, name: impl Into<String>) -> Result<Self, ProjectError> {
|
||||
let root = root.as_ref().to_path_buf();
|
||||
let file = root.join(PROJECT_FILE_NAME);
|
||||
if file.exists() {
|
||||
return Err(ProjectError::AlreadyExists(file));
|
||||
}
|
||||
for dir in [
|
||||
&root,
|
||||
&root.join(SCENES_DIR),
|
||||
&root.join(ASSETS_DIR),
|
||||
&root.join(SCRIPTS_DIR),
|
||||
] {
|
||||
std::fs::create_dir_all(dir).map_err(|source| ProjectError::Io {
|
||||
path: dir.clone(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
let project = Self {
|
||||
root,
|
||||
meta: ProjectMeta::new(name),
|
||||
};
|
||||
project.save()?;
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
/// Opens an existing project. `path` may be the project root directory or
|
||||
/// the project file itself.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`NotFound`](ProjectError::NotFound) if no project file is present, or
|
||||
/// [`Parse`](ProjectError::Parse)/[`Io`](ProjectError::Io) on failure.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, ProjectError> {
|
||||
let path = path.as_ref();
|
||||
let (root, file) = if path.is_dir() {
|
||||
(path.to_path_buf(), path.join(PROJECT_FILE_NAME))
|
||||
} else {
|
||||
let root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
|
||||
(root, path.to_path_buf())
|
||||
};
|
||||
if !file.exists() {
|
||||
return Err(ProjectError::NotFound(file));
|
||||
}
|
||||
let text = std::fs::read_to_string(&file).map_err(|source| ProjectError::Io {
|
||||
path: file.clone(),
|
||||
source,
|
||||
})?;
|
||||
let meta: ProjectMeta = ron::from_str(&text).map_err(|err| ProjectError::Parse {
|
||||
path: file.clone(),
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
Ok(Self { root, meta })
|
||||
}
|
||||
|
||||
/// Writes the project file, stamping it with the current engine version.
|
||||
pub fn save(&self) -> Result<(), ProjectError> {
|
||||
let file = self.project_file_path();
|
||||
let pretty = ron::ser::PrettyConfig::default();
|
||||
let text = ron::ser::to_string_pretty(&self.meta, pretty)
|
||||
.map_err(|err| ProjectError::Serialize(err.to_string()))?;
|
||||
std::fs::write(&file, text).map_err(|source| ProjectError::Io { path: file, source })
|
||||
}
|
||||
|
||||
// --- Layout ------------------------------------------------------------
|
||||
|
||||
/// The project root directory.
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// The path of the project file.
|
||||
pub fn project_file_path(&self) -> PathBuf {
|
||||
self.root.join(PROJECT_FILE_NAME)
|
||||
}
|
||||
|
||||
/// The scenes directory.
|
||||
pub fn scenes_dir(&self) -> PathBuf {
|
||||
self.root.join(SCENES_DIR)
|
||||
}
|
||||
|
||||
/// The assets directory.
|
||||
pub fn assets_dir(&self) -> PathBuf {
|
||||
self.root.join(ASSETS_DIR)
|
||||
}
|
||||
|
||||
/// The scripts directory.
|
||||
pub fn scripts_dir(&self) -> PathBuf {
|
||||
self.root.join(SCRIPTS_DIR)
|
||||
}
|
||||
|
||||
// --- Metadata ----------------------------------------------------------
|
||||
|
||||
/// The project's metadata (name, modules, settings).
|
||||
pub fn meta(&self) -> &ProjectMeta {
|
||||
&self.meta
|
||||
}
|
||||
|
||||
/// The project name.
|
||||
pub fn name(&self) -> &str {
|
||||
&self.meta.name
|
||||
}
|
||||
|
||||
/// Renames the project (call [`save`](Self::save) to persist).
|
||||
pub fn set_name(&mut self, name: impl Into<String>) {
|
||||
self.meta.name = name.into();
|
||||
}
|
||||
|
||||
/// Whether `module` is enabled for this project.
|
||||
pub fn is_module_enabled(&self, module: &str) -> bool {
|
||||
self.meta.enabled_modules.iter().any(|m| m == module)
|
||||
}
|
||||
|
||||
/// Enables `module` (no-op if already enabled).
|
||||
pub fn enable_module(&mut self, module: impl Into<String>) {
|
||||
let module = module.into();
|
||||
if !self.is_module_enabled(&module) {
|
||||
self.meta.enabled_modules.push(module);
|
||||
}
|
||||
}
|
||||
|
||||
/// Disables `module`. Returns whether it was enabled.
|
||||
pub fn disable_module(&mut self, module: &str) -> bool {
|
||||
let before = self.meta.enabled_modules.len();
|
||||
self.meta.enabled_modules.retain(|m| m != module);
|
||||
self.meta.enabled_modules.len() != before
|
||||
}
|
||||
|
||||
/// The raw RON blob stored for settings `section`, if any.
|
||||
pub fn settings_section(&self, section: &str) -> Option<&str> {
|
||||
self.meta.settings.get(section).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Stores a raw RON blob for settings `section`.
|
||||
pub fn set_settings_section(&mut self, section: impl Into<String>, ron: impl Into<String>) {
|
||||
self.meta.settings.insert(section.into(), ron.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// A most-recently-used list of project roots, persisted globally (an editor
|
||||
/// preference, not part of any single project).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RecentProjects {
|
||||
entries: Vec<PathBuf>,
|
||||
#[serde(default = "default_limit")]
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
fn default_limit() -> usize {
|
||||
10
|
||||
}
|
||||
|
||||
impl RecentProjects {
|
||||
/// A list retaining at most `limit` entries.
|
||||
pub fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
limit: limit.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Records `root` as the most recent project, de-duplicating and capping.
|
||||
pub fn record(&mut self, root: impl AsRef<Path>) {
|
||||
let root = root.as_ref().to_path_buf();
|
||||
self.entries.retain(|p| p != &root);
|
||||
self.entries.insert(0, root);
|
||||
self.entries.truncate(self.limit.max(1));
|
||||
}
|
||||
|
||||
/// The recorded roots, most-recent first.
|
||||
pub fn entries(&self) -> &[PathBuf] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// Loads the list from a RON file, or returns an empty list if absent.
|
||||
pub fn load(path: impl AsRef<Path>) -> Self {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|text| ron::from_str(&text).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Saves the list to a RON file.
|
||||
pub fn save(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
|
||||
let text = ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
std::fs::write(path, text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_root(tag: &str) -> PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"oxide_project_test_{}_{}_{tag}",
|
||||
std::process::id(),
|
||||
// A counter to keep tests isolated within the process.
|
||||
COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
|
||||
));
|
||||
path
|
||||
}
|
||||
|
||||
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
#[test]
|
||||
fn create_scaffolds_layout_and_file() {
|
||||
let root = temp_root("create");
|
||||
let project = Project::create(&root, "My Game").unwrap();
|
||||
assert!(project.project_file_path().exists());
|
||||
assert!(project.scenes_dir().is_dir());
|
||||
assert!(project.assets_dir().is_dir());
|
||||
assert!(project.scripts_dir().is_dir());
|
||||
assert_eq!(project.name(), "My Game");
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_then_open_round_trips() {
|
||||
let root = temp_root("roundtrip");
|
||||
let mut project = Project::create(&root, "Game").unwrap();
|
||||
project.enable_module("physics");
|
||||
project.enable_module("audio");
|
||||
project.set_settings_section("editor", "(theme:\"dark\")");
|
||||
project.save().unwrap();
|
||||
|
||||
// Open by directory.
|
||||
let opened = Project::open(&root).unwrap();
|
||||
assert_eq!(opened.name(), "Game");
|
||||
assert!(opened.is_module_enabled("physics") && opened.is_module_enabled("audio"));
|
||||
assert_eq!(opened.settings_section("editor"), Some("(theme:\"dark\")"));
|
||||
|
||||
// Open by file path.
|
||||
let by_file = Project::open(opened.project_file_path()).unwrap();
|
||||
assert_eq!(by_file.meta(), opened.meta());
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_refuses_to_overwrite() {
|
||||
let root = temp_root("nooverwrite");
|
||||
Project::create(&root, "A").unwrap();
|
||||
let err = Project::create(&root, "B").unwrap_err();
|
||||
assert!(matches!(err, ProjectError::AlreadyExists(_)));
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_missing_is_not_found() {
|
||||
let root = temp_root("missing");
|
||||
let err = Project::open(&root).unwrap_err();
|
||||
assert!(matches!(err, ProjectError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_enable_disable() {
|
||||
let root = temp_root("modules");
|
||||
let mut project = Project::create(&root, "M").unwrap();
|
||||
project.enable_module("terrain");
|
||||
project.enable_module("terrain"); // idempotent
|
||||
assert_eq!(project.meta().enabled_modules, vec!["terrain"]);
|
||||
assert!(project.disable_module("terrain"));
|
||||
assert!(!project.disable_module("terrain"));
|
||||
assert!(!project.is_module_enabled("terrain"));
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_projects_dedup_and_cap() {
|
||||
let mut recent = RecentProjects::new(3);
|
||||
recent.record("/a");
|
||||
recent.record("/b");
|
||||
recent.record("/a"); // moves /a to front, no dup
|
||||
recent.record("/c");
|
||||
recent.record("/d"); // evicts the oldest (/b)
|
||||
let entries: Vec<_> = recent
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|p| p.to_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(entries, vec!["/d", "/c", "/a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_projects_persist() {
|
||||
let root = temp_root("recent");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let file = root.join("recent.ron");
|
||||
let mut recent = RecentProjects::new(5);
|
||||
recent.record("/x");
|
||||
recent.record("/y");
|
||||
recent.save(&file).unwrap();
|
||||
let loaded = RecentProjects::load(&file);
|
||||
assert_eq!(loaded.entries(), recent.entries());
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
//! [`Camera`]: perspective projection plus view/projection matrix helpers.
|
||||
//!
|
||||
//! A camera holds only projection parameters; its *position* is a
|
||||
//! [`Transform`] supplied at render time (so a camera can be an entity in the
|
||||
//! scene). The view matrix is the inverse of that world transform.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::layer::{Layer, LayerMask};
|
||||
use crate::math::{Mat4, Transform};
|
||||
|
||||
/// A perspective camera.
|
||||
///
|
||||
/// Stage 4 ships perspective projection only; orthographic and other
|
||||
/// projections can be added later without changing the renderer interface.
|
||||
///
|
||||
/// A `Camera` is also a **reflected, addable component**: place one on an
|
||||
/// entity and it becomes the scene's viewpoint, dual-editable from the editor
|
||||
/// and scripts like any other component. (The runtime gathering of camera
|
||||
/// entities into the render path is wired in a later stage; today the editor
|
||||
/// drives its own viewport camera.)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, crate::reflect::Reflect)]
|
||||
pub struct Camera {
|
||||
/// Vertical field of view, in radians.
|
||||
pub fov_y: f32,
|
||||
/// Near clip plane distance (> 0).
|
||||
pub z_near: f32,
|
||||
/// Far clip plane distance (> `z_near`).
|
||||
pub z_far: f32,
|
||||
/// The layers this camera renders. An entity is drawn only if its
|
||||
/// [`Layer`](crate::layer::Layer) membership intersects this mask. Defaults
|
||||
/// to [`LayerMask::ALL`] (sees everything) — e.g. a minimap or first-person
|
||||
/// view-model camera narrows it. The host applies it when gathering objects.
|
||||
pub visibility: LayerMask,
|
||||
}
|
||||
|
||||
impl Default for Camera {
|
||||
/// A 60° vertical FOV camera with a 0.1–1000 unit depth range that sees all
|
||||
/// layers.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fov_y: 60_f32.to_radians(),
|
||||
z_near: 0.1,
|
||||
z_far: 1000.0,
|
||||
visibility: LayerMask::ALL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Camera {
|
||||
/// Creates a perspective camera from a vertical FOV (radians) and clip range,
|
||||
/// seeing all layers.
|
||||
pub fn perspective(fov_y: f32, z_near: f32, z_far: f32) -> Self {
|
||||
Self {
|
||||
fov_y,
|
||||
z_near,
|
||||
z_far,
|
||||
visibility: LayerMask::ALL,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the layer-visibility mask (builder style).
|
||||
pub fn with_visibility(mut self, visibility: LayerMask) -> Self {
|
||||
self.visibility = visibility;
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether this camera renders an entity with the given layer membership.
|
||||
pub fn sees(&self, layer: Layer) -> bool {
|
||||
layer.matches(self.visibility)
|
||||
}
|
||||
|
||||
/// The projection matrix for a viewport of the given `aspect` (width /
|
||||
/// height). Uses a reversed-Z-free, `0..1` NDC depth range (wgpu/Vulkan/
|
||||
/// DX/Metal convention).
|
||||
pub fn projection_matrix(&self, aspect: f32) -> Mat4 {
|
||||
Mat4::perspective_rh(
|
||||
self.fov_y,
|
||||
aspect.max(f32::EPSILON),
|
||||
self.z_near,
|
||||
self.z_far,
|
||||
)
|
||||
}
|
||||
|
||||
/// The view matrix for a camera placed at `view_transform` — i.e. the
|
||||
/// inverse of the camera's world transform.
|
||||
pub fn view_matrix(view_transform: &Transform) -> Mat4 {
|
||||
view_transform.to_matrix().inverse()
|
||||
}
|
||||
|
||||
/// The combined view-projection matrix: `projection * view`.
|
||||
pub fn view_projection(&self, aspect: f32, view_transform: &Transform) -> Mat4 {
|
||||
self.projection_matrix(aspect) * Self::view_matrix(view_transform)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::Vec3;
|
||||
|
||||
#[test]
|
||||
fn visibility_filters_by_layer() {
|
||||
// Default camera sees every layer.
|
||||
let cam = Camera::default();
|
||||
assert!(cam.sees(Layer::on(7)));
|
||||
|
||||
// A camera restricted to the "UI" layer (3) only sees layer-3 entities.
|
||||
let ui_cam = Camera::default().with_visibility(LayerMask::layer(3));
|
||||
assert!(ui_cam.sees(Layer::on(3)));
|
||||
assert!(!ui_cam.sees(Layer::on(0)));
|
||||
assert!(!ui_cam.sees(Layer::default())); // default layer 0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_is_finite_and_depth_mapped() {
|
||||
let cam = Camera::default();
|
||||
let proj = cam.projection_matrix(16.0 / 9.0);
|
||||
assert!(proj.is_finite());
|
||||
// A point on the near plane maps to NDC z ~ 0, the far plane to ~ 1.
|
||||
let near = proj.project_point3(Vec3::new(0.0, 0.0, -cam.z_near));
|
||||
let far = proj.project_point3(Vec3::new(0.0, 0.0, -cam.z_far));
|
||||
assert!(near.z.abs() < 1e-3, "near z = {}", near.z);
|
||||
assert!((far.z - 1.0).abs() < 1e-3, "far z = {}", far.z);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_matrix_moves_world_into_camera_space() {
|
||||
// Camera at +Z looking at the origin: the origin should sit straight
|
||||
// ahead, down the camera's -Z axis.
|
||||
let cam_tf = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||
let view = Camera::view_matrix(&cam_tf);
|
||||
let origin_in_view = view.project_point3(Vec3::ZERO);
|
||||
assert!((origin_in_view.x).abs() < 1e-5);
|
||||
assert!((origin_in_view.y).abs() < 1e-5);
|
||||
assert!(
|
||||
(origin_in_view.z + 5.0).abs() < 1e-4,
|
||||
"z = {}",
|
||||
origin_in_view.z
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Window surface rendering: swapchain configuration, resize, clear loop.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use winit::window::Window;
|
||||
|
||||
use super::{clear_view, Gpu, RenderError};
|
||||
use crate::math::Color;
|
||||
use crate::window::RenderCtx;
|
||||
|
||||
/// Renders to a window surface.
|
||||
///
|
||||
/// Owns the [`Gpu`] plus the window's [`wgpu::Surface`] and its
|
||||
/// configuration. Stage 2 scope: every frame is cleared to
|
||||
/// [`clear_color`](Self::clear_color); draw passes come in later stages.
|
||||
pub struct RenderContext {
|
||||
gpu: Gpu,
|
||||
surface: wgpu::Surface<'static>,
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
clear_color: Color,
|
||||
}
|
||||
|
||||
impl RenderContext {
|
||||
/// Acquires the GPU and configures a surface for `window`.
|
||||
///
|
||||
/// The window is held by `Arc` so the surface (which borrows it) can be
|
||||
/// `'static`, as winit hands out windows from its event loop.
|
||||
///
|
||||
/// To run on any device, several render backends are tried in turn — the
|
||||
/// default (env-selected Vulkan/Metal/DX12), then GL, then a software
|
||||
/// adapter — and the first that produces a *configurable* surface wins.
|
||||
/// This is what lets the engine survive drivers that report a GPU but
|
||||
/// cannot present to the window's surface (e.g. old NVIDIA on Wayland under
|
||||
/// Vulkan, where `surface.configure` would otherwise fail).
|
||||
pub fn new(window: Arc<Window>) -> Result<Self, RenderError> {
|
||||
// (label, backend override, force a software adapter)
|
||||
let attempts: [(&str, Option<wgpu::Backends>, bool); 3] = [
|
||||
("default", None, false),
|
||||
("GL", Some(wgpu::Backends::GL), false),
|
||||
("software", None, true),
|
||||
];
|
||||
|
||||
let mut last_err: Option<RenderError> = None;
|
||||
for (i, &(label, backends, force_fallback)) in attempts.iter().enumerate() {
|
||||
match Self::try_backend(&window, backends, force_fallback) {
|
||||
Ok(ctx) => {
|
||||
if i > 0 {
|
||||
log::warn!("render backend fell back to '{label}'");
|
||||
}
|
||||
return Ok(ctx);
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("render backend '{label}' unavailable: {err}");
|
||||
last_err = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or(RenderError::NoWorkingBackend))
|
||||
}
|
||||
|
||||
/// Attempts one backend: build an instance (optionally forcing `backends`),
|
||||
/// create the surface, acquire an adapter/device (optionally a software
|
||||
/// one), and configure the surface. Any failure returns `Err` so the caller
|
||||
/// can try the next backend rather than aborting the process.
|
||||
fn try_backend(
|
||||
window: &Arc<Window>,
|
||||
backends: Option<wgpu::Backends>,
|
||||
force_fallback_adapter: bool,
|
||||
) -> Result<Self, RenderError> {
|
||||
let size = window.inner_size();
|
||||
// The window doubles as the display handle (needed by GL/X11-style
|
||||
// backends); `from_env` keeps backend/flags overridable via WGPU_*.
|
||||
let mut desc =
|
||||
wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone()));
|
||||
if let Some(backends) = backends {
|
||||
desc.backends = backends;
|
||||
}
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let surface = instance.create_surface(window.clone())?;
|
||||
let gpu = Gpu::with_instance(instance, Some(&surface), force_fallback_adapter)?;
|
||||
|
||||
let config = surface
|
||||
.get_default_config(gpu.adapter(), size.width.max(1), size.height.max(1))
|
||||
.ok_or(RenderError::UnsupportedSurface)?;
|
||||
configure_surface(gpu.device(), &surface, &config)?;
|
||||
log::info!(
|
||||
"surface configured: {}x{} {:?} ({:?}) on {:?}",
|
||||
config.width,
|
||||
config.height,
|
||||
config.format,
|
||||
config.present_mode,
|
||||
gpu.adapter().get_info().backend,
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
gpu,
|
||||
surface,
|
||||
config,
|
||||
clear_color: Color::BLACK,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reconfigures the surface for a new window size. Zero dimensions
|
||||
/// (minimized window) are clamped to 1 so the surface stays valid.
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
self.config.width = width.max(1);
|
||||
self.config.height = height.max(1);
|
||||
self.surface.configure(self.gpu.device(), &self.config);
|
||||
}
|
||||
|
||||
/// Current surface size in physical pixels.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.config.width, self.config.height)
|
||||
}
|
||||
|
||||
/// The surface's texture format. Apps need this to build render pipelines
|
||||
/// (or UI integrations) whose output matches the surface.
|
||||
pub fn surface_format(&self) -> wgpu::TextureFormat {
|
||||
self.config.format
|
||||
}
|
||||
|
||||
/// The color the surface is cleared to each frame.
|
||||
pub fn clear_color(&self) -> Color {
|
||||
self.clear_color
|
||||
}
|
||||
|
||||
/// Sets the clear color; takes effect on the next rendered frame.
|
||||
pub fn set_clear_color(&mut self, color: Color) {
|
||||
self.clear_color = color;
|
||||
}
|
||||
|
||||
/// Renders one frame: acquires the next surface texture, clears it, and
|
||||
/// presents. Equivalent to [`render_frame_with`](Self::render_frame_with)
|
||||
/// with an empty draw hook.
|
||||
pub fn render_frame(&mut self, window: &Window) -> Result<(), RenderError> {
|
||||
self.render_frame_with(window, |_| {})
|
||||
}
|
||||
|
||||
/// Renders one frame, invoking `draw` after the clear and before present.
|
||||
///
|
||||
/// The surface texture is acquired and cleared to
|
||||
/// [`clear_color`](Self::clear_color), then `draw` is handed a
|
||||
/// [`RenderCtx`] so it can record additional passes into the same view
|
||||
/// (use `LoadOp::Load` to preserve the clear), and finally the frame is
|
||||
/// presented.
|
||||
///
|
||||
/// Lost or outdated surfaces (e.g. mid-resize) are reconfigured and the
|
||||
/// frame skipped; timed-out or occluded acquires skip the frame. All are
|
||||
/// normal transient conditions and not reported as errors.
|
||||
pub fn render_frame_with(
|
||||
&mut self,
|
||||
window: &Window,
|
||||
draw: impl FnOnce(&RenderCtx<'_>),
|
||||
) -> Result<(), RenderError> {
|
||||
use wgpu::CurrentSurfaceTexture;
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
// A suboptimal frame is still presentable; the next resize event
|
||||
// reconfigures the surface anyway.
|
||||
CurrentSurfaceTexture::Success(frame) | CurrentSurfaceTexture::Suboptimal(frame) => {
|
||||
frame
|
||||
}
|
||||
CurrentSurfaceTexture::Lost | CurrentSurfaceTexture::Outdated => {
|
||||
self.surface.configure(self.gpu.device(), &self.config);
|
||||
return Ok(());
|
||||
}
|
||||
CurrentSurfaceTexture::Timeout | CurrentSurfaceTexture::Occluded => return Ok(()),
|
||||
CurrentSurfaceTexture::Validation => return Err(RenderError::SurfaceValidation),
|
||||
};
|
||||
let view = frame
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
clear_view(self.gpu.device(), self.gpu.queue(), &view, self.clear_color);
|
||||
|
||||
let ctx = RenderCtx {
|
||||
gpu: &self.gpu,
|
||||
view: &view,
|
||||
window,
|
||||
surface_format: self.config.format,
|
||||
size: (self.config.width, self.config.height),
|
||||
};
|
||||
draw(&ctx);
|
||||
|
||||
frame.present();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The underlying GPU handle.
|
||||
pub fn gpu(&self) -> &Gpu {
|
||||
&self.gpu
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures `surface`, capturing any validation error instead of letting it
|
||||
/// reach wgpu's default (fatal, process-aborting) error handler.
|
||||
///
|
||||
/// `surface.configure` returns `()` and reports failures through the device's
|
||||
/// error sink, which by default panics. Wrapping it in a validation error scope
|
||||
/// turns "Invalid surface" (and similar) into a recoverable [`Result`] so the
|
||||
/// caller can fall back to another backend.
|
||||
fn configure_surface(
|
||||
device: &wgpu::Device,
|
||||
surface: &wgpu::Surface<'static>,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
) -> Result<(), RenderError> {
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
surface.configure(device, config);
|
||||
// `pop()` consumes the guard and yields any captured error. On native
|
||||
// backends the future is already resolved; `block_on` just unwraps it.
|
||||
if let Some(err) = pollster::block_on(scope.pop()) {
|
||||
return Err(RenderError::SurfaceConfigure(err.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
//! [`ForwardRenderer`]: a single-pass forward renderer with a depth buffer and
|
||||
//! one directional light.
|
||||
//!
|
||||
//! Stage 4 scope: draw a list of [`RenderObject`]s (each a [`GpuMesh`] +
|
||||
//! [`Material`] + [`Transform`]) through the lit shader, into a caller-provided
|
||||
//! color target, using an owned depth texture. Shadows, multiple lights, and
|
||||
//! post-processing arrive in later stages.
|
||||
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use glam::Mat3;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::mesh::{GpuMesh, Vertex};
|
||||
use super::{Camera, Material};
|
||||
use crate::math::{Color, Transform, Vec3, Vec4};
|
||||
|
||||
/// Depth buffer format used by the forward pass.
|
||||
pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
|
||||
|
||||
/// A directional light: parallel rays with a travel `direction`.
|
||||
///
|
||||
/// Also a **reflected, addable component**: drop one on an entity to author a
|
||||
/// sun/key light in the scene, dual-editable from the editor and scripts.
|
||||
/// (Gathering light entities into the forward pass is a later-stage wiring; the
|
||||
/// renderer currently takes its [`Lighting`] directly.)
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, crate::reflect::Reflect)]
|
||||
pub struct DirectionalLight {
|
||||
/// The direction the light travels (does not need to be normalized).
|
||||
pub direction: Vec3,
|
||||
/// Light color.
|
||||
pub color: Color,
|
||||
/// Scalar intensity multiplier.
|
||||
pub intensity: f32,
|
||||
}
|
||||
|
||||
impl Default for DirectionalLight {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
direction: Vec3::new(-0.5, -1.0, -0.35),
|
||||
color: Color::WHITE,
|
||||
intensity: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scene lighting for a forward pass: one directional light plus an ambient term.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Lighting {
|
||||
/// The single directional (sun) light.
|
||||
pub light: DirectionalLight,
|
||||
/// Flat ambient color added everywhere (cheap fill light).
|
||||
pub ambient: Color,
|
||||
}
|
||||
|
||||
impl Default for Lighting {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
light: DirectionalLight::default(),
|
||||
ambient: Color::rgb(0.08, 0.08, 0.10),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One drawable: a GPU mesh placed by `transform` and shaded with `material`.
|
||||
pub struct RenderObject<'a> {
|
||||
/// The mesh to draw.
|
||||
pub mesh: &'a GpuMesh,
|
||||
/// Its surface material.
|
||||
pub material: Material,
|
||||
/// World placement.
|
||||
pub transform: Transform,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
struct GlobalsUniform {
|
||||
view_proj: [[f32; 4]; 4],
|
||||
camera_pos: [f32; 4],
|
||||
light_dir: [f32; 4],
|
||||
light_color: [f32; 4],
|
||||
ambient: [f32; 4],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
struct ObjectUniform {
|
||||
model: [[f32; 4]; 4],
|
||||
normal_mtx: [[f32; 4]; 4],
|
||||
albedo: [f32; 4],
|
||||
mr: [f32; 4],
|
||||
}
|
||||
|
||||
/// A forward renderer owning its pipeline, depth buffer, and uniform storage.
|
||||
pub struct ForwardRenderer {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
globals_buffer: wgpu::Buffer,
|
||||
globals_bind_group: wgpu::BindGroup,
|
||||
object_layout: wgpu::BindGroupLayout,
|
||||
object_buffer: wgpu::Buffer,
|
||||
object_bind_group: wgpu::BindGroup,
|
||||
/// Per-object stride: `size_of::<ObjectUniform>` rounded up to the device's
|
||||
/// minimum dynamic-uniform-buffer offset alignment.
|
||||
object_stride: u64,
|
||||
object_capacity: u32,
|
||||
depth: Option<DepthTarget>,
|
||||
color_format: wgpu::TextureFormat,
|
||||
}
|
||||
|
||||
struct DepthTarget {
|
||||
view: wgpu::TextureView,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
impl ForwardRenderer {
|
||||
/// Builds the renderer for a given color target format (e.g. the surface
|
||||
/// format for a window, or `Rgba8Unorm` for offscreen rendering).
|
||||
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("oxide.forward.lit"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/lit.wgsl").into()),
|
||||
});
|
||||
|
||||
let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("oxide.forward.globals_layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: NonZeroU64::new(std::mem::size_of::<GlobalsUniform>() as u64),
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let object_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("oxide.forward.object_layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: true,
|
||||
min_binding_size: NonZeroU64::new(std::mem::size_of::<ObjectUniform>() as u64),
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("oxide.forward.pipeline_layout"),
|
||||
bind_group_layouts: &[Some(&globals_layout), Some(&object_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("oxide.forward.pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Vertex::LAYOUT],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
strip_index_format: None,
|
||||
front_face: wgpu::FrontFace::Ccw,
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
unclipped_depth: false,
|
||||
polygon_mode: wgpu::PolygonMode::Fill,
|
||||
conservative: false,
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: DEPTH_FORMAT,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Less),
|
||||
stencil: wgpu::StencilState::default(),
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
}),
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: color_format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
let globals_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("oxide.forward.globals"),
|
||||
size: std::mem::size_of::<GlobalsUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let globals_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("oxide.forward.globals_bg"),
|
||||
layout: &globals_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: globals_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
|
||||
let object_stride = align_up(
|
||||
std::mem::size_of::<ObjectUniform>() as u64,
|
||||
device.limits().min_uniform_buffer_offset_alignment as u64,
|
||||
);
|
||||
let object_capacity = 16;
|
||||
let (object_buffer, object_bind_group) =
|
||||
create_object_storage(device, &object_layout, object_stride, object_capacity);
|
||||
|
||||
Self {
|
||||
pipeline,
|
||||
globals_buffer,
|
||||
globals_bind_group,
|
||||
object_layout,
|
||||
object_buffer,
|
||||
object_bind_group,
|
||||
object_stride,
|
||||
object_capacity,
|
||||
depth: None,
|
||||
color_format,
|
||||
}
|
||||
}
|
||||
|
||||
/// The color target format this renderer was built for.
|
||||
pub fn color_format(&self) -> wgpu::TextureFormat {
|
||||
self.color_format
|
||||
}
|
||||
|
||||
/// Renders `objects` into `target` (whose full physical size is
|
||||
/// `width`×`height`) as seen by `camera` placed at `view_transform`, lit
|
||||
/// by `lighting`. Drawing is restricted to `viewport_rect` (a sub-
|
||||
/// rectangle of the target), and the projection uses that rect's aspect
|
||||
/// ratio.
|
||||
///
|
||||
/// The color target is *loaded* (not cleared) so a clear pass run before
|
||||
/// this — e.g. the window's clear color — shows through as the background;
|
||||
/// the depth buffer is cleared to 1.0 each call.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
target: &wgpu::TextureView,
|
||||
(width, height): (u32, u32),
|
||||
viewport_rect: crate::math::Rect,
|
||||
camera: &Camera,
|
||||
view_transform: &Transform,
|
||||
lighting: &Lighting,
|
||||
objects: &[RenderObject<'_>],
|
||||
) {
|
||||
let (width, height) = (width.max(1), height.max(1));
|
||||
// Clamp the viewport rect to the target so wgpu doesn't complain.
|
||||
let vp_w = viewport_rect.width().max(1.0).min(width as f32);
|
||||
let vp_h = viewport_rect.height().max(1.0).min(height as f32);
|
||||
let vp_x = viewport_rect.min.x.max(0.0).min(width as f32 - vp_w);
|
||||
let vp_y = viewport_rect.min.y.max(0.0).min(height as f32 - vp_h);
|
||||
|
||||
// Depth must match the full color target's dimensions (the
|
||||
// attachment binding requires that). Pixels outside `set_viewport`
|
||||
// are never written, so the extra depth is wasted memory but never
|
||||
// incorrect.
|
||||
self.ensure_depth(device, width, height);
|
||||
self.ensure_object_capacity(device, objects.len() as u32);
|
||||
|
||||
// Globals — aspect comes from the viewport rect, not the target.
|
||||
let aspect = vp_w / vp_h;
|
||||
let view_proj = camera.view_projection(aspect, view_transform);
|
||||
let to_light = (-lighting.light.direction).normalize_or_zero();
|
||||
let lc = lighting.light.color;
|
||||
let amb = lighting.ambient;
|
||||
let globals = GlobalsUniform {
|
||||
view_proj: view_proj.to_cols_array_2d(),
|
||||
camera_pos: view_transform.translation.extend(1.0).to_array(),
|
||||
light_dir: to_light.extend(0.0).to_array(),
|
||||
light_color: (Vec4::new(lc.r, lc.g, lc.b, 1.0) * lighting.light.intensity).to_array(),
|
||||
ambient: Vec4::new(amb.r, amb.g, amb.b, 1.0).to_array(),
|
||||
};
|
||||
queue.write_buffer(&self.globals_buffer, 0, bytemuck::bytes_of(&globals));
|
||||
|
||||
// Per-object uniforms.
|
||||
for (i, obj) in objects.iter().enumerate() {
|
||||
let model = obj.transform.to_matrix();
|
||||
let normal_mtx = Mat3::from_mat4(model).inverse().transpose();
|
||||
let normal_mtx4 = [
|
||||
normal_mtx.x_axis.extend(0.0).to_array(),
|
||||
normal_mtx.y_axis.extend(0.0).to_array(),
|
||||
normal_mtx.z_axis.extend(0.0).to_array(),
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
];
|
||||
let a = obj.material.albedo;
|
||||
let uniform = ObjectUniform {
|
||||
model: model.to_cols_array_2d(),
|
||||
normal_mtx: normal_mtx4,
|
||||
albedo: [a.r, a.g, a.b, a.a],
|
||||
mr: [obj.material.metallic, obj.material.roughness, 0.0, 0.0],
|
||||
};
|
||||
queue.write_buffer(
|
||||
&self.object_buffer,
|
||||
i as u64 * self.object_stride,
|
||||
bytemuck::bytes_of(&uniform),
|
||||
);
|
||||
}
|
||||
|
||||
let depth_view = &self.depth.as_ref().expect("depth ensured above").view;
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("oxide.forward.encoder"),
|
||||
});
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("oxide.forward.pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
// Restrict drawing to the host's viewport sub-rect. Pixels
|
||||
// outside this rectangle keep whatever the prior pass (e.g.
|
||||
// ClearPass or the window clear) wrote there.
|
||||
pass.set_viewport(vp_x, vp_y, vp_w, vp_h, 0.0, 1.0);
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.globals_bind_group, &[]);
|
||||
for (i, obj) in objects.iter().enumerate() {
|
||||
let offset = (i as u64 * self.object_stride) as u32;
|
||||
pass.set_bind_group(1, &self.object_bind_group, &[offset]);
|
||||
pass.set_vertex_buffer(0, obj.mesh.vertex_buffer.slice(..));
|
||||
pass.set_index_buffer(obj.mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..obj.mesh.index_count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
queue.submit([encoder.finish()]);
|
||||
}
|
||||
|
||||
fn ensure_depth(&mut self, device: &wgpu::Device, width: u32, height: u32) {
|
||||
let stale = match &self.depth {
|
||||
Some(d) => d.width != width || d.height != height,
|
||||
None => true,
|
||||
};
|
||||
if stale {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("oxide.forward.depth"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: DEPTH_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
});
|
||||
self.depth = Some(DepthTarget {
|
||||
view: texture.create_view(&wgpu::TextureViewDescriptor::default()),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_object_capacity(&mut self, device: &wgpu::Device, needed: u32) {
|
||||
if needed > self.object_capacity {
|
||||
let capacity = needed.next_power_of_two();
|
||||
let (buffer, bind_group) =
|
||||
create_object_storage(device, &self.object_layout, self.object_stride, capacity);
|
||||
self.object_buffer = buffer;
|
||||
self.object_bind_group = bind_group;
|
||||
self.object_capacity = capacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocates the per-object uniform buffer (`capacity` slots of `stride` bytes)
|
||||
/// and a dynamic-offset bind group over it.
|
||||
fn create_object_storage(
|
||||
device: &wgpu::Device,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
stride: u64,
|
||||
capacity: u32,
|
||||
) -> (wgpu::Buffer, wgpu::BindGroup) {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("oxide.forward.objects"),
|
||||
size: stride * capacity as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("oxide.forward.object_bg"),
|
||||
layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &buffer,
|
||||
offset: 0,
|
||||
size: NonZeroU64::new(std::mem::size_of::<ObjectUniform>() as u64),
|
||||
}),
|
||||
}],
|
||||
});
|
||||
(buffer, bind_group)
|
||||
}
|
||||
|
||||
/// Rounds `value` up to the next multiple of `align` (a power of two).
|
||||
fn align_up(value: u64, align: u64) -> u64 {
|
||||
let align = align.max(1);
|
||||
value.div_ceil(align) * align
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! GPU acquisition: instance, adapter, device, queue.
|
||||
|
||||
use super::RenderError;
|
||||
|
||||
/// A handle to the GPU: instance, adapter, and the device/queue pair every
|
||||
/// rendering operation goes through.
|
||||
///
|
||||
/// Created either for a window surface (via [`RenderContext`]) or headless
|
||||
/// with [`Gpu::headless`] for offscreen rendering and tests.
|
||||
///
|
||||
/// [`RenderContext`]: super::RenderContext
|
||||
pub struct Gpu {
|
||||
instance: wgpu::Instance,
|
||||
adapter: wgpu::Adapter,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
}
|
||||
|
||||
impl Gpu {
|
||||
/// Acquires an adapter and device from an existing `instance`, preferring
|
||||
/// an adapter that can present to `compatible_surface` when one is given.
|
||||
///
|
||||
/// `force_fallback_adapter` requests a software adapter (e.g. llvmpipe),
|
||||
/// used as a last resort when no hardware adapter works.
|
||||
pub(crate) fn with_instance(
|
||||
instance: wgpu::Instance,
|
||||
compatible_surface: Option<&wgpu::Surface<'_>>,
|
||||
force_fallback_adapter: bool,
|
||||
) -> Result<Self, RenderError> {
|
||||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
force_fallback_adapter,
|
||||
compatible_surface,
|
||||
}))?;
|
||||
log::info!(
|
||||
"GPU adapter: {} ({:?})",
|
||||
adapter.get_info().name,
|
||||
adapter.get_info().backend
|
||||
);
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("oxide.device"),
|
||||
..Default::default()
|
||||
}))?;
|
||||
Ok(Self {
|
||||
instance,
|
||||
adapter,
|
||||
device,
|
||||
queue,
|
||||
})
|
||||
}
|
||||
|
||||
/// Acquires the GPU without any surface, for offscreen rendering and
|
||||
/// automated tests.
|
||||
///
|
||||
/// Tries a hardware adapter first, then falls back to a software adapter
|
||||
/// (e.g. llvmpipe) so headless rendering also works on machines without a
|
||||
/// usable GPU.
|
||||
pub fn headless() -> Result<Self, RenderError> {
|
||||
// `from_env` keeps backend/flags overridable via WGPU_* env vars.
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
|
||||
match Self::with_instance(instance, None, false) {
|
||||
Ok(gpu) => Ok(gpu),
|
||||
Err(hardware_err) => {
|
||||
log::warn!("no hardware GPU adapter ({hardware_err}); trying software fallback");
|
||||
let instance = wgpu::Instance::new(
|
||||
wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
|
||||
);
|
||||
Self::with_instance(instance, None, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wgpu instance the adapter was created from.
|
||||
pub fn instance(&self) -> &wgpu::Instance {
|
||||
&self.instance
|
||||
}
|
||||
|
||||
/// The physical adapter in use.
|
||||
pub fn adapter(&self) -> &wgpu::Adapter {
|
||||
&self.adapter
|
||||
}
|
||||
|
||||
/// The logical device used to create GPU resources.
|
||||
pub fn device(&self) -> &wgpu::Device {
|
||||
&self.device
|
||||
}
|
||||
|
||||
/// The queue used to submit command buffers.
|
||||
pub fn queue(&self) -> &wgpu::Queue {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! [`Material`]: a PBR-lite surface description.
|
||||
//!
|
||||
//! Stage 4 keeps materials to the parameters the basic lit pass consumes:
|
||||
//! an albedo (base) color plus metallic/roughness factors. Textures, emissive,
|
||||
//! and the full PBR set arrive with the shader system in a later stage.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::math::Color;
|
||||
|
||||
/// A PBR-lite material: base color and metallic/roughness factors.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Material {
|
||||
/// Base (albedo) color, linear RGBA.
|
||||
pub albedo: Color,
|
||||
/// Metalness in `[0, 1]` (0 = dielectric, 1 = metal).
|
||||
pub metallic: f32,
|
||||
/// Perceptual roughness in `[0, 1]` (0 = mirror, 1 = fully rough).
|
||||
pub roughness: f32,
|
||||
}
|
||||
|
||||
impl Default for Material {
|
||||
/// A neutral mid-gray dielectric.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
albedo: Color::rgb(0.8, 0.8, 0.8),
|
||||
metallic: 0.0,
|
||||
roughness: 0.6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Material {
|
||||
/// A matte, non-metallic material of the given color.
|
||||
pub fn diffuse(albedo: Color) -> Self {
|
||||
Self {
|
||||
albedo,
|
||||
metallic: 0.0,
|
||||
roughness: 0.9,
|
||||
}
|
||||
}
|
||||
|
||||
/// A metallic material of the given color and roughness.
|
||||
pub fn metal(albedo: Color, roughness: f32) -> Self {
|
||||
Self {
|
||||
albedo,
|
||||
metallic: 1.0,
|
||||
roughness: roughness.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Mesh data: CPU-side [`Mesh`] geometry, its GPU upload ([`GpuMesh`]), and
|
||||
//! built-in primitive builders.
|
||||
//!
|
||||
//! A [`Vertex`] carries position, normal, and UV — the minimal set the Stage 4
|
||||
//! forward renderer needs for lit, textured-ready geometry. Meshes are built on
|
||||
//! the CPU (procedurally or, later, from a GLTF import) and uploaded once into a
|
||||
//! [`GpuMesh`] for drawing.
|
||||
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::math::{Aabb, Vec2, Vec3};
|
||||
|
||||
/// A single mesh vertex: position, normal, and texture coordinate.
|
||||
///
|
||||
/// `repr(C)` + [`Pod`] so a `&[Vertex]` can be uploaded straight into a GPU
|
||||
/// vertex buffer with no per-field marshalling.
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable)]
|
||||
pub struct Vertex {
|
||||
/// Object-space position.
|
||||
pub position: [f32; 3],
|
||||
/// Object-space normal (expected unit length for correct lighting).
|
||||
pub normal: [f32; 3],
|
||||
/// Texture coordinate.
|
||||
pub uv: [f32; 2],
|
||||
}
|
||||
|
||||
impl Vertex {
|
||||
/// Builds a vertex from math types.
|
||||
pub fn new(position: Vec3, normal: Vec3, uv: Vec2) -> Self {
|
||||
Self {
|
||||
position: position.to_array(),
|
||||
normal: normal.to_array(),
|
||||
uv: uv.to_array(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `wgpu` vertex buffer layout matching this struct's fields
|
||||
/// (`@location(0)` position, `@location(1)` normal, `@location(2)` uv).
|
||||
pub const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &wgpu::vertex_attr_array![
|
||||
0 => Float32x3, // position
|
||||
1 => Float32x3, // normal
|
||||
2 => Float32x2, // uv
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/// CPU-side mesh geometry: an indexed triangle list.
|
||||
///
|
||||
/// Indices are `u32` (32-bit), so meshes are not limited to 65k vertices.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Mesh {
|
||||
/// Vertex data.
|
||||
pub vertices: Vec<Vertex>,
|
||||
/// Triangle indices into [`vertices`](Self::vertices), three per triangle.
|
||||
pub indices: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// Creates a mesh from raw vertex and index data.
|
||||
pub fn new(vertices: Vec<Vertex>, indices: Vec<u32>) -> Self {
|
||||
Self { vertices, indices }
|
||||
}
|
||||
|
||||
/// Number of triangles (index count / 3).
|
||||
pub fn triangle_count(&self) -> usize {
|
||||
self.indices.len() / 3
|
||||
}
|
||||
|
||||
/// The axis-aligned bounds of the mesh in object space
|
||||
/// ([`Aabb::EMPTY`](crate::math::Aabb) for an empty mesh).
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
Aabb::from_points(self.vertices.iter().map(|v| Vec3::from_array(v.position)))
|
||||
}
|
||||
|
||||
/// Uploads the mesh into GPU vertex/index buffers for drawing.
|
||||
pub fn upload(&self, device: &wgpu::Device, label: &str) -> GpuMesh {
|
||||
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(&format!("{label}.vertices")),
|
||||
contents: bytemuck::cast_slice(&self.vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(&format!("{label}.indices")),
|
||||
contents: bytemuck::cast_slice(&self.indices),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
});
|
||||
GpuMesh {
|
||||
vertex_buffer,
|
||||
index_buffer,
|
||||
index_count: self.indices.len() as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit cube centered at the origin (side length 1), with per-face normals
|
||||
/// and UVs (so each face is flat-shaded correctly).
|
||||
pub fn cube() -> Self {
|
||||
Self::box_mesh(Vec3::splat(1.0))
|
||||
}
|
||||
|
||||
/// An axis-aligned box of the given `size` (full extents), centered at the
|
||||
/// origin, with per-face normals and UVs.
|
||||
pub fn box_mesh(size: Vec3) -> Self {
|
||||
let h = size * 0.5;
|
||||
// (normal, then the four corners CCW seen from outside)
|
||||
let faces: [(Vec3, [Vec3; 4]); 6] = [
|
||||
// +X
|
||||
(
|
||||
Vec3::X,
|
||||
[
|
||||
Vec3::new(h.x, -h.y, h.z),
|
||||
Vec3::new(h.x, -h.y, -h.z),
|
||||
Vec3::new(h.x, h.y, -h.z),
|
||||
Vec3::new(h.x, h.y, h.z),
|
||||
],
|
||||
),
|
||||
// -X
|
||||
(
|
||||
Vec3::NEG_X,
|
||||
[
|
||||
Vec3::new(-h.x, -h.y, -h.z),
|
||||
Vec3::new(-h.x, -h.y, h.z),
|
||||
Vec3::new(-h.x, h.y, h.z),
|
||||
Vec3::new(-h.x, h.y, -h.z),
|
||||
],
|
||||
),
|
||||
// +Y
|
||||
(
|
||||
Vec3::Y,
|
||||
[
|
||||
Vec3::new(-h.x, h.y, h.z),
|
||||
Vec3::new(h.x, h.y, h.z),
|
||||
Vec3::new(h.x, h.y, -h.z),
|
||||
Vec3::new(-h.x, h.y, -h.z),
|
||||
],
|
||||
),
|
||||
// -Y
|
||||
(
|
||||
Vec3::NEG_Y,
|
||||
[
|
||||
Vec3::new(-h.x, -h.y, -h.z),
|
||||
Vec3::new(h.x, -h.y, -h.z),
|
||||
Vec3::new(h.x, -h.y, h.z),
|
||||
Vec3::new(-h.x, -h.y, h.z),
|
||||
],
|
||||
),
|
||||
// +Z
|
||||
(
|
||||
Vec3::Z,
|
||||
[
|
||||
Vec3::new(-h.x, -h.y, h.z),
|
||||
Vec3::new(h.x, -h.y, h.z),
|
||||
Vec3::new(h.x, h.y, h.z),
|
||||
Vec3::new(-h.x, h.y, h.z),
|
||||
],
|
||||
),
|
||||
// -Z
|
||||
(
|
||||
Vec3::NEG_Z,
|
||||
[
|
||||
Vec3::new(h.x, -h.y, -h.z),
|
||||
Vec3::new(-h.x, -h.y, -h.z),
|
||||
Vec3::new(-h.x, h.y, -h.z),
|
||||
Vec3::new(h.x, h.y, -h.z),
|
||||
],
|
||||
),
|
||||
];
|
||||
let uvs = [
|
||||
Vec2::new(0.0, 1.0),
|
||||
Vec2::new(1.0, 1.0),
|
||||
Vec2::new(1.0, 0.0),
|
||||
Vec2::new(0.0, 0.0),
|
||||
];
|
||||
let mut vertices = Vec::with_capacity(24);
|
||||
let mut indices = Vec::with_capacity(36);
|
||||
for (normal, corners) in faces {
|
||||
let base = vertices.len() as u32;
|
||||
for (corner, uv) in corners.iter().zip(uvs.iter()) {
|
||||
vertices.push(Vertex::new(*corner, normal, *uv));
|
||||
}
|
||||
indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
|
||||
}
|
||||
Self::new(vertices, indices)
|
||||
}
|
||||
|
||||
/// A flat plane of `size` units on the XZ axes, centered at the origin,
|
||||
/// facing `+Y`. Useful as a ground reference.
|
||||
pub fn plane(size: f32) -> Self {
|
||||
let h = size * 0.5;
|
||||
let n = Vec3::Y;
|
||||
let vertices = vec![
|
||||
Vertex::new(Vec3::new(-h, 0.0, h), n, Vec2::new(0.0, 1.0)),
|
||||
Vertex::new(Vec3::new(h, 0.0, h), n, Vec2::new(1.0, 1.0)),
|
||||
Vertex::new(Vec3::new(h, 0.0, -h), n, Vec2::new(1.0, 0.0)),
|
||||
Vertex::new(Vec3::new(-h, 0.0, -h), n, Vec2::new(0.0, 0.0)),
|
||||
];
|
||||
Self::new(vertices, vec![0, 1, 2, 0, 2, 3])
|
||||
}
|
||||
|
||||
/// A UV sphere of `radius` with `sectors` longitudinal and `stacks`
|
||||
/// latitudinal divisions. Normals are the (normalized) positions.
|
||||
pub fn uv_sphere(radius: f32, sectors: u32, stacks: u32) -> Self {
|
||||
use std::f32::consts::PI;
|
||||
let sectors = sectors.max(3);
|
||||
let stacks = stacks.max(2);
|
||||
let mut vertices = Vec::new();
|
||||
for i in 0..=stacks {
|
||||
// From +Y pole (phi=0) to -Y pole (phi=PI).
|
||||
let phi = PI * i as f32 / stacks as f32;
|
||||
let (sin_phi, cos_phi) = phi.sin_cos();
|
||||
for j in 0..=sectors {
|
||||
let theta = 2.0 * PI * j as f32 / sectors as f32;
|
||||
let (sin_theta, cos_theta) = theta.sin_cos();
|
||||
let dir = Vec3::new(sin_phi * cos_theta, cos_phi, sin_phi * sin_theta);
|
||||
let uv = Vec2::new(j as f32 / sectors as f32, i as f32 / stacks as f32);
|
||||
vertices.push(Vertex::new(dir * radius, dir, uv));
|
||||
}
|
||||
}
|
||||
let mut indices = Vec::new();
|
||||
let row = sectors + 1;
|
||||
for i in 0..stacks {
|
||||
for j in 0..sectors {
|
||||
let a = i * row + j;
|
||||
let b = a + row;
|
||||
// Two triangles per quad; skip degenerate ones at the poles.
|
||||
// Vertex order is `a → a+1 → b` and `a+1 → b+1 → b`, which
|
||||
// winds the quad CCW when seen from *outside* the sphere —
|
||||
// the wgpu front-face convention. The previous ordering
|
||||
// (`a, b, a+1` / `a+1, b, b+1`) wound them CW from outside,
|
||||
// which made back-face culling eat the sphere's surface and
|
||||
// showed intersecting opaque meshes through it.
|
||||
if i != 0 {
|
||||
indices.extend_from_slice(&[a, a + 1, b]);
|
||||
}
|
||||
if i != stacks - 1 {
|
||||
indices.extend_from_slice(&[a + 1, b + 1, b]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::new(vertices, indices)
|
||||
}
|
||||
}
|
||||
|
||||
/// A mesh uploaded to the GPU: vertex and index buffers ready to draw.
|
||||
pub struct GpuMesh {
|
||||
/// Vertex buffer, laid out per [`Vertex::LAYOUT`].
|
||||
pub vertex_buffer: wgpu::Buffer,
|
||||
/// `u32` index buffer.
|
||||
pub index_buffer: wgpu::Buffer,
|
||||
/// Number of indices to draw.
|
||||
pub index_count: u32,
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! GPU rendering infrastructure.
|
||||
//!
|
||||
//! Stage 2 acquired a GPU ([`Gpu`]), drove a window surface ([`RenderContext`]),
|
||||
//! and cleared it each frame. Stage 4 adds mesh rendering: build geometry
|
||||
//! ([`Mesh`]/[`Vertex`]), upload it ([`GpuMesh`]), describe surfaces with a
|
||||
//! [`Material`], place a [`Camera`], and draw through the [`ForwardRenderer`].
|
||||
|
||||
mod camera;
|
||||
mod context;
|
||||
mod forward;
|
||||
mod gpu;
|
||||
mod material;
|
||||
mod mesh;
|
||||
mod pipeline;
|
||||
mod renderable;
|
||||
mod ui_pass;
|
||||
|
||||
pub use camera::Camera;
|
||||
pub use context::RenderContext;
|
||||
pub use forward::{DirectionalLight, ForwardRenderer, Lighting, RenderObject, DEPTH_FORMAT};
|
||||
pub use gpu::Gpu;
|
||||
pub use material::Material;
|
||||
pub use mesh::{GpuMesh, Mesh, Vertex};
|
||||
pub use pipeline::{ClearPass, ForwardPass, FrameContext, RenderPass, RenderPipeline};
|
||||
pub use renderable::{MeshRenderer, PrimitiveShape};
|
||||
pub use ui_pass::{UiBatch, UiOverlayPass};
|
||||
|
||||
use crate::math::Color;
|
||||
|
||||
/// Errors produced by the rendering layer.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RenderError {
|
||||
/// No GPU adapter compatible with the requested surface (or headless use)
|
||||
/// was found on this system.
|
||||
#[error("no compatible GPU adapter found: {0}")]
|
||||
NoAdapter(#[from] wgpu::RequestAdapterError),
|
||||
|
||||
/// The adapter was found but refused to provide a device.
|
||||
#[error("failed to request GPU device: {0}")]
|
||||
Device(#[from] wgpu::RequestDeviceError),
|
||||
|
||||
/// The window surface could not be created.
|
||||
#[error("failed to create surface: {0}")]
|
||||
CreateSurface(#[from] wgpu::CreateSurfaceError),
|
||||
|
||||
/// The adapter cannot present to the created surface.
|
||||
#[error("the GPU adapter does not support presenting to this surface")]
|
||||
UnsupportedSurface,
|
||||
|
||||
/// Configuring the surface raised a validation error. On some drivers a
|
||||
/// backend reports a GPU but cannot actually present to the window surface
|
||||
/// (e.g. old NVIDIA on Wayland under Vulkan); this is caught so the engine
|
||||
/// can fall back to another backend instead of aborting.
|
||||
#[error("surface configuration failed: {0}")]
|
||||
SurfaceConfigure(String),
|
||||
|
||||
/// Every render backend/adapter the engine tried failed to produce a
|
||||
/// working surface — no usable GPU path on this system.
|
||||
#[error("no working render backend found (tried Vulkan/Metal/DX12, GL, and software)")]
|
||||
NoWorkingBackend,
|
||||
|
||||
/// Acquiring the next frame raised a validation error — a bug in surface
|
||||
/// configuration, not a transient condition.
|
||||
#[error("surface frame acquisition failed validation")]
|
||||
SurfaceValidation,
|
||||
}
|
||||
|
||||
/// Records and submits a render pass that clears `view` to `color`.
|
||||
///
|
||||
/// This is the whole of Stage 2's rendering: both the windowed
|
||||
/// [`RenderContext`] and offscreen targets (e.g. tests) clear through here.
|
||||
pub fn clear_view(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
view: &wgpu::TextureView,
|
||||
color: Color,
|
||||
) {
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("oxide.clear"),
|
||||
});
|
||||
// The pass is dropped immediately: a load-op clear with no draws is all
|
||||
// that is needed to fill the target.
|
||||
let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("oxide.clear.pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(to_wgpu_color(color)),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
queue.submit([encoder.finish()]);
|
||||
}
|
||||
|
||||
/// Converts the engine's [`Color`] (linear `f32`) to a [`wgpu::Color`]
|
||||
/// (linear `f64`), as used by clear operations.
|
||||
pub fn to_wgpu_color(color: Color) -> wgpu::Color {
|
||||
wgpu::Color {
|
||||
r: color.r as f64,
|
||||
g: color.g as f64,
|
||||
b: color.b as f64,
|
||||
a: color.a as f64,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! [`RenderPipeline`]: a data-driven, ordered list of composable render passes.
|
||||
//!
|
||||
//! Stage 4's renderer drew everything in one hardcoded pass. Stage 5 generalizes
|
||||
//! that into a list of named [`RenderPass`]es that share one frame's targets and
|
||||
//! run in order. A project enables only the passes it needs — this is the
|
||||
//! mechanism behind *scalable fidelity*: a flat unlit/low-poly look (or a
|
||||
//! stylized post effect) versus a full realistic stack, paying only for the
|
||||
//! passes turned on.
|
||||
//!
|
||||
//! The Stage-4 forward pass is retrofitted onto this as [`ForwardPass`], so the
|
||||
//! default pipeline ([`RenderPipeline::forward`]) is just `[Clear, Forward]` and
|
||||
//! produces pixel-identical output. Later stages add passes (shadows,
|
||||
//! post-process, overlay UI) **without touching the renderer core** — they
|
||||
//! register a pass.
|
||||
|
||||
use crate::math::{Color, Rect, Transform, Vec2};
|
||||
|
||||
use super::{clear_view, Camera, ForwardRenderer, Lighting, RenderObject};
|
||||
|
||||
/// Everything one frame's passes operate on: the shared color target and the
|
||||
/// scene view to draw.
|
||||
///
|
||||
/// Passes share the same `color` target (and, as the pipeline grows, depth and
|
||||
/// intermediate textures), which is what makes them *composable*: a clear pass
|
||||
/// fills the target, the forward pass draws into it, a future post pass reads and
|
||||
/// rewrites it.
|
||||
pub struct FrameContext<'a> {
|
||||
/// The GPU device.
|
||||
pub device: &'a wgpu::Device,
|
||||
/// The GPU queue.
|
||||
pub queue: &'a wgpu::Queue,
|
||||
/// The color target every pass renders into.
|
||||
pub color: &'a wgpu::TextureView,
|
||||
/// Target size in physical pixels (the whole color target the pipeline
|
||||
/// is writing into).
|
||||
pub size: (u32, u32),
|
||||
/// The sub-rectangle of the target that drawing is restricted to, in
|
||||
/// physical pixels (`min` = upper-left, `max` = lower-right). Passes
|
||||
/// configure the wgpu viewport from this and the camera uses its
|
||||
/// aspect ratio for the projection.
|
||||
///
|
||||
/// `None` means "use the full target" — the default for headless tests
|
||||
/// and for hosts that render to a whole window. The editor sets this to
|
||||
/// the Viewport tab's rect from the docking shell so picking and
|
||||
/// projection align with what the user sees inside the tab rather than
|
||||
/// stretching across the whole window.
|
||||
pub viewport_rect: Option<Rect>,
|
||||
/// The background clear color (used by [`ClearPass`]).
|
||||
pub clear_color: Color,
|
||||
/// The camera to render from.
|
||||
pub camera: &'a Camera,
|
||||
/// The camera's world placement.
|
||||
pub view_transform: &'a Transform,
|
||||
/// Scene lighting.
|
||||
pub lighting: &'a Lighting,
|
||||
/// The drawables, already culled by the host (e.g. by camera
|
||||
/// [`visibility`](Camera::visibility)).
|
||||
pub objects: &'a [RenderObject<'a>],
|
||||
}
|
||||
|
||||
impl FrameContext<'_> {
|
||||
/// The viewport rect [`viewport_rect`](Self::viewport_rect) resolves to —
|
||||
/// the explicit sub-rect when set, otherwise the full target.
|
||||
pub fn resolved_viewport(&self) -> Rect {
|
||||
self.viewport_rect.unwrap_or_else(|| {
|
||||
Rect::from_min_size(
|
||||
Vec2::ZERO,
|
||||
Vec2::new(self.size.0.max(1) as f32, self.size.1.max(1) as f32),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One stage of the frame. Implement this to add a custom pass; register it on a
|
||||
/// [`RenderPipeline`]. Passes are owned by the pipeline and run in order.
|
||||
pub trait RenderPass {
|
||||
/// Records this pass's GPU work for the frame.
|
||||
fn run(&mut self, frame: &mut FrameContext<'_>);
|
||||
}
|
||||
|
||||
struct PassEntry {
|
||||
name: String,
|
||||
enabled: bool,
|
||||
pass: Box<dyn RenderPass>,
|
||||
}
|
||||
|
||||
/// An ordered, named list of render passes.
|
||||
///
|
||||
/// Add passes with [`add_pass`](Self::add_pass), toggle them with
|
||||
/// [`set_enabled`](Self::set_enabled), or drop them with [`remove`](Self::remove)
|
||||
/// — all without touching any pass's implementation. [`render`](Self::render)
|
||||
/// runs every enabled pass in order against one [`FrameContext`].
|
||||
#[derive(Default)]
|
||||
pub struct RenderPipeline {
|
||||
passes: Vec<PassEntry>,
|
||||
}
|
||||
|
||||
impl RenderPipeline {
|
||||
/// An empty pipeline (no passes).
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The default forward pipeline: a [`ClearPass`] followed by a
|
||||
/// [`ForwardPass`]. Pixel-identical to the Stage-4 renderer's output.
|
||||
pub fn forward(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
|
||||
let mut pipeline = Self::new();
|
||||
pipeline.add_pass("clear", ClearPass);
|
||||
pipeline.add_pass("forward", ForwardPass::new(device, color_format));
|
||||
pipeline
|
||||
}
|
||||
|
||||
/// Appends a named pass (enabled). Replaces any existing pass with the same
|
||||
/// name, keeping its position.
|
||||
pub fn add_pass(&mut self, name: impl Into<String>, pass: impl RenderPass + 'static) {
|
||||
let name = name.into();
|
||||
let entry = PassEntry {
|
||||
name: name.clone(),
|
||||
enabled: true,
|
||||
pass: Box::new(pass),
|
||||
};
|
||||
match self.passes.iter_mut().find(|e| e.name == name) {
|
||||
Some(existing) => *existing = entry,
|
||||
None => self.passes.push(entry),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts a pass before the pass named `before` (or at the end if not
|
||||
/// found). Useful for slotting a post effect into a fixed position.
|
||||
pub fn insert_before(
|
||||
&mut self,
|
||||
before: &str,
|
||||
name: impl Into<String>,
|
||||
pass: impl RenderPass + 'static,
|
||||
) {
|
||||
let entry = PassEntry {
|
||||
name: name.into(),
|
||||
enabled: true,
|
||||
pass: Box::new(pass),
|
||||
};
|
||||
match self.passes.iter().position(|e| e.name == before) {
|
||||
Some(index) => self.passes.insert(index, entry),
|
||||
None => self.passes.push(entry),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables or disables the named pass. Returns whether it exists.
|
||||
pub fn set_enabled(&mut self, name: &str, enabled: bool) -> bool {
|
||||
match self.passes.iter_mut().find(|e| e.name == name) {
|
||||
Some(entry) => {
|
||||
entry.enabled = enabled;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the named pass. Returns whether it existed.
|
||||
pub fn remove(&mut self, name: &str) -> bool {
|
||||
let before = self.passes.len();
|
||||
self.passes.retain(|e| e.name != name);
|
||||
self.passes.len() != before
|
||||
}
|
||||
|
||||
/// Whether a pass with this name is registered.
|
||||
pub fn has_pass(&self, name: &str) -> bool {
|
||||
self.passes.iter().any(|e| e.name == name)
|
||||
}
|
||||
|
||||
/// The pass names in execution order.
|
||||
pub fn pass_names(&self) -> impl Iterator<Item = &str> {
|
||||
self.passes.iter().map(|e| e.name.as_str())
|
||||
}
|
||||
|
||||
/// Runs every enabled pass in order against `frame`.
|
||||
pub fn render(&mut self, frame: &mut FrameContext<'_>) {
|
||||
for entry in &mut self.passes {
|
||||
if entry.enabled {
|
||||
entry.pass.run(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pass that clears the color target to [`FrameContext::clear_color`].
|
||||
///
|
||||
/// Conventionally the first pass, so later passes load over the cleared
|
||||
/// background (matching the Stage-4 clear-then-draw flow).
|
||||
pub struct ClearPass;
|
||||
|
||||
impl RenderPass for ClearPass {
|
||||
fn run(&mut self, frame: &mut FrameContext<'_>) {
|
||||
clear_view(frame.device, frame.queue, frame.color, frame.clear_color);
|
||||
}
|
||||
}
|
||||
|
||||
/// A pass that draws the frame's objects with the lit forward renderer.
|
||||
///
|
||||
/// Wraps the Stage-4 [`ForwardRenderer`]; the color target is *loaded* (so a
|
||||
/// preceding [`ClearPass`] shows through), depth is managed internally.
|
||||
pub struct ForwardPass {
|
||||
renderer: ForwardRenderer,
|
||||
}
|
||||
|
||||
impl ForwardPass {
|
||||
/// Builds a forward pass for the given color target format.
|
||||
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
|
||||
Self {
|
||||
renderer: ForwardRenderer::new(device, color_format),
|
||||
}
|
||||
}
|
||||
|
||||
/// The wrapped renderer's color format.
|
||||
pub fn color_format(&self) -> wgpu::TextureFormat {
|
||||
self.renderer.color_format()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderPass for ForwardPass {
|
||||
fn run(&mut self, frame: &mut FrameContext<'_>) {
|
||||
self.renderer.render(
|
||||
frame.device,
|
||||
frame.queue,
|
||||
frame.color,
|
||||
frame.size,
|
||||
frame.resolved_viewport(),
|
||||
frame.camera,
|
||||
frame.view_transform,
|
||||
frame.lighting,
|
||||
frame.objects,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Renderable scene components: [`MeshRenderer`] and [`PrimitiveShape`].
|
||||
//!
|
||||
//! A [`MeshRenderer`] is the component that makes a scene entity show up in the
|
||||
//! 3D viewport: it pairs a mesh source with a [`Material`]. Stage 4 ships the
|
||||
//! built-in [`PrimitiveShape`] source (cube/sphere/plane) — lightweight and
|
||||
//! serializable, so the editor (and later scripts/AI agents) can author what an
|
||||
//! entity renders. Imported meshes attach later via a mesh-asset handle.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{Material, Mesh};
|
||||
use crate::math::{Aabb, Vec3};
|
||||
|
||||
/// A built-in primitive mesh an entity can render.
|
||||
///
|
||||
/// This names a shape rather than embedding vertex data, so it stays tiny,
|
||||
/// serializable, and cheap to edit; the renderer resolves it to a (cached)
|
||||
/// [`Mesh`]/GPU buffer.
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Default,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
crate::reflect::ReflectEnum,
|
||||
)]
|
||||
pub enum PrimitiveShape {
|
||||
/// Unit cube centered at the origin.
|
||||
#[default]
|
||||
Cube,
|
||||
/// Unit-radius UV sphere.
|
||||
Sphere,
|
||||
/// A 1×1 ground plane on the XZ axes, facing `+Y`.
|
||||
Plane,
|
||||
}
|
||||
|
||||
impl PrimitiveShape {
|
||||
/// All shapes, for building caches / editor menus.
|
||||
pub const ALL: [PrimitiveShape; 3] = [
|
||||
PrimitiveShape::Cube,
|
||||
PrimitiveShape::Sphere,
|
||||
PrimitiveShape::Plane,
|
||||
];
|
||||
|
||||
/// A human-readable label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
PrimitiveShape::Cube => "Cube",
|
||||
PrimitiveShape::Sphere => "Sphere",
|
||||
PrimitiveShape::Plane => "Plane",
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the CPU [`Mesh`] for this shape.
|
||||
pub fn mesh(self) -> Mesh {
|
||||
match self {
|
||||
PrimitiveShape::Cube => Mesh::cube(),
|
||||
PrimitiveShape::Sphere => Mesh::uv_sphere(1.0, 32, 16),
|
||||
PrimitiveShape::Plane => Mesh::plane(1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The object-space bounds of this shape, without building a mesh — used for
|
||||
/// ray-picking and culling.
|
||||
pub fn local_bounds(self) -> Aabb {
|
||||
let half = match self {
|
||||
PrimitiveShape::Cube => Vec3::splat(0.5),
|
||||
PrimitiveShape::Sphere => Vec3::ONE,
|
||||
PrimitiveShape::Plane => Vec3::new(0.5, 0.0, 0.5),
|
||||
};
|
||||
Aabb::from_center_half_extents(Vec3::ZERO, half)
|
||||
}
|
||||
}
|
||||
|
||||
/// Component: what an entity renders.
|
||||
///
|
||||
/// Attach to a scene entity (via the ECS) to make it appear in a forward pass.
|
||||
/// Stage 4 sources the mesh from a [`PrimitiveShape`]; the [`Material`] is
|
||||
/// edited in the inspector. Both are serializable, supporting the engine's
|
||||
/// dual-editable (editor + script/AI) component goal.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, crate::reflect::Reflect,
|
||||
)]
|
||||
pub struct MeshRenderer {
|
||||
/// The mesh to draw.
|
||||
pub shape: PrimitiveShape,
|
||||
/// The surface material.
|
||||
pub material: Material,
|
||||
}
|
||||
|
||||
impl MeshRenderer {
|
||||
/// A renderer for `shape` with the default material.
|
||||
pub fn new(shape: PrimitiveShape) -> Self {
|
||||
Self {
|
||||
shape,
|
||||
material: Material::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A renderer for `shape` with an explicit `material`.
|
||||
pub fn with_material(shape: PrimitiveShape, material: Material) -> Self {
|
||||
Self { shape, material }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::Color;
|
||||
|
||||
#[test]
|
||||
fn every_shape_builds_a_nonempty_mesh() {
|
||||
for shape in PrimitiveShape::ALL {
|
||||
let mesh = shape.mesh();
|
||||
assert!(!mesh.vertices.is_empty(), "{shape:?} has no vertices");
|
||||
assert!(mesh.triangle_count() > 0, "{shape:?} has no triangles");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_renderer_round_trips_through_ron() {
|
||||
let mr = MeshRenderer::with_material(
|
||||
PrimitiveShape::Sphere,
|
||||
Material::metal(Color::rgb(0.2, 0.4, 0.8), 0.25),
|
||||
);
|
||||
let ron = ron::to_string(&mr).unwrap();
|
||||
let back: MeshRenderer = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(mr, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Stage 4 forward lit shader: a single directional light with Lambert diffuse,
|
||||
// ambient, and a Blinn-Phong specular term scaled by material roughness/metallic
|
||||
// (PBR-lite). Output is linear color; an sRGB surface format converts on write.
|
||||
|
||||
struct Globals {
|
||||
view_proj: mat4x4<f32>,
|
||||
camera_pos: vec4<f32>, // xyz world-space camera position
|
||||
light_dir: vec4<f32>, // xyz unit vector pointing TOWARD the light
|
||||
light_color: vec4<f32>, // rgb light color * intensity
|
||||
ambient: vec4<f32>, // rgb ambient term
|
||||
};
|
||||
|
||||
struct ObjectData {
|
||||
model: mat4x4<f32>,
|
||||
normal_mtx: mat4x4<f32>, // inverse-transpose of model (3x3 in a 4x4)
|
||||
albedo: vec4<f32>,
|
||||
mr: vec4<f32>, // x = metallic, y = roughness
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(1) @binding(0) var<uniform> obj: ObjectData;
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip_pos: vec4<f32>,
|
||||
@location(0) world_pos: vec3<f32>,
|
||||
@location(1) world_normal: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(
|
||||
@location(0) position: vec3<f32>,
|
||||
@location(1) normal: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
) -> VsOut {
|
||||
let world = obj.model * vec4<f32>(position, 1.0);
|
||||
var out: VsOut;
|
||||
out.world_pos = world.xyz;
|
||||
out.world_normal = (obj.normal_mtx * vec4<f32>(normal, 0.0)).xyz;
|
||||
out.uv = uv;
|
||||
out.clip_pos = globals.view_proj * world;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||
let n = normalize(in.world_normal);
|
||||
let l = normalize(globals.light_dir.xyz);
|
||||
let v = normalize(globals.camera_pos.xyz - in.world_pos);
|
||||
let h = normalize(l + v);
|
||||
|
||||
let albedo = obj.albedo.rgb;
|
||||
let metallic = obj.mr.x;
|
||||
let roughness = clamp(obj.mr.y, 0.04, 1.0);
|
||||
|
||||
let ndl = max(dot(n, l), 0.0);
|
||||
let ndh = max(dot(n, h), 0.0);
|
||||
|
||||
// Metals have no diffuse; dielectrics get a fixed 0.04 specular, metals
|
||||
// tint their specular by the albedo.
|
||||
let diffuse = albedo * (1.0 - metallic);
|
||||
let spec_color = mix(vec3<f32>(0.04), albedo, metallic);
|
||||
let spec_power = mix(8.0, 256.0, 1.0 - roughness);
|
||||
let spec = spec_color * pow(ndh, spec_power) * select(0.0, 1.0, ndl > 0.0);
|
||||
|
||||
let direct = (diffuse * ndl + spec) * globals.light_color.rgb;
|
||||
let ambient = albedo * globals.ambient.rgb;
|
||||
return vec4<f32>(ambient + direct, obj.albedo.a);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Oxide Stage-8 UI overlay shader.
|
||||
//
|
||||
// One vertex format covers both solid quads and glyph quads: the sentinel UV
|
||||
// `(-1, -1)` marks "solid color, do not sample the atlas". This avoids
|
||||
// branching on a separate flag attribute and keeps the vertex stride tight
|
||||
// (32 bytes — pos2 + uv2 + color4).
|
||||
|
||||
struct Uniforms {
|
||||
mvp: mat4x4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@group(0) @binding(2) var atlas_sampler: sampler;
|
||||
|
||||
struct VsIn {
|
||||
@location(0) position: vec2<f32>,
|
||||
@location(1) uv: vec2<f32>,
|
||||
@location(2) color: vec4<f32>,
|
||||
};
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip_pos: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(in: VsIn) -> VsOut {
|
||||
var out: VsOut;
|
||||
out.clip_pos = u.mvp * vec4<f32>(in.position, 0.0, 1.0);
|
||||
out.uv = in.uv;
|
||||
out.color = in.color;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||
// Solid quads use the sentinel UV (-1, -1). Sampling out-of-range would
|
||||
// be clamped or wrapped depending on the sampler, but we cheaply detect
|
||||
// it instead so a single texture binding serves every primitive.
|
||||
if (in.uv.x < 0.0) {
|
||||
return in.color;
|
||||
}
|
||||
let alpha = textureSample(atlas, atlas_sampler, in.uv).r;
|
||||
return vec4<f32>(in.color.rgb, in.color.a * alpha);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
//! [`DisabledComponents`]: a hidden per-entity set of disabled component names.
|
||||
//!
|
||||
//! Some game-objects need a component *attached but not active* — e.g. a
|
||||
//! camera that defaults disabled and a script turns it on at a trigger. ECS
|
||||
//! component-sets don't carry an "active" bit per component on their own, so
|
||||
//! this component stores the set of *type names* (matching the reflection
|
||||
//! registry) that should be skipped by systems on this entity.
|
||||
//!
|
||||
//! - Each engine system that runs on a per-entity component query consults
|
||||
//! [`Scene::is_component_disabled`](crate::scene::Scene::is_component_disabled)
|
||||
//! (or this component directly) before acting; it's the Unity
|
||||
//! "Component.enabled" equivalent in an archetypal ECS.
|
||||
//! - The editor inspector reads + writes it through a per-component
|
||||
//! checkbox, hides the component itself from view (it's metadata, not
|
||||
//! authored data), and copies the set on Duplicate.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Per-entity set of *disabled* component type names.
|
||||
///
|
||||
/// Names match the reflection registry (e.g. `"MeshRenderer"`). An absent
|
||||
/// component (or an empty set) means every component on the entity is active.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DisabledComponents {
|
||||
/// Disabled component type names. Stored as `String` so the data
|
||||
/// round-trips through RON without the `&'static str` reference issue.
|
||||
pub disabled: HashSet<String>,
|
||||
}
|
||||
|
||||
impl DisabledComponents {
|
||||
/// An empty set — every component is active.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Whether the component with this type name is disabled.
|
||||
pub fn is_disabled(&self, type_name: &str) -> bool {
|
||||
self.disabled.contains(type_name)
|
||||
}
|
||||
|
||||
/// Marks the component disabled (`true`) or active (`false`). Adds or
|
||||
/// removes the entry as needed.
|
||||
pub fn set_disabled(&mut self, type_name: &str, disabled: bool) {
|
||||
if disabled {
|
||||
self.disabled.insert(type_name.to_string());
|
||||
} else {
|
||||
self.disabled.remove(type_name);
|
||||
}
|
||||
}
|
||||
|
||||
/// True if no components are currently disabled — a hint to systems that
|
||||
/// the entire `DisabledComponents` component can be removed.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.disabled.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_empty_and_disables_nothing() {
|
||||
let d = DisabledComponents::new();
|
||||
assert!(d.is_empty());
|
||||
assert!(!d.is_disabled("MeshRenderer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_disabled_toggles_membership() {
|
||||
let mut d = DisabledComponents::new();
|
||||
d.set_disabled("MeshRenderer", true);
|
||||
assert!(d.is_disabled("MeshRenderer"));
|
||||
assert!(!d.is_empty());
|
||||
// Idempotent.
|
||||
d.set_disabled("MeshRenderer", true);
|
||||
assert_eq!(d.disabled.len(), 1);
|
||||
// Re-enable removes the entry.
|
||||
d.set_disabled("MeshRenderer", false);
|
||||
assert!(!d.is_disabled("MeshRenderer"));
|
||||
assert!(d.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_ron() {
|
||||
let mut d = DisabledComponents::new();
|
||||
d.set_disabled("MeshRenderer", true);
|
||||
d.set_disabled("RigidBody", true);
|
||||
let text = ron::to_string(&d).unwrap();
|
||||
let back: DisabledComponents = ron::from_str(&text).unwrap();
|
||||
assert_eq!(back, d);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
//! The [`Scene`]: entities, their components, and a transform hierarchy.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use hecs::{Component, Entity, World};
|
||||
|
||||
use super::SceneError;
|
||||
use crate::math::Transform;
|
||||
use crate::scene::node::Node;
|
||||
|
||||
/// What happens to an entity's children when it is despawned.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DespawnPolicy {
|
||||
/// Despawn the entity together with its entire subtree.
|
||||
Recursive,
|
||||
/// Despawn only the entity; reparent each child to the entity's parent,
|
||||
/// promoting them to roots if the entity was itself a root.
|
||||
DetachChildren,
|
||||
}
|
||||
|
||||
/// A scene graph: a [`hecs`] world plus a parent/child transform hierarchy.
|
||||
///
|
||||
/// Entities are [`hecs::Entity`] handles. Every entity created through the
|
||||
/// scene carries a [`Node`] and a (local) [`Transform`]; arbitrary additional
|
||||
/// components can be attached via [`world_mut`](Self::world_mut) for the
|
||||
/// systems added in later stages (meshes, rigid bodies, …).
|
||||
///
|
||||
/// The hierarchy is owned by the scene rather than stored as components, which
|
||||
/// keeps child ordering deterministic (important for serialization and the
|
||||
/// editor) and lets reparenting avoid archetype churn. Local transforms are the
|
||||
/// authored values; [`world_transform`](Self::world_transform) and
|
||||
/// [`world_transforms`](Self::world_transforms) resolve them against the
|
||||
/// hierarchy as `parent_world * local`.
|
||||
#[derive(Default)]
|
||||
pub struct Scene {
|
||||
world: World,
|
||||
/// Top-level entities, in insertion order.
|
||||
roots: Vec<Entity>,
|
||||
/// Child lists keyed by parent, each in insertion order. Entities with no
|
||||
/// children may be absent.
|
||||
children: HashMap<Entity, Vec<Entity>>,
|
||||
/// Upward links. Roots are absent from this map.
|
||||
parents: HashMap<Entity, Entity>,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Creates an empty scene.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// --- Lifecycle ---------------------------------------------------------
|
||||
|
||||
/// Spawns a new root entity carrying `node` and `transform`.
|
||||
///
|
||||
/// Every spawned entity automatically carries the three *node-baked*
|
||||
/// components: [`Node`], [`Transform`], and
|
||||
/// [`Layer`](crate::layer::Layer) (membership in the default layer).
|
||||
/// They are inherent to being an entity in this scene — single-instance,
|
||||
/// not added through the editor's "Add Component" menu, not removable.
|
||||
/// Modular components (`MeshRenderer`, future colliders, scripts, …) are
|
||||
/// attached on top.
|
||||
pub fn spawn(&mut self, node: impl Into<Node>, transform: Transform) -> Entity {
|
||||
let entity = self
|
||||
.world
|
||||
.spawn((node.into(), transform, crate::layer::Layer::default()));
|
||||
self.roots.push(entity);
|
||||
entity
|
||||
}
|
||||
|
||||
/// Spawns a new entity as a child of `parent`. Auto-attaches the same
|
||||
/// node-baked components as [`spawn`](Self::spawn).
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `parent` is not a live entity in this scene.
|
||||
pub fn spawn_child(
|
||||
&mut self,
|
||||
parent: Entity,
|
||||
node: impl Into<Node>,
|
||||
transform: Transform,
|
||||
) -> Entity {
|
||||
assert!(
|
||||
self.world.contains(parent),
|
||||
"spawn_child: parent {parent:?} is not a live entity in this scene"
|
||||
);
|
||||
let entity = self
|
||||
.world
|
||||
.spawn((node.into(), transform, crate::layer::Layer::default()));
|
||||
self.parents.insert(entity, parent);
|
||||
self.children.entry(parent).or_default().push(entity);
|
||||
entity
|
||||
}
|
||||
|
||||
/// Despawns `entity`, handling its children according to `policy`.
|
||||
///
|
||||
/// Returns `true` if the entity existed and was removed.
|
||||
pub fn despawn(&mut self, entity: Entity, policy: DespawnPolicy) -> bool {
|
||||
if !self.world.contains(entity) {
|
||||
return false;
|
||||
}
|
||||
// Remember the parent before unlinking, so DetachChildren can promote
|
||||
// the orphans to the right place.
|
||||
let grandparent = self.parents.get(&entity).copied();
|
||||
self.unlink(entity);
|
||||
|
||||
match policy {
|
||||
DespawnPolicy::Recursive => self.despawn_recursive(entity),
|
||||
DespawnPolicy::DetachChildren => {
|
||||
let kids = self.children.remove(&entity).unwrap_or_default();
|
||||
let _ = self.world.despawn(entity);
|
||||
for kid in kids {
|
||||
match grandparent {
|
||||
Some(gp) => {
|
||||
self.parents.insert(kid, gp);
|
||||
self.children.entry(gp).or_default().push(kid);
|
||||
}
|
||||
None => {
|
||||
self.parents.remove(&kid);
|
||||
self.roots.push(kid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Recursively despawns `entity` and everything beneath it. Assumes
|
||||
/// `entity` has already been unlinked from its parent / the root list.
|
||||
fn despawn_recursive(&mut self, entity: Entity) {
|
||||
let kids = self.children.remove(&entity).unwrap_or_default();
|
||||
self.parents.remove(&entity);
|
||||
let _ = self.world.despawn(entity);
|
||||
for kid in kids {
|
||||
self.despawn_recursive(kid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes `entity` from its parent's child list (or the root list) and
|
||||
/// from the parent map, without touching the entity itself.
|
||||
fn unlink(&mut self, entity: Entity) {
|
||||
match self.parents.remove(&entity) {
|
||||
Some(parent) => {
|
||||
if let Some(siblings) = self.children.get_mut(&parent) {
|
||||
siblings.retain(|&e| e != entity);
|
||||
}
|
||||
}
|
||||
None => self.roots.retain(|&e| e != entity),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hierarchy ---------------------------------------------------------
|
||||
|
||||
/// Reparents `entity` under `new_parent`, or makes it a root when
|
||||
/// `new_parent` is `None`. Child ordering places `entity` last among its
|
||||
/// new siblings.
|
||||
///
|
||||
/// Local transforms are preserved as-is (this does not compensate to keep
|
||||
/// the world transform fixed).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`SceneError::NoSuchEntity`] if `entity` or `new_parent` is not live.
|
||||
/// - [`SceneError::WouldCycle`] if `new_parent` is `entity` itself or one
|
||||
/// of its descendants.
|
||||
pub fn set_parent(
|
||||
&mut self,
|
||||
entity: Entity,
|
||||
new_parent: Option<Entity>,
|
||||
) -> Result<(), SceneError> {
|
||||
if !self.world.contains(entity) {
|
||||
return Err(SceneError::NoSuchEntity);
|
||||
}
|
||||
if let Some(parent) = new_parent {
|
||||
if !self.world.contains(parent) {
|
||||
return Err(SceneError::NoSuchEntity);
|
||||
}
|
||||
// Walking up from the prospective parent must not reach `entity`,
|
||||
// otherwise the link would form a cycle.
|
||||
if parent == entity || self.is_ancestor(entity, parent) {
|
||||
return Err(SceneError::WouldCycle);
|
||||
}
|
||||
}
|
||||
|
||||
self.unlink(entity);
|
||||
match new_parent {
|
||||
Some(parent) => {
|
||||
self.parents.insert(entity, parent);
|
||||
self.children.entry(parent).or_default().push(entity);
|
||||
}
|
||||
None => self.roots.push(entity),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Moves `entity` under `new_parent` (or to the root level when `None`),
|
||||
/// positioned **immediately before** sibling `before`. If `before` is
|
||||
/// `None` or isn't a child of the target, `entity` is appended.
|
||||
///
|
||||
/// Unlike [`set_parent`](Self::set_parent) (which always appends), this
|
||||
/// controls the sibling order, so it covers both reparenting *and*
|
||||
/// reordering within the same parent — the operation a hierarchy
|
||||
/// drag-and-drop with an insertion indicator needs. The position is
|
||||
/// resolved *after* unlinking `entity`, so reordering within one parent
|
||||
/// doesn't suffer an off-by-one. Rejects cycles like `set_parent`.
|
||||
pub fn reorder(
|
||||
&mut self,
|
||||
entity: Entity,
|
||||
new_parent: Option<Entity>,
|
||||
before: Option<Entity>,
|
||||
) -> Result<(), SceneError> {
|
||||
if !self.world.contains(entity) {
|
||||
return Err(SceneError::NoSuchEntity);
|
||||
}
|
||||
if let Some(parent) = new_parent {
|
||||
if !self.world.contains(parent) {
|
||||
return Err(SceneError::NoSuchEntity);
|
||||
}
|
||||
if parent == entity || self.is_ancestor(entity, parent) {
|
||||
return Err(SceneError::WouldCycle);
|
||||
}
|
||||
}
|
||||
|
||||
self.unlink(entity);
|
||||
let siblings = match new_parent {
|
||||
Some(parent) => {
|
||||
self.parents.insert(entity, parent);
|
||||
self.children.entry(parent).or_default()
|
||||
}
|
||||
None => &mut self.roots,
|
||||
};
|
||||
let index = before
|
||||
.and_then(|b| siblings.iter().position(|&e| e == b))
|
||||
.unwrap_or(siblings.len());
|
||||
siblings.insert(index, entity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` if `ancestor` lies on the parent chain above `entity`.
|
||||
fn is_ancestor(&self, ancestor: Entity, entity: Entity) -> bool {
|
||||
let mut cursor = self.parents.get(&entity).copied();
|
||||
while let Some(p) = cursor {
|
||||
if p == ancestor {
|
||||
return true;
|
||||
}
|
||||
cursor = self.parents.get(&p).copied();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The parent of `entity`, or `None` if it is a root or absent.
|
||||
pub fn parent(&self, entity: Entity) -> Option<Entity> {
|
||||
self.parents.get(&entity).copied()
|
||||
}
|
||||
|
||||
/// The direct children of `entity`, in order. Empty for leaves.
|
||||
pub fn children(&self, entity: Entity) -> &[Entity] {
|
||||
self.children.get(&entity).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// The top-level entities, in insertion order.
|
||||
pub fn roots(&self) -> &[Entity] {
|
||||
&self.roots
|
||||
}
|
||||
|
||||
// --- Component access --------------------------------------------------
|
||||
|
||||
/// The node name, or `None` if `entity` is not live.
|
||||
pub fn name(&self, entity: Entity) -> Option<String> {
|
||||
self.world.get::<&Node>(entity).ok().map(|n| n.name.clone())
|
||||
}
|
||||
|
||||
/// Renames `entity`. Returns `false` if it is not live.
|
||||
pub fn set_name(&mut self, entity: Entity, name: impl Into<String>) -> bool {
|
||||
match self.world.get::<&mut Node>(entity) {
|
||||
Ok(mut node) => {
|
||||
node.name = name.into();
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `entity` is enabled, or `None` if it is not live.
|
||||
pub fn is_enabled(&self, entity: Entity) -> Option<bool> {
|
||||
self.world.get::<&Node>(entity).ok().map(|n| n.enabled)
|
||||
}
|
||||
|
||||
/// Sets the enabled flag on `entity`. Returns `false` if it is not live.
|
||||
pub fn set_enabled(&mut self, entity: Entity, enabled: bool) -> bool {
|
||||
match self.world.get::<&mut Node>(entity) {
|
||||
Ok(mut node) => {
|
||||
node.enabled = enabled;
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the named component on `entity` is marked disabled by a
|
||||
/// [`DisabledComponents`](crate::scene::DisabledComponents) component.
|
||||
/// Defaults to `false` when no `DisabledComponents` is attached.
|
||||
///
|
||||
/// Systems that act on a per-entity component query check this to honor
|
||||
/// "attached but inactive" — the ECS equivalent of Unity's
|
||||
/// `Component.enabled = false`.
|
||||
pub fn is_component_disabled(&self, entity: hecs::Entity, type_name: &str) -> bool {
|
||||
self.world
|
||||
.get::<&super::DisabledComponents>(entity)
|
||||
.ok()
|
||||
.map(|d| d.is_disabled(type_name))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether `entity` is enabled **and every ancestor is enabled** — its
|
||||
/// effective state in the hierarchy. `None` if it is not live.
|
||||
///
|
||||
/// [`is_enabled`](Self::is_enabled) reports an entity's own authored flag;
|
||||
/// this reports whether it is actually active, since disabling a node
|
||||
/// disables its whole subtree (rendering, physics, audio, and queries skip
|
||||
/// effectively-disabled entities). This is the Unity/Godot
|
||||
/// `activeInHierarchy` distinction: the per-node flag is what you author,
|
||||
/// the effective value is what systems honor.
|
||||
pub fn is_effectively_enabled(&self, entity: Entity) -> Option<bool> {
|
||||
if !self.contains(entity) {
|
||||
return None;
|
||||
}
|
||||
let mut current = Some(entity);
|
||||
while let Some(e) = current {
|
||||
if !self.is_enabled(e).unwrap_or(true) {
|
||||
return Some(false);
|
||||
}
|
||||
current = self.parent(e);
|
||||
}
|
||||
Some(true)
|
||||
}
|
||||
|
||||
/// The authored (local) transform of `entity`, or `None` if not live.
|
||||
pub fn local_transform(&self, entity: Entity) -> Option<Transform> {
|
||||
self.world.get::<&Transform>(entity).ok().map(|t| *t)
|
||||
}
|
||||
|
||||
/// Sets the local transform of `entity`. Returns `false` if not live.
|
||||
pub fn set_local_transform(&mut self, entity: Entity, transform: Transform) -> bool {
|
||||
match self.world.get::<&mut Transform>(entity) {
|
||||
Ok(mut t) => {
|
||||
*t = transform;
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- World transforms --------------------------------------------------
|
||||
|
||||
/// Resolves the world-space transform of a single `entity` by composing
|
||||
/// local transforms up the parent chain. `None` if `entity` is not live.
|
||||
///
|
||||
/// For resolving many entities at once, prefer
|
||||
/// [`world_transforms`](Self::world_transforms), which is a single pass.
|
||||
pub fn world_transform(&self, entity: Entity) -> Option<Transform> {
|
||||
let local = self.local_transform(entity)?;
|
||||
match self.parents.get(&entity) {
|
||||
Some(&parent) => Some(self.world_transform(parent)?.mul_transform(&local)),
|
||||
None => Some(local),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves world-space transforms for every entity in the scene in a
|
||||
/// single top-down pass (`parent_world * local`).
|
||||
pub fn world_transforms(&self) -> HashMap<Entity, Transform> {
|
||||
let mut out = HashMap::with_capacity(self.len());
|
||||
// Depth-first from each root, carrying the accumulated parent world
|
||||
// transform down the stack.
|
||||
let mut stack: Vec<(Entity, Transform)> = Vec::new();
|
||||
for &root in &self.roots {
|
||||
if let Some(local) = self.local_transform(root) {
|
||||
stack.push((root, local));
|
||||
}
|
||||
}
|
||||
while let Some((entity, world)) = stack.pop() {
|
||||
out.insert(entity, world);
|
||||
if let Some(children) = self.children.get(&entity) {
|
||||
for &child in children {
|
||||
if let Some(local) = self.local_transform(child) {
|
||||
stack.push((child, world.mul_transform(&local)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// --- ECS access --------------------------------------------------------
|
||||
|
||||
/// Whether `entity` is live in this scene.
|
||||
pub fn contains(&self, entity: Entity) -> bool {
|
||||
self.world.contains(entity)
|
||||
}
|
||||
|
||||
/// Number of live entities.
|
||||
pub fn len(&self) -> usize {
|
||||
self.world.len() as usize
|
||||
}
|
||||
|
||||
/// Whether the scene has no entities.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.world.len() == 0
|
||||
}
|
||||
|
||||
/// An iterator over every live entity, in unspecified order.
|
||||
pub fn entities(&self) -> impl Iterator<Item = Entity> + '_ {
|
||||
self.world.iter().map(|e| e.entity())
|
||||
}
|
||||
|
||||
/// Borrows a component of `entity`, e.g. `scene.get::<Transform>(e)`.
|
||||
pub fn get<T: Component>(&self, entity: Entity) -> Option<hecs::Ref<'_, T>> {
|
||||
self.world.get::<&T>(entity).ok()
|
||||
}
|
||||
|
||||
/// Mutably borrows a component of `entity`.
|
||||
///
|
||||
/// Do not mutate hierarchy state through here — use the scene's own
|
||||
/// methods so the parent/child bookkeeping stays consistent.
|
||||
pub fn get_mut<T: Component>(&mut self, entity: Entity) -> Option<hecs::RefMut<'_, T>> {
|
||||
self.world.get::<&mut T>(entity).ok()
|
||||
}
|
||||
|
||||
/// The underlying [`hecs::World`], for read-only ECS queries.
|
||||
pub fn world(&self) -> &World {
|
||||
&self.world
|
||||
}
|
||||
|
||||
/// The underlying [`hecs::World`], for attaching extra components.
|
||||
///
|
||||
/// Spawning or despawning directly through the world bypasses the scene's
|
||||
/// hierarchy bookkeeping; use [`spawn`](Self::spawn) /
|
||||
/// [`despawn`](Self::despawn) for lifecycle and reserve this for adding or
|
||||
/// querying non-hierarchy components.
|
||||
pub fn world_mut(&mut self) -> &mut World {
|
||||
&mut self.world
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::Vec3;
|
||||
|
||||
fn t(x: f32, y: f32, z: f32) -> Transform {
|
||||
Transform::from_translation(Vec3::new(x, y, z))
|
||||
}
|
||||
|
||||
fn approx(a: Vec3, b: Vec3) -> bool {
|
||||
(a - b).length() <= 1e-5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_makes_roots() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
let b = scene.spawn("b", Transform::IDENTITY);
|
||||
assert_eq!(scene.roots(), &[a, b]);
|
||||
assert_eq!(scene.len(), 2);
|
||||
assert_eq!(scene.parent(a), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_child_links_both_ways() {
|
||||
let mut scene = Scene::new();
|
||||
let parent = scene.spawn("parent", Transform::IDENTITY);
|
||||
let child = scene.spawn_child(parent, "child", Transform::IDENTITY);
|
||||
assert_eq!(scene.parent(child), Some(parent));
|
||||
assert_eq!(scene.children(parent), &[child]);
|
||||
assert_eq!(scene.roots(), &[parent]); // child is not a root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_transform_composes_down_the_chain() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", t(1.0, 0.0, 0.0));
|
||||
let b = scene.spawn_child(a, "b", t(0.0, 2.0, 0.0));
|
||||
let c = scene.spawn_child(b, "c", t(0.0, 0.0, 3.0));
|
||||
let w = scene.world_transform(c).unwrap();
|
||||
assert!(approx(w.translation, Vec3::new(1.0, 2.0, 3.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_world_transforms_match_single() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", t(5.0, 0.0, 0.0));
|
||||
let b = scene.spawn_child(a, "b", t(0.0, 1.0, 0.0));
|
||||
let c = scene.spawn_child(a, "c", t(0.0, 0.0, 1.0));
|
||||
let all = scene.world_transforms();
|
||||
for e in [a, b, c] {
|
||||
assert!(approx(
|
||||
all[&e].translation,
|
||||
scene.world_transform(e).unwrap().translation
|
||||
));
|
||||
}
|
||||
assert!(approx(all[&b].translation, Vec3::new(5.0, 1.0, 0.0)));
|
||||
assert!(approx(all[&c].translation, Vec3::new(5.0, 0.0, 1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_propagates_to_children() {
|
||||
use crate::math::Quat;
|
||||
use std::f32::consts::FRAC_PI_2;
|
||||
let mut scene = Scene::new();
|
||||
// Parent rotated 90° about Z, child offset +X by 1.
|
||||
let parent = scene.spawn(
|
||||
"p",
|
||||
Transform::from_rotation(Quat::from_rotation_z(FRAC_PI_2)),
|
||||
);
|
||||
let child = scene.spawn_child(parent, "c", t(1.0, 0.0, 0.0));
|
||||
let w = scene.world_transform(child).unwrap();
|
||||
// The +X offset is rotated into +Y by the parent.
|
||||
assert!(approx(w.translation, Vec3::new(0.0, 1.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn despawn_recursive_removes_subtree() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
let b = scene.spawn_child(a, "b", Transform::IDENTITY);
|
||||
let c = scene.spawn_child(b, "c", Transform::IDENTITY);
|
||||
assert!(scene.despawn(a, DespawnPolicy::Recursive));
|
||||
assert!(!scene.contains(a));
|
||||
assert!(!scene.contains(b));
|
||||
assert!(!scene.contains(c));
|
||||
assert!(scene.roots().is_empty());
|
||||
assert_eq!(scene.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn despawn_detach_promotes_children_to_grandparent() {
|
||||
let mut scene = Scene::new();
|
||||
let root = scene.spawn("root", Transform::IDENTITY);
|
||||
let mid = scene.spawn_child(root, "mid", Transform::IDENTITY);
|
||||
let leaf = scene.spawn_child(mid, "leaf", Transform::IDENTITY);
|
||||
assert!(scene.despawn(mid, DespawnPolicy::DetachChildren));
|
||||
assert!(!scene.contains(mid));
|
||||
assert!(scene.contains(leaf));
|
||||
// leaf is now a child of root directly.
|
||||
assert_eq!(scene.parent(leaf), Some(root));
|
||||
assert_eq!(scene.children(root), &[leaf]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn despawn_detach_root_promotes_children_to_roots() {
|
||||
let mut scene = Scene::new();
|
||||
let root = scene.spawn("root", Transform::IDENTITY);
|
||||
let child = scene.spawn_child(root, "child", Transform::IDENTITY);
|
||||
assert!(scene.despawn(root, DespawnPolicy::DetachChildren));
|
||||
assert_eq!(scene.parent(child), None);
|
||||
assert_eq!(scene.roots(), &[child]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_updates_links() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
let b = scene.spawn("b", Transform::IDENTITY);
|
||||
let c = scene.spawn_child(a, "c", Transform::IDENTITY);
|
||||
scene.set_parent(c, Some(b)).unwrap();
|
||||
assert_eq!(scene.parent(c), Some(b));
|
||||
assert_eq!(scene.children(a), &[] as &[Entity]);
|
||||
assert_eq!(scene.children(b), &[c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_to_root() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
let c = scene.spawn_child(a, "c", Transform::IDENTITY);
|
||||
scene.set_parent(c, None).unwrap();
|
||||
assert_eq!(scene.parent(c), None);
|
||||
assert!(scene.roots().contains(&c));
|
||||
assert_eq!(scene.children(a), &[] as &[Entity]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_cycle_is_rejected() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
let b = scene.spawn_child(a, "b", Transform::IDENTITY);
|
||||
// Making `a` a child of its own descendant `b` would form a cycle.
|
||||
assert_eq!(scene.set_parent(a, Some(b)), Err(SceneError::WouldCycle));
|
||||
// Self-parenting is also a cycle.
|
||||
assert_eq!(scene.set_parent(a, Some(a)), Err(SceneError::WouldCycle));
|
||||
// The hierarchy is unchanged.
|
||||
assert_eq!(scene.parent(b), Some(a));
|
||||
assert_eq!(scene.parent(a), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_missing_entity_errors() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
scene.despawn(a, DespawnPolicy::Recursive);
|
||||
assert_eq!(scene.set_parent(a, None), Err(SceneError::NoSuchEntity));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_and_rename() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
assert_eq!(scene.is_enabled(a), Some(true));
|
||||
assert!(scene.set_enabled(a, false));
|
||||
assert_eq!(scene.is_enabled(a), Some(false));
|
||||
assert!(scene.set_name(a, "renamed"));
|
||||
assert_eq!(scene.name(a).as_deref(), Some("renamed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_moves_within_and_between_parents() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
let b = scene.spawn("b", Transform::IDENTITY);
|
||||
let c = scene.spawn("c", Transform::IDENTITY);
|
||||
assert_eq!(scene.roots(), &[a, b, c]);
|
||||
|
||||
// Reorder within the root list: move c before a → [c, a, b].
|
||||
scene.reorder(c, None, Some(a)).unwrap();
|
||||
assert_eq!(scene.roots(), &[c, a, b]);
|
||||
|
||||
// Same-parent move that crosses its old slot (no off-by-one): move c to
|
||||
// just before b → [a, c, b].
|
||||
scene.reorder(c, None, Some(b)).unwrap();
|
||||
assert_eq!(scene.roots(), &[a, c, b]);
|
||||
|
||||
// Reparent + position: put b under a, before none → appended child.
|
||||
scene.reorder(b, Some(a), None).unwrap();
|
||||
assert_eq!(scene.roots(), &[a, c]);
|
||||
assert_eq!(scene.children(a), &[b]);
|
||||
assert_eq!(scene.parent(b), Some(a));
|
||||
|
||||
// Insert before an existing child: c under a, before b → [c, b].
|
||||
scene.reorder(c, Some(a), Some(b)).unwrap();
|
||||
assert_eq!(scene.children(a), &[c, b]);
|
||||
|
||||
// Cycle rejected: a cannot become a child of its descendant b.
|
||||
assert!(matches!(
|
||||
scene.reorder(a, Some(b), None),
|
||||
Err(SceneError::WouldCycle)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_auto_attaches_layers_default() {
|
||||
// Every entity is inherently *on* some layer (the default if not
|
||||
// overridden) — so Layer is a node-baked component the scene always
|
||||
// provides, not something the user has to add. Pinned here.
|
||||
use super::super::super::layer::Layer;
|
||||
let mut scene = Scene::new();
|
||||
let e = scene.spawn("e", Transform::IDENTITY);
|
||||
{
|
||||
let layers = scene.world().get::<&Layer>(e).expect("Layer attached");
|
||||
assert_eq!(*layers, Layer::DEFAULT);
|
||||
}
|
||||
|
||||
let child = scene.spawn_child(e, "child", Transform::IDENTITY);
|
||||
let layers = scene
|
||||
.world()
|
||||
.get::<&Layer>(child)
|
||||
.expect("child gets Layer too");
|
||||
assert_eq!(*layers, Layer::DEFAULT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_component_disabled_reads_the_disabled_set() {
|
||||
use super::super::DisabledComponents;
|
||||
let mut scene = Scene::new();
|
||||
let e = scene.spawn("e", Transform::IDENTITY);
|
||||
// No DisabledComponents attached → nothing is disabled.
|
||||
assert!(!scene.is_component_disabled(e, "MeshRenderer"));
|
||||
|
||||
let mut d = DisabledComponents::new();
|
||||
d.set_disabled("MeshRenderer", true);
|
||||
scene.world_mut().insert_one(e, d).unwrap();
|
||||
assert!(scene.is_component_disabled(e, "MeshRenderer"));
|
||||
assert!(!scene.is_component_disabled(e, "RigidBody"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_enabled_cascades_from_ancestors() {
|
||||
let mut scene = Scene::new();
|
||||
let player = scene.spawn("player", Transform::IDENTITY);
|
||||
let camera = scene.spawn_child(player, "camera", Transform::IDENTITY);
|
||||
let mesh = scene.spawn_child(camera, "mesh", Transform::IDENTITY);
|
||||
|
||||
// All enabled by default → effectively enabled.
|
||||
assert_eq!(scene.is_effectively_enabled(mesh), Some(true));
|
||||
|
||||
// Disabling the root disables the whole subtree's effective state,
|
||||
// even though each descendant's own flag is still true.
|
||||
scene.set_enabled(player, false);
|
||||
assert_eq!(scene.is_enabled(camera), Some(true)); // own flag unchanged
|
||||
assert_eq!(scene.is_effectively_enabled(camera), Some(false));
|
||||
assert_eq!(scene.is_effectively_enabled(mesh), Some(false));
|
||||
|
||||
// Re-enable the root; disable a middle node → only it + below are off.
|
||||
scene.set_enabled(player, true);
|
||||
scene.set_enabled(camera, false);
|
||||
assert_eq!(scene.is_effectively_enabled(player), Some(true));
|
||||
assert_eq!(scene.is_effectively_enabled(camera), Some(false));
|
||||
assert_eq!(scene.is_effectively_enabled(mesh), Some(false));
|
||||
|
||||
// A dead entity has no effective state.
|
||||
let ghost = scene.spawn("ghost", Transform::IDENTITY);
|
||||
scene.despawn(ghost, DespawnPolicy::Recursive);
|
||||
assert_eq!(scene.is_effectively_enabled(ghost), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_components_via_world() {
|
||||
// The scene is a real ECS: extra components can ride along on entities.
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
scene.world_mut().insert_one(a, 42u32).unwrap();
|
||||
assert_eq!(*scene.get::<u32>(a).unwrap(), 42);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Scene graph and entity management.
|
||||
//!
|
||||
//! Stage 3 builds the world model every later system plugs into. Entities are
|
||||
//! [`hecs`] handles living in a [`Scene`], which adds a parent/child
|
||||
//! [`Transform`](crate::math::Transform) hierarchy on top of the bare ECS:
|
||||
//!
|
||||
//! - [`Scene`] — owns the entities and the hierarchy; spawn, despawn,
|
||||
//! reparent, query, and resolve world-space transforms
|
||||
//! - [`Node`] — per-entity metadata (`name`, `enabled`)
|
||||
//! - [`DespawnPolicy`] — whether despawning takes the subtree with it or
|
||||
//! detaches the children
|
||||
//!
|
||||
//! Local transforms are authored per entity; the scene resolves them against
|
||||
//! the hierarchy on demand ([`Scene::world_transform`],
|
||||
//! [`Scene::world_transforms`]). The node-baked hierarchy serializes to RON via
|
||||
//! [`Scene::to_ron`] / [`Scene::from_ron`]; a registry-aware
|
||||
//! [`SceneSnapshot`] (via [`Scene::snapshot`]) additionally captures every
|
||||
//! reflected component, for play-mode restore and full scene files.
|
||||
//!
|
||||
//! `hecs` is re-exported as [`oxide_engine::hecs`](crate::hecs) so consumers
|
||||
//! share one copy of [`Entity`](hecs::Entity) and the query API.
|
||||
|
||||
mod disabled;
|
||||
mod graph;
|
||||
mod node;
|
||||
mod serialize;
|
||||
mod snapshot;
|
||||
|
||||
pub use disabled::DisabledComponents;
|
||||
pub use graph::{DespawnPolicy, Scene};
|
||||
pub use node::Node;
|
||||
pub use snapshot::SceneSnapshot;
|
||||
|
||||
// The handle type is part of the public scene API; re-export it here so callers
|
||||
// can name it without reaching into the `hecs` re-export.
|
||||
pub use hecs::Entity;
|
||||
|
||||
/// Errors produced by scene operations.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum SceneError {
|
||||
/// An operation referenced an entity that is not live in this scene.
|
||||
#[error("entity does not exist in this scene")]
|
||||
NoSuchEntity,
|
||||
|
||||
/// A reparent would have made an entity its own ancestor.
|
||||
#[error("cannot parent an entity to itself or one of its descendants")]
|
||||
WouldCycle,
|
||||
|
||||
/// Encoding the scene to RON failed.
|
||||
#[error("scene serialization failed: {0}")]
|
||||
Serialize(String),
|
||||
|
||||
/// Decoding the scene from RON failed, or the data was inconsistent.
|
||||
#[error("scene deserialization failed: {0}")]
|
||||
Deserialize(String),
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! The [`Node`] component: per-entity scene metadata.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Metadata attached to every entity that participates in the scene graph.
|
||||
///
|
||||
/// A `Node` carries the human-facing identity of an entity (its `name`, shown
|
||||
/// in the editor hierarchy) and an `enabled` flag. Disabling a node is a
|
||||
/// declaration of intent that later systems honor — rendering, physics, and
|
||||
/// audio skip disabled subtrees — but it does **not** affect transform
|
||||
/// resolution, which is purely geometric. Stage 3 only stores and edits the
|
||||
/// flag; the systems that act on it arrive in later stages.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, crate::reflect::Reflect)]
|
||||
pub struct Node {
|
||||
/// Display name. Need not be unique; entities are identified by their
|
||||
/// [`Entity`](hecs::Entity) handle, not by name.
|
||||
pub name: String,
|
||||
/// Whether this node (and, by convention, its subtree) is active.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Creates an enabled node with the given name.
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Node {
|
||||
/// An enabled, unnamed node.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Into<String>> From<T> for Node {
|
||||
fn from(name: T) -> Self {
|
||||
Self::new(name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! RON serialization for [`Scene`].
|
||||
//!
|
||||
//! `hecs::Entity` handles are runtime values that are not stable across a
|
||||
//! save/load, so the scene is flattened to a list of records with array
|
||||
//! indices standing in for entity references. The list is built in a
|
||||
//! deterministic pre-order walk of the hierarchy, so a serialize → deserialize
|
||||
//! → serialize cycle is byte-for-byte stable.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use hecs::Entity;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{Node, Scene, SceneError};
|
||||
use crate::math::Transform;
|
||||
|
||||
/// One entity in the flattened scene. `children` holds indices into the
|
||||
/// surrounding [`SceneData::nodes`] list.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct NodeRecord {
|
||||
name: String,
|
||||
enabled: bool,
|
||||
transform: Transform,
|
||||
children: Vec<usize>,
|
||||
}
|
||||
|
||||
/// The serializable form of a [`Scene`]: a flat node list plus the indices of
|
||||
/// the root nodes. Parent links are implied by the `children` arrays.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct SceneData {
|
||||
nodes: Vec<NodeRecord>,
|
||||
roots: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Serializes the scene to a pretty-printed RON string.
|
||||
pub fn to_ron(&self) -> Result<String, SceneError> {
|
||||
let data = self.to_data();
|
||||
ron::ser::to_string_pretty(&data, ron::ser::PrettyConfig::default())
|
||||
.map_err(|e| SceneError::Serialize(e.to_string()))
|
||||
}
|
||||
|
||||
/// Reconstructs a scene from a RON string produced by [`to_ron`](Self::to_ron).
|
||||
pub fn from_ron(ron: &str) -> Result<Scene, SceneError> {
|
||||
let data: SceneData =
|
||||
ron::from_str(ron).map_err(|e| SceneError::Deserialize(e.to_string()))?;
|
||||
Scene::from_data(&data)
|
||||
}
|
||||
|
||||
/// Flattens the hierarchy into index-based records via a deterministic
|
||||
/// pre-order walk (roots in order, then each subtree depth-first).
|
||||
fn to_data(&self) -> SceneData {
|
||||
let mut index: HashMap<Entity, usize> = HashMap::with_capacity(self.len());
|
||||
let mut order: Vec<Entity> = Vec::with_capacity(self.len());
|
||||
for &root in self.roots() {
|
||||
self.assign_indices(root, &mut index, &mut order);
|
||||
}
|
||||
|
||||
let nodes = order
|
||||
.iter()
|
||||
.map(|&entity| {
|
||||
let node = self
|
||||
.get::<Node>(entity)
|
||||
.expect("entity in hierarchy must have a Node");
|
||||
let transform = self
|
||||
.local_transform(entity)
|
||||
.expect("entity in hierarchy must have a Transform");
|
||||
NodeRecord {
|
||||
name: node.name.clone(),
|
||||
enabled: node.enabled,
|
||||
transform,
|
||||
children: self.children(entity).iter().map(|c| index[c]).collect(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let roots = self.roots().iter().map(|r| index[r]).collect();
|
||||
SceneData { nodes, roots }
|
||||
}
|
||||
|
||||
/// Pre-order index assignment helper for [`to_data`](Self::to_data).
|
||||
fn assign_indices(
|
||||
&self,
|
||||
entity: Entity,
|
||||
index: &mut HashMap<Entity, usize>,
|
||||
order: &mut Vec<Entity>,
|
||||
) {
|
||||
index.insert(entity, order.len());
|
||||
order.push(entity);
|
||||
for &child in self.children(entity) {
|
||||
self.assign_indices(child, index, order);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds a scene from flattened records, validating index references.
|
||||
fn from_data(data: &SceneData) -> Result<Scene, SceneError> {
|
||||
let mut scene = Scene::new();
|
||||
let n = data.nodes.len();
|
||||
|
||||
// Spawn every entity first (as a root), so all indices resolve before
|
||||
// wiring up parent/child links.
|
||||
let entities: Vec<Entity> = data
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|rec| {
|
||||
scene.spawn(
|
||||
Node {
|
||||
name: rec.name.clone(),
|
||||
enabled: rec.enabled,
|
||||
},
|
||||
rec.transform,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Re-link: each record's children become children of that record's
|
||||
// entity (and are removed from the root list).
|
||||
for (i, rec) in data.nodes.iter().enumerate() {
|
||||
for &child_idx in &rec.children {
|
||||
let child = *entities
|
||||
.get(child_idx)
|
||||
.ok_or(SceneError::Deserialize(format!(
|
||||
"child index {child_idx} out of range (have {n} nodes)"
|
||||
)))?;
|
||||
scene
|
||||
.set_parent(child, Some(entities[i]))
|
||||
.map_err(|e| SceneError::Deserialize(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the declared roots match the entities left parentless. The
|
||||
// re-link above already produced the correct root set; we just confirm
|
||||
// the file's `roots` list is consistent so corrupt input is rejected.
|
||||
for &root_idx in &data.roots {
|
||||
let entity = *entities
|
||||
.get(root_idx)
|
||||
.ok_or(SceneError::Deserialize(format!(
|
||||
"root index {root_idx} out of range (have {n} nodes)"
|
||||
)))?;
|
||||
if scene.parent(entity).is_some() {
|
||||
return Err(SceneError::Deserialize(format!(
|
||||
"node {root_idx} is listed as a root but is also a child"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(scene)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::{Quat, Vec3};
|
||||
use crate::scene::DespawnPolicy;
|
||||
|
||||
/// Builds a small, varied scene used by the round-trip tests.
|
||||
fn sample() -> Scene {
|
||||
let mut scene = Scene::new();
|
||||
let root = scene.spawn(
|
||||
Node {
|
||||
name: "root".into(),
|
||||
enabled: true,
|
||||
},
|
||||
Transform::from_translation(Vec3::new(1.0, 2.0, 3.0)),
|
||||
);
|
||||
let arm = scene.spawn_child(
|
||||
root,
|
||||
"arm",
|
||||
Transform::from_rotation(Quat::from_rotation_y(0.5)),
|
||||
);
|
||||
scene.spawn_child(arm, "hand", Transform::from_scale(Vec3::splat(2.0)));
|
||||
let mut disabled = Node::new("disabled");
|
||||
disabled.enabled = false;
|
||||
scene.spawn_child(root, disabled, Transform::IDENTITY);
|
||||
// A second independent root, to exercise multi-root serialization.
|
||||
scene.spawn("other-root", Transform::from_translation(Vec3::NEG_X));
|
||||
scene
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_structure() {
|
||||
let scene = sample();
|
||||
let ron = scene.to_ron().unwrap();
|
||||
let restored = Scene::from_ron(&ron).unwrap();
|
||||
|
||||
// Re-serializing the restored scene yields identical text: structure,
|
||||
// names, flags, transforms, and ordering all survived.
|
||||
assert_eq!(ron, restored.to_ron().unwrap());
|
||||
assert_eq!(scene.len(), restored.len());
|
||||
assert_eq!(scene.roots().len(), restored.roots().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_world_transforms() {
|
||||
let scene = sample();
|
||||
let restored = Scene::from_ron(&scene.to_ron().unwrap()).unwrap();
|
||||
|
||||
// Compare resolved world transforms by name, since entity ids differ
|
||||
// across the rebuild.
|
||||
let by_name = |s: &Scene| -> Vec<(String, Vec3)> {
|
||||
let worlds = s.world_transforms();
|
||||
let mut v: Vec<_> = worlds
|
||||
.iter()
|
||||
.map(|(&e, t)| (s.name(e).unwrap(), t.translation))
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
v
|
||||
};
|
||||
let a = by_name(&scene);
|
||||
let b = by_name(&restored);
|
||||
assert_eq!(a.len(), b.len());
|
||||
for ((na, ta), (nb, tb)) in a.iter().zip(b.iter()) {
|
||||
assert_eq!(na, nb);
|
||||
assert!((*ta - *tb).length() <= 1e-5, "{na}: {ta} vs {tb}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_scene_round_trips() {
|
||||
let scene = Scene::new();
|
||||
let restored = Scene::from_ron(&scene.to_ron().unwrap()).unwrap();
|
||||
assert!(restored.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reordering_after_edits_still_round_trips() {
|
||||
// Mutating the scene (despawn) must not break index bookkeeping.
|
||||
let mut scene = sample();
|
||||
let root = scene.roots()[0];
|
||||
let kid = scene.children(root)[0];
|
||||
scene.despawn(kid, DespawnPolicy::DetachChildren);
|
||||
let ron = scene.to_ron().unwrap();
|
||||
assert_eq!(ron, Scene::from_ron(&ron).unwrap().to_ron().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_child_index_is_rejected() {
|
||||
let bad = r#"(nodes: [(name: "a", enabled: true, transform: (translation: (0,0,0), rotation: (0,0,0,1), scale: (1,1,1)), children: [5])], roots: [0])"#;
|
||||
assert!(Scene::from_ron(bad).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Registry-aware full-scene capture, for play mode (and future scene files).
|
||||
//!
|
||||
//! [`Scene::to_ron`](super::Scene::to_ron) records only the node-baked
|
||||
//! `Node`/`Transform`/hierarchy. A [`SceneSnapshot`] additionally captures
|
||||
//! **every reflected component** on each entity through the
|
||||
//! [`TypeRegistry`], so a scene mutated while *playing* (physics moving bodies,
|
||||
//! scripts spawning or editing entities) can be restored **bit-for-bit** when
|
||||
//! play stops — avoiding Unity's classic "edited in play mode, lost it" footgun.
|
||||
//!
|
||||
//! Why a separate type from [`SceneData`](super::serialize): the plain RON form
|
||||
//! is registry-free (it can round-trip without knowing any component types),
|
||||
//! whereas a snapshot needs the registry to enumerate and serialize arbitrary
|
||||
//! components. Keeping the two apart means the cheap path stays cheap.
|
||||
//!
|
||||
//! Fidelity is bounded by what is *registered*, plus the engine's intrinsic
|
||||
//! components: every reflected type in the [`TypeRegistry`] is captured, as are
|
||||
//! the built-in non-reflected components (`Node`/`Transform`, plus the inspector-
|
||||
//! hidden [`Tags`] and [`DisabledComponents`]). A *module's* component that is
|
||||
//! neither registered nor one of those is invisible to capture — modules that
|
||||
//! want play-mode survival register their components, which they do anyway to be
|
||||
//! editable. Within that set, capture → restore → capture is stable.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use hecs::Entity;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{DisabledComponents, Node, Scene, SceneError};
|
||||
use crate::layer::Tags;
|
||||
use crate::math::Transform;
|
||||
use crate::reflect::TypeRegistry;
|
||||
|
||||
/// Components captured explicitly via [`Scene::spawn`] on restore, so they are
|
||||
/// excluded from the per-node component map to avoid storing them twice.
|
||||
/// (`Layer` is *not* here: it is auto-attached on spawn but carries authored
|
||||
/// data, so it round-trips through the component map like any other component.)
|
||||
const SPAWN_BAKED: [&str; 2] = ["Node", "Transform"];
|
||||
|
||||
/// One entity in a flattened [`SceneSnapshot`]. `children` holds indices into
|
||||
/// the surrounding [`SceneSnapshot::nodes`] list (entity handles are not stable
|
||||
/// across a capture/restore, so positions stand in for references).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct SnapshotNode {
|
||||
name: String,
|
||||
enabled: bool,
|
||||
transform: Transform,
|
||||
children: Vec<usize>,
|
||||
/// Every *other* registered component on the node, `type_name` → RON.
|
||||
/// A `BTreeMap` so the serialized form is order-stable.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
components: BTreeMap<String, String>,
|
||||
/// The entity's gameplay [`Tags`], if any. Captured directly (not via the
|
||||
/// registry) because `Tags` is an engine-intrinsic component edited through
|
||||
/// the inspector's dedicated Groups UI, not registered as a generic
|
||||
/// reflected type — without this, Play→Stop would wipe group membership.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
tags: Option<Tags>,
|
||||
/// The entity's [`DisabledComponents`] set, if any. Captured directly for
|
||||
/// the same reason as [`tags`](Self::tags): it's hidden engine metadata, not
|
||||
/// a reflected authored component.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
disabled: Option<DisabledComponents>,
|
||||
}
|
||||
|
||||
/// A complete, restorable capture of a [`Scene`]: hierarchy plus every reflected
|
||||
/// component on each node. See the [module docs](self).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SceneSnapshot {
|
||||
nodes: Vec<SnapshotNode>,
|
||||
roots: Vec<usize>,
|
||||
}
|
||||
|
||||
impl SceneSnapshot {
|
||||
/// Captures `scene` in full, serializing every component the `registry`
|
||||
/// knows about on each entity. Built from a deterministic pre-order walk of
|
||||
/// the hierarchy, so two captures of equal scenes compare equal.
|
||||
pub fn capture(scene: &Scene, registry: &TypeRegistry) -> Self {
|
||||
let mut index: HashMap<Entity, usize> = HashMap::with_capacity(scene.len());
|
||||
let mut order: Vec<Entity> = Vec::with_capacity(scene.len());
|
||||
for &root in scene.roots() {
|
||||
assign_indices(scene, root, &mut index, &mut order);
|
||||
}
|
||||
|
||||
let nodes = order
|
||||
.iter()
|
||||
.map(|&entity| {
|
||||
// Pull the node-baked fields first, then drop the borrow before
|
||||
// the reflection walk reads the same world.
|
||||
let (name, enabled) = {
|
||||
let node = scene
|
||||
.get::<Node>(entity)
|
||||
.expect("entity in hierarchy must have a Node");
|
||||
(node.name.clone(), node.enabled)
|
||||
};
|
||||
let transform = scene
|
||||
.local_transform(entity)
|
||||
.expect("entity in hierarchy must have a Transform");
|
||||
|
||||
let mut components = BTreeMap::new();
|
||||
for type_name in registry.components_on(scene.world(), entity) {
|
||||
if SPAWN_BAKED.contains(&type_name) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(ron) = registry.get_ron(scene.world(), entity, type_name) {
|
||||
components.insert(type_name.to_string(), ron);
|
||||
}
|
||||
}
|
||||
|
||||
SnapshotNode {
|
||||
name,
|
||||
enabled,
|
||||
transform,
|
||||
children: scene.children(entity).iter().map(|c| index[c]).collect(),
|
||||
components,
|
||||
tags: scene.get::<Tags>(entity).map(|t| (*t).clone()),
|
||||
disabled: scene
|
||||
.get::<DisabledComponents>(entity)
|
||||
.map(|d| (*d).clone()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let roots = scene.roots().iter().map(|r| index[r]).collect();
|
||||
SceneSnapshot { nodes, roots }
|
||||
}
|
||||
|
||||
/// Rebuilds a fresh [`Scene`] from this snapshot. Spawns every node (which
|
||||
/// auto-attaches the node-baked `Node`/`Transform`/`Layer`), re-links the
|
||||
/// hierarchy, then applies each captured component over the defaults.
|
||||
///
|
||||
/// Entity handles in the new scene differ from the captured ones — callers
|
||||
/// holding an [`Entity`] (e.g. an editor selection) must drop or re-resolve
|
||||
/// it after a restore.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Deserialize`](SceneError::Deserialize) if an index reference is out of
|
||||
/// range or a component's stored RON no longer parses for its type.
|
||||
pub fn restore(&self, registry: &TypeRegistry) -> Result<Scene, SceneError> {
|
||||
let mut scene = Scene::new();
|
||||
let n = self.nodes.len();
|
||||
|
||||
// Spawn every entity first (as a root) so all indices resolve before
|
||||
// wiring parent/child links.
|
||||
let entities: Vec<Entity> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|rec| {
|
||||
scene.spawn(
|
||||
Node {
|
||||
name: rec.name.clone(),
|
||||
enabled: rec.enabled,
|
||||
},
|
||||
rec.transform,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Re-link the hierarchy.
|
||||
for (i, rec) in self.nodes.iter().enumerate() {
|
||||
for &child_idx in &rec.children {
|
||||
let child = *entities.get(child_idx).ok_or_else(|| {
|
||||
SceneError::Deserialize(format!(
|
||||
"child index {child_idx} out of range (have {n} nodes)"
|
||||
))
|
||||
})?;
|
||||
scene
|
||||
.set_parent(child, Some(entities[i]))
|
||||
.map_err(|e| SceneError::Deserialize(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply captured components over the spawn defaults.
|
||||
for (i, rec) in self.nodes.iter().enumerate() {
|
||||
for (type_name, ron) in &rec.components {
|
||||
registry
|
||||
.set_ron(scene.world_mut(), entities[i], type_name, ron)
|
||||
.map_err(|e| SceneError::Deserialize(e.to_string()))?;
|
||||
}
|
||||
// Reinstate the engine-intrinsic, non-reflected components.
|
||||
if let Some(tags) = &rec.tags {
|
||||
let _ = scene.world_mut().insert_one(entities[i], tags.clone());
|
||||
}
|
||||
if let Some(disabled) = &rec.disabled {
|
||||
let _ = scene.world_mut().insert_one(entities[i], disabled.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(scene)
|
||||
}
|
||||
|
||||
/// Serializes the snapshot to a pretty-printed RON string.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Serialize`](SceneError::Serialize) if encoding fails.
|
||||
pub fn to_ron(&self) -> Result<String, SceneError> {
|
||||
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
|
||||
.map_err(|e| SceneError::Serialize(e.to_string()))
|
||||
}
|
||||
|
||||
/// Reconstructs a snapshot from a string produced by [`to_ron`](Self::to_ron).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Deserialize`](SceneError::Deserialize) if the text is not valid.
|
||||
pub fn from_ron(ron: &str) -> Result<Self, SceneError> {
|
||||
ron::from_str(ron).map_err(|e| SceneError::Deserialize(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-order index assignment: mirrors the plain-RON walk so snapshot and
|
||||
/// `to_ron` order entities identically.
|
||||
fn assign_indices(
|
||||
scene: &Scene,
|
||||
entity: Entity,
|
||||
index: &mut HashMap<Entity, usize>,
|
||||
order: &mut Vec<Entity>,
|
||||
) {
|
||||
index.insert(entity, order.len());
|
||||
order.push(entity);
|
||||
for &child in scene.children(entity) {
|
||||
assign_indices(scene, child, index, order);
|
||||
}
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Captures this scene in full (hierarchy + every reflected component) into
|
||||
/// a restorable [`SceneSnapshot`]. See that type for why it differs from
|
||||
/// [`to_ron`](Self::to_ron).
|
||||
pub fn snapshot(&self, registry: &TypeRegistry) -> SceneSnapshot {
|
||||
SceneSnapshot::capture(self, registry)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::Vec3;
|
||||
use crate::render::{MeshRenderer, PrimitiveShape};
|
||||
|
||||
/// A registry seeded like the editor's: node-baked types plus a couple of
|
||||
/// modular components, so snapshots exercise the component map.
|
||||
fn registry() -> TypeRegistry {
|
||||
let mut r = TypeRegistry::new();
|
||||
r.register_reflected::<Transform>("Transform");
|
||||
r.register_reflected::<Node>("Node");
|
||||
r.register_reflected::<crate::layer::Layer>("Layer");
|
||||
r.register_reflected::<MeshRenderer>("MeshRenderer");
|
||||
r
|
||||
}
|
||||
|
||||
fn sample_scene() -> Scene {
|
||||
let mut scene = Scene::new();
|
||||
let parent = scene.spawn("parent", Transform::IDENTITY);
|
||||
let child = scene.spawn(
|
||||
"child",
|
||||
Transform::from_translation(Vec3::new(1.0, 2.0, 3.0)),
|
||||
);
|
||||
scene.set_parent(child, Some(parent)).unwrap();
|
||||
scene
|
||||
.world_mut()
|
||||
.insert_one(
|
||||
child,
|
||||
MeshRenderer {
|
||||
shape: PrimitiveShape::Sphere,
|
||||
..MeshRenderer::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
scene
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_restore_round_trips_components_and_hierarchy() {
|
||||
let reg = registry();
|
||||
let scene = sample_scene();
|
||||
let snap = scene.snapshot(®);
|
||||
|
||||
let restored = snap.restore(®).unwrap();
|
||||
// Hierarchy (plain RON) matches.
|
||||
assert_eq!(scene.to_ron().unwrap(), restored.to_ron().unwrap());
|
||||
// And the full snapshot (incl. components) matches.
|
||||
assert_eq!(snap, restored.snapshot(®));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_reinstates_a_modular_component() {
|
||||
let reg = registry();
|
||||
let scene = sample_scene();
|
||||
let snap = scene.snapshot(®);
|
||||
let restored = snap.restore(®).unwrap();
|
||||
|
||||
// The child's MeshRenderer (Sphere) survived the round-trip.
|
||||
let child = restored
|
||||
.entities()
|
||||
.find(|&e| {
|
||||
restored
|
||||
.get::<Node>(e)
|
||||
.map(|n| n.name == "child")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap();
|
||||
let mesh = restored.get::<MeshRenderer>(child).unwrap();
|
||||
assert_eq!(mesh.shape, PrimitiveShape::Sphere);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_reverts_a_play_mode_mutation_bit_for_bit() {
|
||||
// The play-mode contract: snapshot, mutate (as a tick would), restore,
|
||||
// and the scene returns to its captured form.
|
||||
let reg = registry();
|
||||
let mut scene = sample_scene();
|
||||
let snap = scene.snapshot(®);
|
||||
|
||||
// Mutate every transform, as a physics/script tick might.
|
||||
let entities: Vec<_> = scene.entities().collect();
|
||||
for e in entities {
|
||||
let t = scene.local_transform(e).unwrap();
|
||||
scene.set_local_transform(e, Transform::from_translation(t.translation + Vec3::X));
|
||||
}
|
||||
assert_ne!(snap, scene.snapshot(®));
|
||||
|
||||
let reverted = snap.restore(®).unwrap();
|
||||
assert_eq!(snap, reverted.snapshot(®));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captures_intrinsic_tags_and_disabled() {
|
||||
use crate::layer::Tags;
|
||||
use crate::scene::DisabledComponents;
|
||||
|
||||
let reg = registry();
|
||||
let mut scene = sample_scene();
|
||||
let parent = scene
|
||||
.entities()
|
||||
.find(|&e| {
|
||||
scene
|
||||
.get::<Node>(e)
|
||||
.map(|n| n.name == "parent")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap();
|
||||
scene
|
||||
.world_mut()
|
||||
.insert_one(parent, Tags::single("Enemy"))
|
||||
.unwrap();
|
||||
let mut dc = DisabledComponents::new();
|
||||
dc.set_disabled("MeshRenderer", true);
|
||||
scene.world_mut().insert_one(parent, dc).unwrap();
|
||||
|
||||
let snap = scene.snapshot(®);
|
||||
let restored = snap.restore(®).unwrap();
|
||||
// Both engine-intrinsic, non-reflected components survive the round-trip
|
||||
// even though neither is in the registry.
|
||||
assert_eq!(snap, restored.snapshot(®));
|
||||
let rparent = restored
|
||||
.entities()
|
||||
.find(|&e| {
|
||||
restored
|
||||
.get::<Node>(e)
|
||||
.map(|n| n.name == "parent")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(restored.get::<Tags>(rparent).unwrap().contains("Enemy"));
|
||||
assert!(restored
|
||||
.get::<DisabledComponents>(rparent)
|
||||
.unwrap()
|
||||
.is_disabled("MeshRenderer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ron_round_trips() {
|
||||
let reg = registry();
|
||||
let snap = sample_scene().snapshot(®);
|
||||
let ron = snap.to_ron().unwrap();
|
||||
assert_eq!(snap, SceneSnapshot::from_ron(&ron).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! The settings / preferences framework.
|
||||
//!
|
||||
//! A unified, serialized configuration store shared by the engine, the editor,
|
||||
//! and modules. Each contributor registers a typed **section** (a plain
|
||||
//! `serde`-serializable struct) under a name; the framework persists every
|
||||
//! section to RON and restores it, without any central code knowing the
|
||||
//! sections' shapes. This is what lets:
|
||||
//!
|
||||
//! - **engine** preferences (render/quality defaults),
|
||||
//! - **editor** preferences (theme, layout, shortcuts), and
|
||||
//! - **per-module** settings (each module's own options)
|
||||
//!
|
||||
//! all live in one place, while a [`Project`](crate::project::Project) persists
|
||||
//! the per-project subset (it stores section → RON blobs that line up exactly
|
||||
//! with [`Settings::export`]/[`Settings::import`]).
|
||||
//!
|
||||
//! ```
|
||||
//! use oxide_engine::settings::Settings;
|
||||
//! use serde::{Serialize, Deserialize};
|
||||
//!
|
||||
//! #[derive(Serialize, Deserialize, Default, PartialEq, Debug)]
|
||||
//! struct EditorPrefs { theme: String, grid: bool }
|
||||
//!
|
||||
//! let mut settings = Settings::new();
|
||||
//! settings.register::<EditorPrefs>("editor");
|
||||
//! settings.get_mut::<EditorPrefs>("editor").unwrap().theme = "dark".into();
|
||||
//!
|
||||
//! // Persist every section to RON, and restore it later.
|
||||
//! let saved = settings.export();
|
||||
//! let mut restored = Settings::new();
|
||||
//! restored.register::<EditorPrefs>("editor");
|
||||
//! restored.import(&saved);
|
||||
//! assert_eq!(restored.get::<EditorPrefs>("editor").unwrap().theme, "dark");
|
||||
//! ```
|
||||
|
||||
use std::any::Any;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
|
||||
/// The monomorphized operations for one registered section, as plain function
|
||||
/// pointers (the closures capture nothing).
|
||||
struct SectionOps {
|
||||
value: Box<dyn Any>,
|
||||
to_ron: fn(&dyn Any) -> Option<String>,
|
||||
from_ron: fn(&str) -> Option<Box<dyn Any>>,
|
||||
default: fn() -> Box<dyn Any>,
|
||||
}
|
||||
|
||||
/// A registry of typed, serializable settings sections keyed by name.
|
||||
#[derive(Default)]
|
||||
pub struct Settings {
|
||||
sections: BTreeMap<&'static str, SectionOps>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// An empty settings store.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registers section type `T` under `name`, initialized to `T::default()`.
|
||||
/// Re-registering the same name resets it to default.
|
||||
pub fn register<T>(&mut self, name: &'static str)
|
||||
where
|
||||
T: Serialize + DeserializeOwned + Default + 'static,
|
||||
{
|
||||
self.sections.insert(
|
||||
name,
|
||||
SectionOps {
|
||||
value: Box::new(T::default()),
|
||||
to_ron: |any| any.downcast_ref::<T>().and_then(|v| ron::to_string(v).ok()),
|
||||
from_ron: |text| {
|
||||
ron::from_str::<T>(text)
|
||||
.ok()
|
||||
.map(|v| Box::new(v) as Box<dyn Any>)
|
||||
},
|
||||
default: || Box::new(T::default()) as Box<dyn Any>,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether a section is registered under `name`.
|
||||
pub fn is_registered(&self, name: &str) -> bool {
|
||||
self.sections.contains_key(name)
|
||||
}
|
||||
|
||||
/// The registered section names, sorted.
|
||||
pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
|
||||
self.sections.keys().copied()
|
||||
}
|
||||
|
||||
/// Borrows section `name` as `T`, or `None` if absent or the type mismatches.
|
||||
pub fn get<T: 'static>(&self, name: &str) -> Option<&T> {
|
||||
self.sections.get(name)?.value.downcast_ref::<T>()
|
||||
}
|
||||
|
||||
/// Mutably borrows section `name` as `T`.
|
||||
pub fn get_mut<T: 'static>(&mut self, name: &str) -> Option<&mut T> {
|
||||
self.sections.get_mut(name)?.value.downcast_mut::<T>()
|
||||
}
|
||||
|
||||
/// Replaces the value of section `name`. Returns whether it was registered
|
||||
/// (with a matching type).
|
||||
pub fn set<T: 'static>(&mut self, name: &str, value: T) -> bool {
|
||||
match self.sections.get_mut(name) {
|
||||
// Only overwrite if the registered type matches.
|
||||
Some(section) if section.value.is::<T>() => {
|
||||
section.value = Box::new(value);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets section `name` to its default. Returns whether it was registered.
|
||||
pub fn reset(&mut self, name: &str) -> bool {
|
||||
match self.sections.get_mut(name) {
|
||||
Some(section) => {
|
||||
section.value = (section.default)();
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes section `name` to RON, or `None` if it is not registered.
|
||||
pub fn section_ron(&self, name: &str) -> Option<String> {
|
||||
let section = self.sections.get(name)?;
|
||||
(section.to_ron)(section.value.as_ref())
|
||||
}
|
||||
|
||||
/// Loads section `name` from a RON blob, replacing its value. Returns `false`
|
||||
/// if the section is not registered or the text fails to parse.
|
||||
pub fn load_section(&mut self, name: &str, ron: &str) -> bool {
|
||||
match self.sections.get_mut(name) {
|
||||
Some(section) => match (section.from_ron)(ron) {
|
||||
Some(value) => {
|
||||
section.value = value;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes every section to a `name → RON` map (the format a
|
||||
/// [`Project`](crate::project::Project) stores).
|
||||
pub fn export(&self) -> BTreeMap<String, String> {
|
||||
self.sections
|
||||
.iter()
|
||||
.filter_map(|(name, section)| {
|
||||
(section.to_ron)(section.value.as_ref()).map(|ron| (name.to_string(), ron))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Loads every matching, registered section from a `name → RON` map.
|
||||
/// Unknown sections are ignored (a module may be disabled); malformed
|
||||
/// sections are skipped, leaving their current value.
|
||||
pub fn import(&mut self, map: &BTreeMap<String, String>) {
|
||||
for (name, ron) in map {
|
||||
self.load_section(name, ron);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, PartialEq, Debug)]
|
||||
struct EditorPrefs {
|
||||
theme: String,
|
||||
grid: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, PartialEq, Debug)]
|
||||
struct Render {
|
||||
shadows: bool,
|
||||
msaa: u32,
|
||||
}
|
||||
impl Default for Render {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
shadows: true,
|
||||
msaa: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn settings() -> Settings {
|
||||
let mut s = Settings::new();
|
||||
s.register::<EditorPrefs>("editor");
|
||||
s.register::<Render>("render");
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_and_typed_access() {
|
||||
let mut s = settings();
|
||||
assert_eq!(s.names().collect::<Vec<_>>(), vec!["editor", "render"]);
|
||||
assert!(s.get::<Render>("render").unwrap().shadows);
|
||||
s.get_mut::<EditorPrefs>("editor").unwrap().theme = "dark".into();
|
||||
assert_eq!(s.get::<EditorPrefs>("editor").unwrap().theme, "dark");
|
||||
// Wrong type → None.
|
||||
assert!(s.get::<Render>("editor").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_reset() {
|
||||
let mut s = settings();
|
||||
assert!(s.set(
|
||||
"render",
|
||||
Render {
|
||||
shadows: false,
|
||||
msaa: 8
|
||||
}
|
||||
));
|
||||
assert_eq!(s.get::<Render>("render").unwrap().msaa, 8);
|
||||
// Setting an unregistered section fails.
|
||||
assert!(!s.set("missing", 5u32));
|
||||
// Reset returns to default.
|
||||
assert!(s.reset("render"));
|
||||
assert_eq!(s.get::<Render>("render").unwrap(), &Render::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_import_round_trips() {
|
||||
let mut s = settings();
|
||||
s.get_mut::<EditorPrefs>("editor").unwrap().theme = "light".into();
|
||||
s.get_mut::<EditorPrefs>("editor").unwrap().grid = true;
|
||||
s.set(
|
||||
"render",
|
||||
Render {
|
||||
shadows: false,
|
||||
msaa: 2,
|
||||
},
|
||||
);
|
||||
let saved = s.export();
|
||||
|
||||
// A fresh store with the same sections restores the saved values.
|
||||
let mut restored = settings();
|
||||
restored.import(&saved);
|
||||
assert_eq!(
|
||||
restored.get::<EditorPrefs>("editor").unwrap(),
|
||||
&EditorPrefs {
|
||||
theme: "light".into(),
|
||||
grid: true
|
||||
}
|
||||
);
|
||||
assert_eq!(restored.get::<Render>("render").unwrap().msaa, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_ignores_unknown_and_malformed() {
|
||||
let mut s = settings();
|
||||
let mut map = BTreeMap::new();
|
||||
map.insert("editor".to_string(), "(theme:\"x\",grid:true)".to_string());
|
||||
map.insert("disabled_module".to_string(), "(whatever:1)".to_string());
|
||||
map.insert("render".to_string(), "not valid ron".to_string());
|
||||
s.import(&map);
|
||||
// Known + valid applied.
|
||||
assert_eq!(s.get::<EditorPrefs>("editor").unwrap().theme, "x");
|
||||
// Malformed left the section at its default (unchanged).
|
||||
assert_eq!(s.get::<Render>("render").unwrap(), &Render::default());
|
||||
// Unknown silently ignored.
|
||||
assert!(!s.is_registered("disabled_module"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,763 @@
|
||||
//! Layout algorithm — turns a [`Widget`] tree into resolved screen rects.
|
||||
//!
|
||||
//! [`layout`] is a single recursive top-down pass that mixes a one-shot
|
||||
//! intrinsic-size measurement (for `FitContent` and `Grow` accounting) with
|
||||
//! the actual placement. The resulting [`LayoutTree`] is a flat `Vec` of
|
||||
//! [`LayoutNode`]s; each node records its own `rect`, `content_rect`
|
||||
//! (padding-inset), and the indices of its direct children. The layout
|
||||
//! function itself has no GPU, no input, no allocation outside the result —
|
||||
//! every test in this stage runs headlessly.
|
||||
//!
|
||||
//! # Slot vs rect, and why anchor children skip resizing
|
||||
//!
|
||||
//! The recursion uses two entry points:
|
||||
//!
|
||||
//! - [`arrange_in_slot`] is for stack / grid children and the root: the slot
|
||||
//! is the **outer space** the widget can occupy; the algorithm applies the
|
||||
//! widget's margin, sizing, and alignment to derive its rect.
|
||||
//! - [`arrange_in_rect`] is for anchor children: the rect is *already* what
|
||||
//! the anchor decided; the widget's margin / sizing / alignment are skipped
|
||||
//! so the anchor is authoritative. Padding still applies (it's an inside-
|
||||
//! the-rect concern). This matches the Unity/Godot convention that "anchor
|
||||
//! determines rect" — sizing knobs would let the child silently disagree
|
||||
//! with the anchor it was placed by.
|
||||
//!
|
||||
//! # DPI scale factor
|
||||
//!
|
||||
//! Every linear input (sizing, padding, margin, gaps, anchor offsets) is in
|
||||
//! logical pixels and multiplied by [`layout`]'s `scale` argument at resolve
|
||||
//! time. The widget tree is DPI-independent; the layout call is where the
|
||||
//! display's scale factor enters.
|
||||
|
||||
use glam::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::math::Rect;
|
||||
|
||||
use super::style::{Align, Insets, LayoutStyle, Sizing};
|
||||
use super::widget::{AnchorGroup, Grid, Stack, StackDirection, Widget, WidgetId, WidgetKind};
|
||||
|
||||
/// One node in a resolved [`LayoutTree`] — the widget's id and its on-screen
|
||||
/// rectangles.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LayoutNode {
|
||||
/// Mirror of [`Widget::id`].
|
||||
pub id: WidgetId,
|
||||
/// The outer rectangle the widget occupies, after margin / sizing /
|
||||
/// alignment.
|
||||
pub rect: Rect,
|
||||
/// `rect` minus the widget's padding — the area children are arranged
|
||||
/// inside.
|
||||
pub content_rect: Rect,
|
||||
/// Indices into [`LayoutTree::nodes`] of the direct children, in the same
|
||||
/// order as on the input widget.
|
||||
pub children: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Result of laying out a widget tree — a flat array of [`LayoutNode`]s with
|
||||
/// the root at index 0.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LayoutTree {
|
||||
nodes: Vec<LayoutNode>,
|
||||
}
|
||||
|
||||
impl LayoutTree {
|
||||
/// All nodes, root first, in the pre-order produced by [`layout`].
|
||||
pub fn nodes(&self) -> &[LayoutNode] {
|
||||
&self.nodes
|
||||
}
|
||||
|
||||
/// The root node (always present after a successful layout).
|
||||
pub fn root(&self) -> Option<&LayoutNode> {
|
||||
self.nodes.first()
|
||||
}
|
||||
|
||||
/// Look up the first node with the given non-empty id.
|
||||
///
|
||||
/// Returns `None` if `id` is empty or no node matches. Linear scan — fine
|
||||
/// for the dozens-of-widgets trees Stage 8 currently targets; a hash map
|
||||
/// can be added if a profile says it's hot.
|
||||
pub fn find(&self, id: &WidgetId) -> Option<&LayoutNode> {
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.nodes.iter().find(|n| n.id == *id)
|
||||
}
|
||||
|
||||
/// The direct children of the node at `index`.
|
||||
pub fn children_of(&self, index: usize) -> impl Iterator<Item = &LayoutNode> + '_ {
|
||||
self.nodes[index]
|
||||
.children
|
||||
.iter()
|
||||
.map(move |i| &self.nodes[*i as usize])
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out `root` inside `viewport` at the given DPI `scale`, producing a
|
||||
/// [`LayoutTree`] with one entry per widget in pre-order.
|
||||
pub fn layout(root: &Widget, viewport: Rect, scale: f32) -> LayoutTree {
|
||||
let mut nodes = Vec::with_capacity(root.node_count());
|
||||
arrange_in_slot(root, viewport, scale, &mut nodes);
|
||||
LayoutTree { nodes }
|
||||
}
|
||||
|
||||
// ---------- internal: recursive arrangement ----------
|
||||
|
||||
fn arrange_in_slot(widget: &Widget, slot: Rect, scale: f32, out: &mut Vec<LayoutNode>) -> u32 {
|
||||
let margin = widget.style.margin.scaled(scale);
|
||||
let outer = shrink(slot, margin);
|
||||
let outer_size = outer.size();
|
||||
|
||||
let intrinsic = measure(widget, outer_size, scale);
|
||||
let resolved_w = resolve_axis(widget.style.width, outer_size.x, intrinsic.x, scale);
|
||||
let resolved_h = resolve_axis(widget.style.height, outer_size.y, intrinsic.y, scale);
|
||||
let resolved = Vec2::new(resolved_w, resolved_h);
|
||||
|
||||
let extra = (outer_size - resolved).max(Vec2::ZERO);
|
||||
let offset = Vec2::new(
|
||||
align_offset(widget.style.align_horizontal, extra.x),
|
||||
align_offset(widget.style.align_vertical, extra.y),
|
||||
);
|
||||
let rect = Rect::from_min_size(outer.min + offset, resolved);
|
||||
|
||||
arrange_in_rect(widget, rect, scale, out)
|
||||
}
|
||||
|
||||
fn arrange_in_rect(widget: &Widget, rect: Rect, scale: f32, out: &mut Vec<LayoutNode>) -> u32 {
|
||||
let padding = widget.style.padding.scaled(scale);
|
||||
let content_rect = shrink(rect, padding);
|
||||
|
||||
let my_idx = out.len() as u32;
|
||||
out.push(LayoutNode {
|
||||
id: widget.id.clone(),
|
||||
rect,
|
||||
content_rect,
|
||||
children: Vec::new(),
|
||||
});
|
||||
|
||||
match &widget.kind {
|
||||
WidgetKind::Leaf { .. } => {}
|
||||
WidgetKind::Stack(stack) => arrange_stack(my_idx, stack, content_rect, scale, out),
|
||||
WidgetKind::Grid(grid) => arrange_grid(my_idx, grid, content_rect, scale, out),
|
||||
WidgetKind::Anchor(group) => arrange_anchor(my_idx, group, content_rect, scale, out),
|
||||
}
|
||||
|
||||
my_idx
|
||||
}
|
||||
|
||||
fn arrange_stack(
|
||||
parent_idx: u32,
|
||||
stack: &Stack,
|
||||
content: Rect,
|
||||
scale: f32,
|
||||
out: &mut Vec<LayoutNode>,
|
||||
) {
|
||||
let n = stack.children.len();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let gap = stack.gap * scale;
|
||||
let total_gap = gap * n.saturating_sub(1) as f32;
|
||||
let content_main = main_extent(stack.direction, content.size());
|
||||
let content_cross = cross_extent(stack.direction, content.size());
|
||||
|
||||
// Pass 1: compute each child's main-axis size (fixed/fit) and tally
|
||||
// grow weights.
|
||||
let mut main_sizes: Vec<f32> = Vec::with_capacity(n);
|
||||
let mut grow_weights: Vec<Option<f32>> = Vec::with_capacity(n);
|
||||
let mut fixed_main_total = 0.0_f32;
|
||||
let mut total_grow = 0.0_f32;
|
||||
|
||||
for child in &stack.children {
|
||||
let margin = child.style.margin.scaled(scale);
|
||||
let margin_main = main_extent(
|
||||
stack.direction,
|
||||
Vec2::new(margin.horizontal(), margin.vertical()),
|
||||
);
|
||||
let main_sizing = match stack.direction {
|
||||
StackDirection::Row => child.style.width,
|
||||
StackDirection::Column => child.style.height,
|
||||
};
|
||||
|
||||
let (inner_main, weight) = match main_sizing {
|
||||
Sizing::Fixed(v) => (v * scale, None),
|
||||
Sizing::FitContent => {
|
||||
let m = measure(child, content.size(), scale);
|
||||
(main_extent(stack.direction, m), None)
|
||||
}
|
||||
Sizing::Grow(w) => (0.0, Some(w.max(0.0))),
|
||||
};
|
||||
|
||||
if let Some(w) = weight {
|
||||
total_grow += w;
|
||||
}
|
||||
grow_weights.push(weight);
|
||||
main_sizes.push(inner_main + margin_main);
|
||||
fixed_main_total += inner_main + margin_main;
|
||||
}
|
||||
|
||||
let leftover = (content_main - fixed_main_total - total_gap).max(0.0);
|
||||
if total_grow > 0.0 {
|
||||
for (i, w) in grow_weights.iter().enumerate() {
|
||||
if let Some(w) = w {
|
||||
main_sizes[i] += leftover * (*w / total_grow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After distributing Grow, any remaining slack is positioned via the
|
||||
// stack's `main_align`. (If any child grew, slack is zero.)
|
||||
let used_main: f32 = main_sizes.iter().sum::<f32>() + total_gap;
|
||||
let extra = (content_main - used_main).max(0.0);
|
||||
let start_offset = align_offset(stack.main_align, extra);
|
||||
|
||||
// Pass 2: place each child in its slot.
|
||||
let mut cursor = start_offset;
|
||||
let mut child_indices = Vec::with_capacity(n);
|
||||
for (i, child) in stack.children.iter().enumerate() {
|
||||
let slot_main = main_sizes[i];
|
||||
let slot = make_slot(stack.direction, content, cursor, slot_main, content_cross);
|
||||
cursor += slot_main + gap;
|
||||
child_indices.push(arrange_in_slot(child, slot, scale, out));
|
||||
}
|
||||
|
||||
out[parent_idx as usize].children = child_indices;
|
||||
}
|
||||
|
||||
fn arrange_grid(
|
||||
parent_idx: u32,
|
||||
grid: &Grid,
|
||||
content: Rect,
|
||||
scale: f32,
|
||||
out: &mut Vec<LayoutNode>,
|
||||
) {
|
||||
if grid.cols == 0 || grid.rows == 0 || grid.children.is_empty() {
|
||||
return;
|
||||
}
|
||||
let gap = grid.gap * scale;
|
||||
let total_gap_x = gap.x * grid.cols.saturating_sub(1) as f32;
|
||||
let total_gap_y = gap.y * grid.rows.saturating_sub(1) as f32;
|
||||
let cell_w = ((content.width() - total_gap_x) / grid.cols as f32).max(0.0);
|
||||
let cell_h = ((content.height() - total_gap_y) / grid.rows as f32).max(0.0);
|
||||
let cells = grid.cols * grid.rows;
|
||||
|
||||
let mut child_indices = Vec::with_capacity(grid.children.len().min(cells as usize));
|
||||
for (i, child) in grid.children.iter().enumerate() {
|
||||
if i as u32 >= cells {
|
||||
break;
|
||||
}
|
||||
let row = i as u32 / grid.cols;
|
||||
let col = i as u32 % grid.cols;
|
||||
let cell_origin =
|
||||
content.min + Vec2::new(col as f32 * (cell_w + gap.x), row as f32 * (cell_h + gap.y));
|
||||
let slot = Rect::from_min_size(cell_origin, Vec2::new(cell_w, cell_h));
|
||||
child_indices.push(arrange_in_slot(child, slot, scale, out));
|
||||
}
|
||||
out[parent_idx as usize].children = child_indices;
|
||||
}
|
||||
|
||||
fn arrange_anchor(
|
||||
parent_idx: u32,
|
||||
group: &AnchorGroup,
|
||||
content: Rect,
|
||||
scale: f32,
|
||||
out: &mut Vec<LayoutNode>,
|
||||
) {
|
||||
let size = content.size();
|
||||
let mut child_indices = Vec::with_capacity(group.children.len());
|
||||
for child in &group.children {
|
||||
let a = child.style.anchor;
|
||||
let min = content.min + size * a.min + a.offset_min * scale;
|
||||
let max = content.min + size * a.max + a.offset_max * scale;
|
||||
let target = Rect::new(min, max);
|
||||
child_indices.push(arrange_in_rect(child, target, scale, out));
|
||||
}
|
||||
out[parent_idx as usize].children = child_indices;
|
||||
}
|
||||
|
||||
// ---------- internal: measurement ----------
|
||||
|
||||
fn measure(widget: &Widget, available: Vec2, scale: f32) -> Vec2 {
|
||||
match &widget.kind {
|
||||
WidgetKind::Leaf { intrinsic } => *intrinsic * scale,
|
||||
WidgetKind::Stack(stack) => measure_stack(&widget.style, stack, available, scale),
|
||||
WidgetKind::Grid(grid) => measure_grid(&widget.style, grid, available, scale),
|
||||
// Anchor parents derive their children's rects from the parent's size,
|
||||
// so they can't propose an intrinsic "fit" size; FitContent on an
|
||||
// anchor parent collapses to zero.
|
||||
WidgetKind::Anchor(_) => Vec2::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Outer footprint of a child (the slot it would consume in its parent),
|
||||
/// including its own margin.
|
||||
fn measure_outer(widget: &Widget, available: Vec2, scale: f32) -> Vec2 {
|
||||
let intrinsic = measure(widget, available, scale);
|
||||
let w = match widget.style.width {
|
||||
Sizing::Fixed(v) => v * scale,
|
||||
Sizing::FitContent => intrinsic.x,
|
||||
Sizing::Grow(_) => 0.0,
|
||||
};
|
||||
let h = match widget.style.height {
|
||||
Sizing::Fixed(v) => v * scale,
|
||||
Sizing::FitContent => intrinsic.y,
|
||||
Sizing::Grow(_) => 0.0,
|
||||
};
|
||||
let m = widget.style.margin.scaled(scale);
|
||||
Vec2::new(w + m.horizontal(), h + m.vertical())
|
||||
}
|
||||
|
||||
fn measure_stack(parent_style: &LayoutStyle, stack: &Stack, available: Vec2, scale: f32) -> Vec2 {
|
||||
let mut main = 0.0_f32;
|
||||
let mut cross = 0.0_f32;
|
||||
let n = stack.children.len();
|
||||
for child in &stack.children {
|
||||
let s = measure_outer(child, available, scale);
|
||||
main += main_extent(stack.direction, s);
|
||||
cross = cross.max(cross_extent(stack.direction, s));
|
||||
}
|
||||
if n > 1 {
|
||||
main += stack.gap * scale * (n - 1) as f32;
|
||||
}
|
||||
let p = parent_style.padding.scaled(scale);
|
||||
match stack.direction {
|
||||
StackDirection::Row => Vec2::new(main + p.horizontal(), cross + p.vertical()),
|
||||
StackDirection::Column => Vec2::new(cross + p.horizontal(), main + p.vertical()),
|
||||
}
|
||||
}
|
||||
|
||||
fn measure_grid(parent_style: &LayoutStyle, grid: &Grid, available: Vec2, scale: f32) -> Vec2 {
|
||||
if grid.cols == 0 || grid.rows == 0 {
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
let mut cell_w = 0.0_f32;
|
||||
let mut cell_h = 0.0_f32;
|
||||
for child in &grid.children {
|
||||
let s = measure_outer(child, available, scale);
|
||||
cell_w = cell_w.max(s.x);
|
||||
cell_h = cell_h.max(s.y);
|
||||
}
|
||||
let gap = grid.gap * scale;
|
||||
let total = Vec2::new(
|
||||
cell_w * grid.cols as f32 + gap.x * grid.cols.saturating_sub(1) as f32,
|
||||
cell_h * grid.rows as f32 + gap.y * grid.rows.saturating_sub(1) as f32,
|
||||
);
|
||||
let p = parent_style.padding.scaled(scale);
|
||||
Vec2::new(total.x + p.horizontal(), total.y + p.vertical())
|
||||
}
|
||||
|
||||
// ---------- internal: small helpers ----------
|
||||
|
||||
fn shrink(r: Rect, i: Insets) -> Rect {
|
||||
let min = r.min + Vec2::new(i.left, i.top);
|
||||
let max = r.max - Vec2::new(i.right, i.bottom);
|
||||
Rect::new(min, max)
|
||||
}
|
||||
|
||||
fn align_offset(align: Align, extra: f32) -> f32 {
|
||||
match align {
|
||||
Align::Start => 0.0,
|
||||
Align::Center => extra * 0.5,
|
||||
Align::End => extra,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_axis(sizing: Sizing, available: f32, intrinsic: f32, scale: f32) -> f32 {
|
||||
match sizing {
|
||||
Sizing::Fixed(v) => (v * scale).min(available),
|
||||
Sizing::Grow(_) => available,
|
||||
Sizing::FitContent => intrinsic.min(available),
|
||||
}
|
||||
}
|
||||
|
||||
fn main_extent(dir: StackDirection, v: Vec2) -> f32 {
|
||||
match dir {
|
||||
StackDirection::Row => v.x,
|
||||
StackDirection::Column => v.y,
|
||||
}
|
||||
}
|
||||
|
||||
fn cross_extent(dir: StackDirection, v: Vec2) -> f32 {
|
||||
match dir {
|
||||
StackDirection::Row => v.y,
|
||||
StackDirection::Column => v.x,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_slot(dir: StackDirection, content: Rect, cursor: f32, main: f32, cross: f32) -> Rect {
|
||||
match dir {
|
||||
StackDirection::Row => {
|
||||
Rect::from_min_size(content.min + Vec2::new(cursor, 0.0), Vec2::new(main, cross))
|
||||
}
|
||||
StackDirection::Column => {
|
||||
Rect::from_min_size(content.min + Vec2::new(0.0, cursor), Vec2::new(cross, main))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ui::style::Anchor;
|
||||
|
||||
fn vp(w: f32, h: f32) -> Rect {
|
||||
Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h))
|
||||
}
|
||||
|
||||
/// `Grow(1.0)` on both axes — the common "fill the parent" style for
|
||||
/// container tests where intrinsic sizing would collapse the root.
|
||||
fn grow_both() -> LayoutStyle {
|
||||
LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_leaf_takes_intrinsic_size_at_origin() {
|
||||
let w = Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a");
|
||||
let tree = layout(&w, vp(800.0, 600.0), 1.0);
|
||||
let n = tree.find(&"a".into()).unwrap();
|
||||
assert_eq!(
|
||||
n.rect,
|
||||
Rect::from_min_size(Vec2::ZERO, Vec2::new(40.0, 20.0))
|
||||
);
|
||||
assert_eq!(n.content_rect, n.rect);
|
||||
assert_eq!(tree.nodes().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dpi_scale_doubles_sizes() {
|
||||
let w = Widget::leaf(Vec2::new(40.0, 20.0));
|
||||
let tree = layout(&w, vp(800.0, 600.0), 2.0);
|
||||
let n = tree.root().unwrap();
|
||||
assert_eq!(n.rect.size(), Vec2::new(80.0, 40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_stack_places_fixed_children_with_gap() {
|
||||
let row = Widget::row()
|
||||
.with_id("row")
|
||||
.with_gap(4.0)
|
||||
.with_child(Widget::leaf(Vec2::new(30.0, 20.0)).with_id("a"))
|
||||
.with_child(Widget::leaf(Vec2::new(50.0, 20.0)).with_id("b"))
|
||||
.with_child(Widget::leaf(Vec2::new(10.0, 20.0)).with_id("c"));
|
||||
let tree = layout(&row, vp(200.0, 100.0), 1.0);
|
||||
let a = tree.find(&"a".into()).unwrap().rect;
|
||||
let b = tree.find(&"b".into()).unwrap().rect;
|
||||
let c = tree.find(&"c".into()).unwrap().rect;
|
||||
assert_eq!(a, Rect::from_min_size(Vec2::ZERO, Vec2::new(30.0, 20.0)));
|
||||
assert_eq!(
|
||||
b,
|
||||
Rect::from_min_size(Vec2::new(34.0, 0.0), Vec2::new(50.0, 20.0))
|
||||
);
|
||||
assert_eq!(
|
||||
c,
|
||||
Rect::from_min_size(Vec2::new(88.0, 0.0), Vec2::new(10.0, 20.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_stack_grow_fills_leftover_space() {
|
||||
// 200 wide; A=30 fixed, B=Grow, C=10 fixed → B gets 160 wide.
|
||||
let row =
|
||||
Widget::row()
|
||||
.with_id("row")
|
||||
.with_style(grow_both())
|
||||
.with_child(Widget::leaf(Vec2::new(30.0, 20.0)).with_id("a"))
|
||||
.with_child(Widget::leaf(Vec2::new(0.0, 20.0)).with_id("b").with_style(
|
||||
LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Fixed(20.0),
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
.with_child(Widget::leaf(Vec2::new(10.0, 20.0)).with_id("c"));
|
||||
let tree = layout(&row, vp(200.0, 100.0), 1.0);
|
||||
let b = tree.find(&"b".into()).unwrap().rect;
|
||||
assert_eq!(b.min.x, 30.0);
|
||||
assert_eq!(b.width(), 160.0);
|
||||
assert_eq!(tree.find(&"c".into()).unwrap().rect.min.x, 190.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_stack_grow_weights_split_proportionally() {
|
||||
// 300 wide root; A=Grow(1), B=Grow(2) → A gets 100, B gets 200.
|
||||
let row = Widget::row()
|
||||
.with_style(grow_both())
|
||||
.with_child(Widget::default().with_id("a").with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
}))
|
||||
.with_child(Widget::default().with_id("b").with_style(LayoutStyle {
|
||||
width: Sizing::Grow(2.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
}));
|
||||
let tree = layout(&row, vp(300.0, 30.0), 1.0);
|
||||
let a = tree.find(&"a".into()).unwrap().rect;
|
||||
let b = tree.find(&"b".into()).unwrap().rect;
|
||||
assert_eq!(a.width(), 100.0);
|
||||
assert_eq!(b.width(), 200.0);
|
||||
assert_eq!(b.min.x, 100.0);
|
||||
// Cross axis Grow fills full height.
|
||||
assert_eq!(a.height(), 30.0);
|
||||
assert_eq!(b.height(), 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_stack_main_align_center_splits_extra() {
|
||||
// Two 30-wide children with gap 0 → main extent 60; viewport 200 →
|
||||
// 140 extra, centered → 70 each side.
|
||||
let row = Widget::row()
|
||||
.with_main_align(Align::Center)
|
||||
.with_style(grow_both())
|
||||
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)).with_id("a"))
|
||||
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)).with_id("b"));
|
||||
let tree = layout(&row, vp(200.0, 20.0), 1.0);
|
||||
assert_eq!(tree.find(&"a".into()).unwrap().rect.min.x, 70.0);
|
||||
assert_eq!(tree.find(&"b".into()).unwrap().rect.min.x, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_stack_cross_align_end_docks_to_bottom() {
|
||||
// Child is 30x10 in a 100-wide row with 40 tall → align End → top=30.
|
||||
let row = Widget::row().with_style(grow_both()).with_child(
|
||||
Widget::leaf(Vec2::new(30.0, 10.0))
|
||||
.with_id("a")
|
||||
.with_style(LayoutStyle {
|
||||
align_vertical: Align::End,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let tree = layout(&row, vp(100.0, 40.0), 1.0);
|
||||
let a = tree.find(&"a".into()).unwrap().rect;
|
||||
assert_eq!(a.min.y, 30.0);
|
||||
assert_eq!(a.max.y, 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_stack_flows_top_to_bottom() {
|
||||
let col = Widget::column()
|
||||
.with_gap(2.0)
|
||||
.with_child(Widget::leaf(Vec2::new(20.0, 10.0)).with_id("a"))
|
||||
.with_child(Widget::leaf(Vec2::new(20.0, 30.0)).with_id("b"));
|
||||
let tree = layout(&col, vp(100.0, 100.0), 1.0);
|
||||
let a = tree.find(&"a".into()).unwrap().rect;
|
||||
let b = tree.find(&"b".into()).unwrap().rect;
|
||||
assert_eq!(a.min, Vec2::ZERO);
|
||||
assert_eq!(a.max.y, 10.0);
|
||||
assert_eq!(b.min.y, 12.0);
|
||||
assert_eq!(b.max.y, 42.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_two_by_three_makes_six_equal_cells() {
|
||||
// 100x60 content, 2 cols × 3 rows, no gap → cells 50x20.
|
||||
let grid = Widget::grid(2, 3)
|
||||
.with_style(grow_both())
|
||||
.with_children((0..6).map(|i| Widget::leaf(Vec2::ZERO).with_id(format!("c{i}"))));
|
||||
let tree = layout(&grid, vp(100.0, 60.0), 1.0);
|
||||
for i in 0..6 {
|
||||
let row = i / 2;
|
||||
let col = i % 2;
|
||||
let n = tree.find(&format!("c{i}").into()).unwrap();
|
||||
// Default FitContent of zero intrinsic ⇒ children collapse to
|
||||
// (col*50, row*20)–(col*50, row*20) at Start align inside the
|
||||
// cell. Verify the *cell origin* via the node's `rect.min`.
|
||||
assert_eq!(n.rect.min, Vec2::new(col as f32 * 50.0, row as f32 * 20.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_gap_subtracts_from_cell_size() {
|
||||
let grid = Widget::grid(2, 2)
|
||||
.with_style(grow_both())
|
||||
.with_grid_gap(Vec2::new(10.0, 10.0))
|
||||
.with_children((0..4).map(|i| {
|
||||
Widget::default()
|
||||
.with_id(format!("c{i}"))
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
})
|
||||
}));
|
||||
let tree = layout(&grid, vp(110.0, 110.0), 1.0);
|
||||
// (110 - 10 gap) / 2 = 50 per cell.
|
||||
for i in 0..4 {
|
||||
let n = tree.find(&format!("c{i}").into()).unwrap();
|
||||
assert_eq!(n.rect.size(), Vec2::new(50.0, 50.0));
|
||||
}
|
||||
// Second column starts at 60 (50 + 10 gap).
|
||||
assert_eq!(tree.find(&"c1".into()).unwrap().rect.min.x, 60.0);
|
||||
// Second row starts at 60.
|
||||
assert_eq!(tree.find(&"c2".into()).unwrap().rect.min.y, 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_fill_makes_child_match_parent_content() {
|
||||
let parent = Widget::anchor()
|
||||
.with_id("p")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("child"));
|
||||
let tree = layout(&parent, vp(200.0, 100.0), 1.0);
|
||||
let child = tree.find(&"child".into()).unwrap();
|
||||
assert_eq!(
|
||||
child.rect,
|
||||
Rect::from_min_size(Vec2::ZERO, Vec2::new(200.0, 100.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_top_right_with_offsets_places_child_relative_to_corner() {
|
||||
// Pin the child's top-right at the parent's top-right, then push the
|
||||
// top-left corner 80 pixels left and 24 pixels down → 80×24 child in
|
||||
// the top-right corner.
|
||||
let parent = Widget::anchor()
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(
|
||||
Widget::leaf(Vec2::ZERO)
|
||||
.with_id("c")
|
||||
.with_style(LayoutStyle {
|
||||
anchor: Anchor::TOP_RIGHT
|
||||
.with_offsets(Vec2::new(-80.0, 0.0), Vec2::new(0.0, 24.0)),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let tree = layout(&parent, vp(300.0, 200.0), 1.0);
|
||||
let c = tree.find(&"c".into()).unwrap().rect;
|
||||
assert_eq!(c.min, Vec2::new(220.0, 0.0));
|
||||
assert_eq!(c.max, Vec2::new(300.0, 24.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_dpi_scales_offsets() {
|
||||
let parent = Widget::anchor()
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(
|
||||
Widget::leaf(Vec2::ZERO)
|
||||
.with_id("c")
|
||||
.with_style(LayoutStyle {
|
||||
anchor: Anchor::TOP_LEFT.with_offsets(Vec2::ZERO, Vec2::new(40.0, 20.0)),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let tree = layout(&parent, vp(400.0, 400.0), 2.0);
|
||||
let c = tree.find(&"c".into()).unwrap().rect;
|
||||
assert_eq!(c.min, Vec2::ZERO);
|
||||
assert_eq!(c.max, Vec2::new(80.0, 40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_shrinks_content_rect_and_offsets_children() {
|
||||
let row = Widget::row()
|
||||
.with_id("row")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
padding: Insets::all(10.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(Widget::leaf(Vec2::new(50.0, 20.0)).with_id("a"));
|
||||
let tree = layout(&row, vp(200.0, 100.0), 1.0);
|
||||
let row_node = tree.find(&"row".into()).unwrap();
|
||||
assert_eq!(
|
||||
row_node.rect,
|
||||
Rect::from_min_size(Vec2::ZERO, Vec2::new(200.0, 100.0))
|
||||
);
|
||||
assert_eq!(
|
||||
row_node.content_rect,
|
||||
Rect::from_min_size(Vec2::splat(10.0), Vec2::new(180.0, 80.0))
|
||||
);
|
||||
let a = tree.find(&"a".into()).unwrap().rect;
|
||||
assert_eq!(a.min, Vec2::splat(10.0));
|
||||
assert_eq!(a.size(), Vec2::new(50.0, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margin_reserves_space_outside_widget() {
|
||||
let row =
|
||||
Widget::row().with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a").with_style(
|
||||
LayoutStyle {
|
||||
margin: Insets::symmetric(5.0, 0.0),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
let tree = layout(&row, vp(200.0, 100.0), 1.0);
|
||||
let a = tree.find(&"a".into()).unwrap().rect;
|
||||
// 5px left margin → child starts at 5, width 40.
|
||||
assert_eq!(a.min.x, 5.0);
|
||||
assert_eq!(a.max.x, 45.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_content_stack_sums_children_plus_padding() {
|
||||
// Two 30x10 fixed children, no gap, padding=8 → root 76 x 26.
|
||||
let row = Widget::row()
|
||||
.with_id("root")
|
||||
.with_style(LayoutStyle {
|
||||
padding: Insets::all(8.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)))
|
||||
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)));
|
||||
let tree = layout(&row, vp(1000.0, 1000.0), 1.0);
|
||||
let r = tree.find(&"root".into()).unwrap().rect;
|
||||
assert_eq!(r.size(), Vec2::new(76.0, 26.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_tree_round_trips_through_ron() {
|
||||
let w = Widget::row()
|
||||
.with_id("root")
|
||||
.with_gap(4.0)
|
||||
.with_child(Widget::leaf(Vec2::new(20.0, 10.0)).with_id("a"));
|
||||
let tree = layout(&w, vp(100.0, 50.0), 1.0);
|
||||
let text = ron::ser::to_string(&tree).unwrap();
|
||||
let decoded: LayoutTree = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(tree, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_rejects_empty_id() {
|
||||
let w = Widget::leaf(Vec2::ONE);
|
||||
let tree = layout(&w, vp(10.0, 10.0), 1.0);
|
||||
assert!(tree.find(&WidgetId::default()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn children_of_iterates_direct_children_only() {
|
||||
let tree = layout(
|
||||
&Widget::row()
|
||||
.with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("a"))
|
||||
.with_child(
|
||||
Widget::column()
|
||||
.with_id("col")
|
||||
.with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("inner")),
|
||||
),
|
||||
vp(100.0, 100.0),
|
||||
1.0,
|
||||
);
|
||||
let ids: Vec<_> = tree
|
||||
.children_of(0)
|
||||
.map(|n| n.id.as_str().to_owned())
|
||||
.collect();
|
||||
assert_eq!(ids, vec!["a".to_string(), "col".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! In-game UI system — widget tree, layout, styling, text, input routing.
|
||||
//!
|
||||
//! Stage 8 builds the engine's **in-game** UI — what an exported game uses
|
||||
//! to draw its menus, HUDs, and tools. This is intentionally distinct from
|
||||
//! the editor's `egui` (which stays editor-only): a shipped game cannot pull
|
||||
//! in `egui`, so the runtime owns its own widget tree, lays it out, batches
|
||||
//! it through the Stage-5 render pipeline, and routes input through the
|
||||
//! Stage-7 model.
|
||||
//!
|
||||
//! Stage 8 is shipped in pieces:
|
||||
//!
|
||||
//! 1. **Piece 1 — widget tree + layout (this module, right now).** A flat
|
||||
//! [`Widget`] data structure, three layout modes ([`Stack`], [`Grid`],
|
||||
//! [`AnchorGroup`]), and a pure-logic [`layout`] function that turns a
|
||||
//! tree into a [`LayoutTree`] of resolved screen rects. No rendering,
|
||||
//! no input, fully testable headlessly.
|
||||
//! 2. Piece 2 — styling & theming (`Style` / `Theme` + RON dual-edit).
|
||||
//! 3. Piece 3 — text shaping & glyph atlas.
|
||||
//! 4. Piece 4 — 2D overlay render pass.
|
||||
//! 5. Piece 5 — input routing (hit-test, hover/focus/press).
|
||||
//! 6. Piece 6 — events + data binding.
|
||||
//! 7. Pieces 7–9 — GUI tail (`examples/ui_menu`, `examples/ui_hud`, editor
|
||||
//! UI canvas).
|
||||
//!
|
||||
//! # Worked example
|
||||
//!
|
||||
//! ```
|
||||
//! use glam::Vec2;
|
||||
//! use oxide_engine::math::Rect;
|
||||
//! use oxide_engine::ui::{layout, Insets, LayoutStyle, Sizing, Widget};
|
||||
//!
|
||||
//! // A toolbar with two buttons and a stretching spacer between them.
|
||||
//! let toolbar = Widget::row()
|
||||
//! .with_id("toolbar")
|
||||
//! .with_gap(8.0)
|
||||
//! .with_style(LayoutStyle {
|
||||
//! width: Sizing::Grow(1.0),
|
||||
//! height: Sizing::Fixed(32.0),
|
||||
//! padding: Insets::all(4.0),
|
||||
//! ..Default::default()
|
||||
//! })
|
||||
//! .with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("file"))
|
||||
//! .with_child(
|
||||
//! Widget::default()
|
||||
//! .with_id("spacer")
|
||||
//! .with_style(LayoutStyle {
|
||||
//! width: Sizing::Grow(1.0),
|
||||
//! height: Sizing::Grow(1.0),
|
||||
//! ..Default::default()
|
||||
//! }),
|
||||
//! )
|
||||
//! .with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("help"));
|
||||
//!
|
||||
//! let viewport = Rect::from_min_size(Vec2::ZERO, Vec2::new(800.0, 600.0));
|
||||
//! let tree = layout(&toolbar, viewport, 1.0);
|
||||
//! let toolbar_rect = tree.root().unwrap().rect;
|
||||
//! assert_eq!(toolbar_rect.height(), 32.0);
|
||||
//! let help_rect = tree.find(&"help".into()).unwrap().rect;
|
||||
//! assert_eq!(help_rect.max.x, 800.0 - 4.0); // padding on the right
|
||||
//! ```
|
||||
|
||||
mod layout;
|
||||
pub mod paint;
|
||||
mod panel;
|
||||
pub mod routing;
|
||||
mod style;
|
||||
pub mod text;
|
||||
mod theme;
|
||||
mod value;
|
||||
mod visual;
|
||||
mod widget;
|
||||
|
||||
pub use layout::{layout, LayoutNode, LayoutTree};
|
||||
pub use paint::{paint, DrawCommand, PaintedFrame};
|
||||
pub use panel::UiPanel;
|
||||
pub use routing::{hit_test, Router, RouterEvent, RouterFrame};
|
||||
pub use style::{Align, Anchor, Insets, LayoutStyle, Sizing};
|
||||
pub use text::{
|
||||
shape, shape_runs, AtlasEntry, Font, FontError, FontId, FontLoader, FontStore, GlyphAtlas,
|
||||
GlyphId, GlyphKey, RasterizedGlyph, ShapeParams, ShapedGlyph, ShapedLine, ShapedText,
|
||||
TextAlign, TextRun, TextStyle,
|
||||
};
|
||||
pub use theme::Theme;
|
||||
pub use value::WidgetValue;
|
||||
pub use visual::{Border, FontRef, FontWeight, VisualStyle};
|
||||
pub use widget::{
|
||||
AnchorGroup, Grid, Stack, StackDirection, Widget, WidgetId, WidgetKind, WidgetPath,
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Paint — turn a laid-out widget tree into a flat list of draw commands.
|
||||
//!
|
||||
//! Layout (piece 1) is purely geometric: rects in, rects out. Paint (piece 4)
|
||||
//! adds the *visual* dimension: solid fills for backgrounds, textured quads
|
||||
//! for text. The output is a [`PaintedFrame`] — a flat list of
|
||||
//! [`DrawCommand`]s the [`UiOverlayPass`](super::super::render::UiOverlayPass)
|
||||
//! consumes directly. Keeping paint pure-CPU and the GPU pass downstream
|
||||
//! lets every paint test run headlessly; the GPU pass only has to know how
|
||||
//! to *consume* commands, not how to derive them.
|
||||
//!
|
||||
//! # Algorithm
|
||||
//!
|
||||
//! 1. Walk the [`LayoutTree`] in node order (root first, children after).
|
||||
//! 2. For each laid-out node:
|
||||
//! - Resolve its [`VisualStyle`] under the active [`Theme`].
|
||||
//! - If the resolved style has a background, emit one [`DrawCommand::Quad`]
|
||||
//! filling `node.rect`.
|
||||
//! - If the source widget has `text`, shape it inside `node.content_rect`
|
||||
//! with the resolved font / size / color, then emit one
|
||||
//! [`DrawCommand::Glyph`] per non-space glyph.
|
||||
//! 3. The frame's overall `size` mirrors the layout root's rect so the GPU
|
||||
//! pass knows how big the viewport for this batch is.
|
||||
//!
|
||||
//! Render order is the layout order: parents before children, so the
|
||||
//! children draw *on top of* their parents (matching standard UI layering).
|
||||
|
||||
use glam::Vec2;
|
||||
|
||||
use super::layout::LayoutTree;
|
||||
use super::text::{shape, FontStore, GlyphKey, ShapeParams, TextStyle};
|
||||
use super::theme::Theme;
|
||||
use super::visual::VisualStyle;
|
||||
use super::widget::Widget;
|
||||
use crate::math::{Color, Rect};
|
||||
|
||||
/// One draw call in a painted UI frame.
|
||||
///
|
||||
/// All commands share a single GPU pipeline and one texture (the glyph
|
||||
/// atlas). Solid quads emit a sentinel UV the shader recognises as
|
||||
/// "untextured" so a single fragment path handles both cases.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DrawCommand {
|
||||
/// A solid-colored axis-aligned rectangle.
|
||||
Quad { rect: Rect, color: Color },
|
||||
/// One glyph quad — the renderer turns the [`GlyphKey`] into an atlas
|
||||
/// region at draw time. `pen_position` is the **baseline** point; the
|
||||
/// atlas's per-glyph bearing positions the quad relative to it.
|
||||
Glyph {
|
||||
key: GlyphKey,
|
||||
pen_position: Vec2,
|
||||
color: Color,
|
||||
},
|
||||
}
|
||||
|
||||
/// Output of [`paint`] — the size of the painted area and the ordered list
|
||||
/// of draw commands.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct PaintedFrame {
|
||||
/// Size of the painted area in (post-scale) pixels — usually the
|
||||
/// layout root's rect size.
|
||||
pub size: Vec2,
|
||||
/// Draw commands in the order they should be submitted (back-to-front).
|
||||
pub commands: Vec<DrawCommand>,
|
||||
}
|
||||
|
||||
/// Walk a laid-out widget tree under a theme and produce the draw commands
|
||||
/// for one frame.
|
||||
///
|
||||
/// `scale` matches the value passed to
|
||||
/// [`layout`](super::layout::layout) — paint uses it to pass the same DPI
|
||||
/// factor to [`shape`] for text.
|
||||
pub fn paint(
|
||||
root: &Widget,
|
||||
tree: &LayoutTree,
|
||||
theme: &Theme,
|
||||
fonts: &FontStore,
|
||||
scale: f32,
|
||||
) -> PaintedFrame {
|
||||
let mut commands = Vec::new();
|
||||
paint_widget(root, tree, 0, theme, fonts, scale, &mut commands);
|
||||
let size = tree
|
||||
.root()
|
||||
.map(|node| node.rect.size())
|
||||
.unwrap_or(Vec2::ZERO);
|
||||
PaintedFrame { size, commands }
|
||||
}
|
||||
|
||||
fn paint_widget(
|
||||
widget: &Widget,
|
||||
tree: &LayoutTree,
|
||||
node_index: usize,
|
||||
theme: &Theme,
|
||||
fonts: &FontStore,
|
||||
scale: f32,
|
||||
out: &mut Vec<DrawCommand>,
|
||||
) {
|
||||
let node = &tree.nodes()[node_index];
|
||||
let resolved = widget.resolve_visual(theme);
|
||||
|
||||
// Background fill — only emit if the rect has area and a background was
|
||||
// resolved. A `corner_radius` is captured in the resolved style for
|
||||
// future use but ignored by piece-4's rectangular renderer.
|
||||
if let Some(bg) = resolved.background {
|
||||
if !node.rect.is_empty() {
|
||||
out.push(DrawCommand::Quad {
|
||||
rect: node.rect,
|
||||
color: bg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Text — shape inside `content_rect` (so padding is respected) and emit
|
||||
// one glyph per non-empty position.
|
||||
if let Some(text) = widget.text.as_ref() {
|
||||
paint_text(text, node.content_rect, &resolved, fonts, scale, out);
|
||||
}
|
||||
|
||||
// Children draw on top of self.
|
||||
for (child_widget, child_index) in widget.children().iter().zip(node.children.iter()) {
|
||||
paint_widget(
|
||||
child_widget,
|
||||
tree,
|
||||
*child_index as usize,
|
||||
theme,
|
||||
fonts,
|
||||
scale,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_text(
|
||||
text: &str,
|
||||
content_rect: Rect,
|
||||
resolved: &VisualStyle,
|
||||
fonts: &FontStore,
|
||||
scale: f32,
|
||||
out: &mut Vec<DrawCommand>,
|
||||
) {
|
||||
let Some(font_ref) = resolved.font.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(font_id) = fonts.resolve(font_ref) else {
|
||||
return;
|
||||
};
|
||||
let size_px = resolved.font_size.unwrap_or(14.0);
|
||||
let color = resolved.foreground.unwrap_or(Color::BLACK);
|
||||
let style = TextStyle {
|
||||
font: font_id,
|
||||
size_px,
|
||||
};
|
||||
let params = ShapeParams {
|
||||
max_width: Some(content_rect.width()),
|
||||
scale,
|
||||
..ShapeParams::default()
|
||||
};
|
||||
let shaped = shape(text, style, ¶ms, fonts);
|
||||
for line in &shaped.lines {
|
||||
for g in &line.glyphs {
|
||||
out.push(DrawCommand::Glyph {
|
||||
key: g.key,
|
||||
pen_position: content_rect.min + g.position,
|
||||
color,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::layout::layout;
|
||||
use super::super::text::{common_system_font_paths, Font};
|
||||
use super::super::visual::FontRef;
|
||||
use super::super::widget::Widget;
|
||||
use super::*;
|
||||
use glam::Vec2;
|
||||
|
||||
fn viewport(w: f32, h: f32) -> Rect {
|
||||
Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h))
|
||||
}
|
||||
|
||||
fn solid_panel(color: Color, w: f32, h: f32) -> Widget {
|
||||
Widget::leaf(Vec2::new(w, h)).with_visual(VisualStyle {
|
||||
background: Some(color),
|
||||
..VisualStyle::EMPTY
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_widget_emits_one_quad_at_its_rect() {
|
||||
let root = solid_panel(Color::RED, 40.0, 20.0);
|
||||
let tree = layout(&root, viewport(100.0, 50.0), 1.0);
|
||||
let theme = Theme::new();
|
||||
let fonts = FontStore::new();
|
||||
let painted = paint(&root, &tree, &theme, &fonts, 1.0);
|
||||
assert_eq!(painted.size, Vec2::new(40.0, 20.0));
|
||||
assert_eq!(painted.commands.len(), 1);
|
||||
match &painted.commands[0] {
|
||||
DrawCommand::Quad { rect, color } => {
|
||||
assert_eq!(
|
||||
*rect,
|
||||
Rect::from_min_size(Vec2::ZERO, Vec2::new(40.0, 20.0))
|
||||
);
|
||||
assert_eq!(*color, Color::RED);
|
||||
}
|
||||
_ => panic!("expected a Quad"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_without_visual_emits_no_quads() {
|
||||
// Default widget has empty visual — nothing to paint.
|
||||
let root = Widget::leaf(Vec2::new(40.0, 20.0));
|
||||
let tree = layout(&root, viewport(100.0, 50.0), 1.0);
|
||||
let painted = paint(&root, &tree, &Theme::new(), &FontStore::new(), 1.0);
|
||||
assert!(painted.commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_quad_is_emitted_after_parent_quad() {
|
||||
let root = Widget::row()
|
||||
.with_visual(VisualStyle {
|
||||
background: Some(Color::WHITE),
|
||||
..VisualStyle::EMPTY
|
||||
})
|
||||
.with_style(super::super::style::LayoutStyle {
|
||||
width: super::super::style::Sizing::Fixed(100.0),
|
||||
height: super::super::style::Sizing::Fixed(50.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(solid_panel(Color::RED, 40.0, 20.0));
|
||||
let tree = layout(&root, viewport(200.0, 100.0), 1.0);
|
||||
let painted = paint(&root, &tree, &Theme::new(), &FontStore::new(), 1.0);
|
||||
assert_eq!(painted.commands.len(), 2);
|
||||
// Parent (white) painted before child (red), so child draws on top.
|
||||
match &painted.commands[0] {
|
||||
DrawCommand::Quad { color, .. } => assert_eq!(*color, Color::WHITE),
|
||||
_ => panic!(),
|
||||
}
|
||||
match &painted.commands[1] {
|
||||
DrawCommand::Quad { color, .. } => assert_eq!(*color, Color::RED),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_load_font() -> Option<Font> {
|
||||
for path in common_system_font_paths() {
|
||||
if std::path::Path::new(path).exists() {
|
||||
if let Ok(font) = Font::from_path(path) {
|
||||
return Some(font);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("SKIP: no system font available for paint tests");
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_emits_one_glyph_per_visible_char() {
|
||||
let Some(font) = try_load_font() else {
|
||||
return;
|
||||
};
|
||||
let descriptor = FontRef::regular("Sys");
|
||||
let mut fonts = FontStore::new();
|
||||
fonts.insert_with_descriptor(descriptor.clone(), font);
|
||||
let theme = Theme::new().with_default(VisualStyle {
|
||||
font: Some(descriptor),
|
||||
font_size: Some(14.0),
|
||||
foreground: Some(Color::BLACK),
|
||||
..VisualStyle::EMPTY
|
||||
});
|
||||
|
||||
let root = Widget::leaf(Vec2::new(80.0, 20.0))
|
||||
.with_id("label")
|
||||
.with_text("Hi")
|
||||
.with_style(super::super::style::LayoutStyle {
|
||||
width: super::super::style::Sizing::Fixed(80.0),
|
||||
height: super::super::style::Sizing::Fixed(20.0),
|
||||
..Default::default()
|
||||
});
|
||||
let tree = layout(&root, viewport(200.0, 100.0), 1.0);
|
||||
let painted = paint(&root, &tree, &theme, &fonts, 1.0);
|
||||
|
||||
// "Hi" → 2 glyphs (H, i). No background → no Quad commands.
|
||||
let glyph_count = painted
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|c| matches!(c, DrawCommand::Glyph { .. }))
|
||||
.count();
|
||||
let quad_count = painted
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|c| matches!(c, DrawCommand::Quad { .. }))
|
||||
.count();
|
||||
assert_eq!(glyph_count, 2);
|
||||
assert_eq!(quad_count, 0);
|
||||
|
||||
// Both glyphs sit at the same baseline.
|
||||
let baselines: Vec<f32> = painted
|
||||
.commands
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
DrawCommand::Glyph { pen_position, .. } => Some(pen_position.y),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(baselines[0], baselines[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_without_font_in_theme_silently_emits_nothing() {
|
||||
// No font registered → text resolves but shape returns no lines.
|
||||
// Paint must not panic.
|
||||
let root = Widget::leaf(Vec2::new(40.0, 20.0)).with_text("Hi");
|
||||
let tree = layout(&root, viewport(100.0, 50.0), 1.0);
|
||||
let painted = paint(&root, &tree, &Theme::new(), &FontStore::new(), 1.0);
|
||||
assert!(painted.commands.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! World-space UI panels — a [`Widget`] tree rendered onto a quad in 3D.
|
||||
//!
|
||||
//! Stage 8's UI is "in-game UI" — what the exported game uses to draw
|
||||
//! menus and HUDs. Most of the time those are **screen-space**: pixel-
|
||||
//! anchored, drawn over the 3D scene by piece 4a's
|
||||
//! [`UiOverlayPass`](super::super::render::UiOverlayPass) using an
|
||||
//! orthographic projection. A [`UiPanel`] is the world-space alternative —
|
||||
//! the same `Widget` tree, but laid out on a flat panel that sits in the
|
||||
//! 3D world at some [`Transform`].
|
||||
//!
|
||||
//! This is what gives game projects:
|
||||
//!
|
||||
//! - **Diegetic UI** — terminal screens, signs, dashboards, control
|
||||
//! panels — the player sees them rendered inside the world rather than
|
||||
//! pasted over it.
|
||||
//! - **Editor previews** — the UI canvas (piece 9) can drop a panel into
|
||||
//! the scene to preview a document at scale, on the same hardware path
|
||||
//! the shipped game uses.
|
||||
//! - **VR / room-scale UI** later — once Stage-13 head-mounted display
|
||||
//! support lands, world-space panels are the only sensible way to
|
||||
//! present interactive UI.
|
||||
//!
|
||||
//! # How the math works
|
||||
//!
|
||||
//! A panel describes itself in two coordinate spaces:
|
||||
//!
|
||||
//! - **Pixel space** — where the layout algorithm operates. `pixel_size`
|
||||
//! is the resolution the `Widget` tree is laid out at (e.g.,
|
||||
//! `Vec2::new(1024.0, 768.0)`). Glyphs are rasterized at this scale.
|
||||
//! - **World space** — where the panel sits in 3D. `world_size` is its
|
||||
//! physical size in world units (e.g., `Vec2::new(2.0, 1.5)` for a
|
||||
//! 2 m × 1.5 m monitor).
|
||||
//!
|
||||
//! The piece-4 vertex format carries 2D pixel-space positions. To draw
|
||||
//! that on a 3D quad, [`UiBatch::world_space`](super::super::render::UiBatch::world_space)
|
||||
//! builds a single MVP that composes:
|
||||
//!
|
||||
//! ```text
|
||||
//! mvp = camera_view_projection
|
||||
//! * panel_transform // world placement
|
||||
//! * scale(world.x / pixel.x, -world.y / pixel.y, 1) // pixels → world (and flip y, since UI is y-down)
|
||||
//! * translate(-pixel.x / 2, -pixel.y / 2, 0) // recentre pixel origin
|
||||
//! ```
|
||||
//!
|
||||
//! The same `UiOverlayPass` then draws the panel using the same shader
|
||||
//! and the same R8 atlas — the only thing that distinguishes a screen-
|
||||
//! space batch from a world-space one is which constructor built it.
|
||||
//!
|
||||
//! # Overlay semantics for piece 4b
|
||||
//!
|
||||
//! World-space panels in piece 4b render as **overlays**: no depth test,
|
||||
//! no depth write — they draw on top of whatever's already in the color
|
||||
//! target. That keeps the implementation simple and matches the common
|
||||
//! "always-visible" use case (player nameplates, mission markers,
|
||||
//! editor canvas previews).
|
||||
//!
|
||||
//! A future depth-aware mode (where a panel behind a wall is properly
|
||||
//! hidden) is in [`PLAN.md`](../../../../PLAN.md)'s Stage-8 backlog and
|
||||
//! slots in by attaching a depth attachment to a second pass of the
|
||||
//! same pipeline.
|
||||
|
||||
use glam::Mat4;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::layout::layout;
|
||||
use super::paint::paint;
|
||||
use super::text::FontStore;
|
||||
use super::theme::Theme;
|
||||
use super::widget::Widget;
|
||||
use crate::math::{Rect, Transform, Vec2};
|
||||
|
||||
/// A widget tree placed on a 3D quad.
|
||||
///
|
||||
/// `UiPanel` carries pure data: the document, its pixel resolution, and
|
||||
/// its world size. The host owns the panel's [`Transform`] separately
|
||||
/// (typically as an ECS component on the same entity), the active
|
||||
/// [`Theme`], and the [`FontStore`] — all three are needed at render
|
||||
/// time to build the panel's [`UiBatch`](super::super::render::UiBatch).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UiPanel {
|
||||
/// The UI document on this panel.
|
||||
pub root: Widget,
|
||||
/// Resolution to lay out the UI at, in logical pixels. Drives the
|
||||
/// pixel size of every glyph rasterization (so a higher
|
||||
/// `pixel_size.x` on the same `world_size.x` produces a crisper
|
||||
/// panel at a cost of more atlas memory).
|
||||
pub pixel_size: Vec2,
|
||||
/// Panel dimensions in world units. Together with `pixel_size` this
|
||||
/// gives the pixels-per-world-unit ratio the MVP uses.
|
||||
pub world_size: Vec2,
|
||||
}
|
||||
|
||||
impl UiPanel {
|
||||
/// Build a panel with the given UI document and dimensions. Equivalent
|
||||
/// to the struct literal; kept as a function so the API can grow
|
||||
/// validation later without breaking callers.
|
||||
pub fn new(root: Widget, pixel_size: Vec2, world_size: Vec2) -> Self {
|
||||
Self {
|
||||
root,
|
||||
pixel_size,
|
||||
world_size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: lay out + paint this panel and build the
|
||||
/// [`UiBatch`](super::super::render::UiBatch) the
|
||||
/// [`UiOverlayPass`](super::super::render::UiOverlayPass) consumes.
|
||||
///
|
||||
/// Returns `None` if the panel's `pixel_size` is non-positive — the
|
||||
/// caller didn't configure the panel and there's no meaningful
|
||||
/// rendering to do.
|
||||
pub fn build_batch(
|
||||
&self,
|
||||
theme: &Theme,
|
||||
fonts: &FontStore,
|
||||
panel_transform: &Transform,
|
||||
view_projection: Mat4,
|
||||
) -> Option<super::super::render::UiBatch> {
|
||||
if self.pixel_size.x <= 0.0 || self.pixel_size.y <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let viewport = Rect::from_min_size(Vec2::ZERO, self.pixel_size);
|
||||
let tree = layout(&self.root, viewport, 1.0);
|
||||
let painted = paint(&self.root, &tree, theme, fonts, 1.0);
|
||||
Some(super::super::render::UiBatch::world_space(
|
||||
painted,
|
||||
self.pixel_size,
|
||||
self.world_size,
|
||||
panel_transform,
|
||||
view_projection,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::style::{LayoutStyle, Sizing};
|
||||
use super::super::visual::VisualStyle;
|
||||
use super::*;
|
||||
use crate::math::{Color, Vec3};
|
||||
|
||||
#[test]
|
||||
fn build_batch_returns_none_on_zero_pixel_size() {
|
||||
let panel = UiPanel::new(Widget::default(), Vec2::ZERO, Vec2::new(2.0, 2.0));
|
||||
let theme = Theme::new();
|
||||
let fonts = FontStore::new();
|
||||
let result = panel.build_batch(&theme, &fonts, &Transform::default(), Mat4::IDENTITY);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_batch_succeeds_with_valid_panel() {
|
||||
let root = Widget::default()
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_visual(VisualStyle {
|
||||
background: Some(Color::RED),
|
||||
..VisualStyle::EMPTY
|
||||
});
|
||||
let panel = UiPanel::new(root, Vec2::new(256.0, 128.0), Vec2::new(2.0, 1.0));
|
||||
let theme = Theme::new();
|
||||
let fonts = FontStore::new();
|
||||
let batch = panel
|
||||
.build_batch(&theme, &fonts, &Transform::default(), Mat4::IDENTITY)
|
||||
.expect("valid panel should build a batch");
|
||||
// The batch's painted frame matches the panel's pixel size and has
|
||||
// one Quad command (the red background).
|
||||
assert_eq!(batch.frame.size, Vec2::new(256.0, 128.0));
|
||||
assert_eq!(batch.frame.commands.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_round_trips_through_ron() {
|
||||
let panel = UiPanel::new(
|
||||
Widget::row().with_id("hud").with_visual(VisualStyle {
|
||||
background: Some(Color::WHITE),
|
||||
..VisualStyle::EMPTY
|
||||
}),
|
||||
Vec2::new(1024.0, 768.0),
|
||||
Vec2::new(4.0, 3.0),
|
||||
);
|
||||
let text = ron::ser::to_string_pretty(&panel, ron::ser::PrettyConfig::default()).unwrap();
|
||||
let decoded: UiPanel = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(panel, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_mvp_keeps_pixel_origin_at_panel_centre() {
|
||||
// Sanity: with an identity view-projection and default panel
|
||||
// transform, a vertex at (0, 0) in pixel space lands at the top-
|
||||
// left of the panel in world space, which under our MVP becomes
|
||||
// (-world.x/2, +world.y/2, 0) (y-down → y-up).
|
||||
let panel = UiPanel::new(
|
||||
Widget::default()
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_visual(VisualStyle {
|
||||
background: Some(Color::RED),
|
||||
..VisualStyle::EMPTY
|
||||
}),
|
||||
Vec2::new(2.0, 2.0),
|
||||
Vec2::new(2.0, 2.0),
|
||||
);
|
||||
let batch = panel
|
||||
.build_batch(
|
||||
&Theme::new(),
|
||||
&FontStore::new(),
|
||||
&Transform::default(),
|
||||
Mat4::IDENTITY,
|
||||
)
|
||||
.unwrap();
|
||||
// Apply the MVP to the pixel-space top-left (0, 0, 0, 1).
|
||||
let top_left = batch.mvp * Vec3::new(0.0, 0.0, 0.0).extend(1.0);
|
||||
assert!(
|
||||
(top_left.x - -1.0).abs() < 1e-5 && (top_left.y - 1.0).abs() < 1e-5,
|
||||
"top-left should map to (-1, 1) under identity MVP, got {top_left:?}"
|
||||
);
|
||||
// Bottom-right pixel maps to (+world.x/2, -world.y/2).
|
||||
let bottom_right = batch.mvp * Vec3::new(2.0, 2.0, 0.0).extend(1.0);
|
||||
assert!(
|
||||
(bottom_right.x - 1.0).abs() < 1e-5 && (bottom_right.y - -1.0).abs() < 1e-5,
|
||||
"bottom-right should map to (1, -1), got {bottom_right:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
//! Input routing — hit-test the UI against the cursor, track hover / press /
|
||||
//! focus per widget, and tell the host whether the UI captured the frame's
|
||||
//! input so the game can decide whether to also handle it.
|
||||
//!
|
||||
//! Stage 8's UI must *consume input before the game* (PLAN.md): if the
|
||||
//! cursor is over a button, clicking shouldn't also fire the game-world
|
||||
//! action bound to that mouse button. The [`Router`] gives the host one
|
||||
//! object to drive each frame:
|
||||
//!
|
||||
//! ```text
|
||||
//! game loop:
|
||||
//! input.handle_event(e); ...
|
||||
//! let frame = router.process(&layout_tree, &input);
|
||||
//! if !frame.captured_mouse { /* game receives mouse input */ }
|
||||
//! if !frame.captured_keyboard { /* game receives keys */ }
|
||||
//! for event in &frame.events { /* run widget callbacks (piece 6) */ }
|
||||
//! ```
|
||||
//!
|
||||
//! The router is purely a state machine over the Stage-7 [`InputState`] and
|
||||
//! the Stage-8 [`LayoutTree`] — no GPU, no widget callbacks (those land in
|
||||
//! piece 6). Tests run headlessly.
|
||||
//!
|
||||
//! # Hit-test order
|
||||
//!
|
||||
//! Hit testing walks [`LayoutTree::nodes`] in **reverse order**. That order
|
||||
//! matches the paint order (parents-before-children, earlier siblings
|
||||
//! before later ones — see [`super::paint`]) — so the *last* node drawn
|
||||
//! is the *first* one tested, which is exactly the topmost interactive
|
||||
//! widget under the cursor.
|
||||
//!
|
||||
//! Anonymous widgets (`WidgetId::default()`) are treated as transparent
|
||||
//! for hit-test purposes: the router skips them and looks deeper, so a
|
||||
//! decorative container without an id doesn't block clicks reaching the
|
||||
//! button inside it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use winit::event::MouseButton;
|
||||
|
||||
use super::layout::{LayoutNode, LayoutTree};
|
||||
use super::widget::WidgetId;
|
||||
use crate::input::InputState;
|
||||
use crate::math::Vec2;
|
||||
|
||||
/// One event emitted by [`Router::process`] for the current frame.
|
||||
///
|
||||
/// Events are ordered: hover changes come first, then per-button press /
|
||||
/// release / click, then focus changes. Callers in piece 6 will dispatch
|
||||
/// each event to the matching widget's registered callback.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum RouterEvent {
|
||||
/// The cursor moved onto this widget this frame.
|
||||
Hovered(WidgetId),
|
||||
/// The cursor moved off this widget this frame.
|
||||
Unhovered(WidgetId),
|
||||
/// A mouse button was pressed while the cursor was over this widget.
|
||||
Pressed(WidgetId, MouseButton),
|
||||
/// A mouse button was released while the cursor was over this widget.
|
||||
/// May or may not be accompanied by a [`Clicked`](Self::Clicked); see
|
||||
/// the comment on that variant.
|
||||
Released(WidgetId, MouseButton),
|
||||
/// A click completed on this widget: the press *and* release happened
|
||||
/// over the same widget without the cursor leaving in between.
|
||||
/// Dragging off cancels the click.
|
||||
Clicked(WidgetId, MouseButton),
|
||||
/// This widget became the focused widget.
|
||||
FocusGained(WidgetId),
|
||||
/// This widget lost focus.
|
||||
FocusLost(WidgetId),
|
||||
}
|
||||
|
||||
/// What [`Router::process`] produces for one frame.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct RouterFrame {
|
||||
/// Events emitted this frame, in the order they were observed.
|
||||
pub events: Vec<RouterEvent>,
|
||||
/// `true` if the cursor is over any (non-anonymous) widget — the game
|
||||
/// should not also process this frame's mouse input.
|
||||
pub captured_mouse: bool,
|
||||
/// `true` if a widget currently has keyboard focus — the game should
|
||||
/// not also process this frame's key events.
|
||||
pub captured_keyboard: bool,
|
||||
}
|
||||
|
||||
impl RouterFrame {
|
||||
/// `true` if this frame contains a `Clicked` event on `id` for the
|
||||
/// given mouse button. The immediate-mode pattern: game code calls
|
||||
/// `if frame.clicked("play", MouseButton::Left) { start_game() }`
|
||||
/// instead of registering a callback.
|
||||
pub fn clicked(&self, id: impl AsRef<str>, button: MouseButton) -> bool {
|
||||
let id = id.as_ref();
|
||||
self.events.iter().any(|e| match e {
|
||||
RouterEvent::Clicked(w, b) => w.as_str() == id && *b == button,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Shorthand for [`clicked`](Self::clicked) with the left button.
|
||||
pub fn clicked_left(&self, id: impl AsRef<str>) -> bool {
|
||||
self.clicked(id, MouseButton::Left)
|
||||
}
|
||||
|
||||
/// `true` if this frame contains a `Pressed` event on `id` with the
|
||||
/// given mouse button.
|
||||
pub fn pressed(&self, id: impl AsRef<str>, button: MouseButton) -> bool {
|
||||
let id = id.as_ref();
|
||||
self.events.iter().any(|e| match e {
|
||||
RouterEvent::Pressed(w, b) => w.as_str() == id && *b == button,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` if this frame contains a `Released` event on `id` with the
|
||||
/// given mouse button.
|
||||
pub fn released(&self, id: impl AsRef<str>, button: MouseButton) -> bool {
|
||||
let id = id.as_ref();
|
||||
self.events.iter().any(|e| match e {
|
||||
RouterEvent::Released(w, b) => w.as_str() == id && *b == button,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` if the cursor entered `id` this frame.
|
||||
pub fn hovered_in(&self, id: impl AsRef<str>) -> bool {
|
||||
let id = id.as_ref();
|
||||
self.events.iter().any(|e| match e {
|
||||
RouterEvent::Hovered(w) => w.as_str() == id,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` if the cursor left `id` this frame.
|
||||
pub fn hovered_out(&self, id: impl AsRef<str>) -> bool {
|
||||
let id = id.as_ref();
|
||||
self.events.iter().any(|e| match e {
|
||||
RouterEvent::Unhovered(w) => w.as_str() == id,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` if `id` gained focus this frame.
|
||||
pub fn focus_gained(&self, id: impl AsRef<str>) -> bool {
|
||||
let id = id.as_ref();
|
||||
self.events.iter().any(|e| match e {
|
||||
RouterEvent::FocusGained(w) => w.as_str() == id,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` if `id` lost focus this frame.
|
||||
pub fn focus_lost(&self, id: impl AsRef<str>) -> bool {
|
||||
let id = id.as_ref();
|
||||
self.events.iter().any(|e| match e {
|
||||
RouterEvent::FocusLost(w) => w.as_str() == id,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-widget input state machine. Persists hover / focus / pending-press
|
||||
/// across frames so click-detection (press *and* release on the same
|
||||
/// widget) works correctly across the multiple frames a click typically
|
||||
/// spans.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Router {
|
||||
hovered: Option<WidgetId>,
|
||||
focused: Option<WidgetId>,
|
||||
/// Per-button: the widget that received the most recent un-released
|
||||
/// press. A click completes if the release happens over the same
|
||||
/// widget; otherwise the press is cancelled (drag-off semantics).
|
||||
pending: HashMap<MouseButton, WidgetId>,
|
||||
}
|
||||
|
||||
impl Router {
|
||||
/// Build an empty router with no hover, no focus, and no pending presses.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The currently hovered widget, or `None` when the cursor is not over
|
||||
/// any addressable widget.
|
||||
pub fn hovered(&self) -> Option<&WidgetId> {
|
||||
self.hovered.as_ref()
|
||||
}
|
||||
|
||||
/// The currently focused widget, or `None` if none.
|
||||
pub fn focused(&self) -> Option<&WidgetId> {
|
||||
self.focused.as_ref()
|
||||
}
|
||||
|
||||
/// Explicitly focus a widget (e.g., from game code after opening a
|
||||
/// menu). Emits no event — the caller decided to do this.
|
||||
pub fn set_focused(&mut self, id: Option<WidgetId>) {
|
||||
self.focused = id;
|
||||
}
|
||||
|
||||
/// Run the input pipeline against one frame's [`InputState`] and the
|
||||
/// current [`LayoutTree`]. Updates internal state, returns events plus
|
||||
/// the capture flags.
|
||||
pub fn process(&mut self, tree: &LayoutTree, input: &InputState) -> RouterFrame {
|
||||
let mut frame = RouterFrame::default();
|
||||
let new_hover = input
|
||||
.cursor()
|
||||
.and_then(|c| hit_test(tree, c))
|
||||
.map(|node| node.id.clone());
|
||||
|
||||
// Hover transitions.
|
||||
if new_hover != self.hovered {
|
||||
if let Some(old) = self.hovered.take() {
|
||||
frame.events.push(RouterEvent::Unhovered(old));
|
||||
}
|
||||
if let Some(new) = new_hover.clone() {
|
||||
frame.events.push(RouterEvent::Hovered(new));
|
||||
}
|
||||
}
|
||||
self.hovered = new_hover;
|
||||
frame.captured_mouse = self.hovered.is_some();
|
||||
|
||||
// Mouse press / release per button. The Stage-7 InputState
|
||||
// exposes "buttons held" + per-button edge flags; we walk the
|
||||
// currently-relevant buttons (those held this frame *or* present
|
||||
// as pending from previous frames).
|
||||
let mut buttons = std::collections::HashSet::new();
|
||||
buttons.extend(input.mouse_buttons_held());
|
||||
buttons.extend(self.pending.keys().copied());
|
||||
// Common buttons that may have just pressed/released without being
|
||||
// held now (release edge happens after the held set has cleared
|
||||
// the button).
|
||||
for b in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] {
|
||||
if input.mouse_pressed(b) || input.mouse_released(b) {
|
||||
buttons.insert(b);
|
||||
}
|
||||
}
|
||||
|
||||
for button in buttons {
|
||||
if input.mouse_pressed(button) {
|
||||
if let Some(target) = self.hovered.clone() {
|
||||
frame
|
||||
.events
|
||||
.push(RouterEvent::Pressed(target.clone(), button));
|
||||
self.pending.insert(button, target.clone());
|
||||
self.update_focus(Some(target), &mut frame);
|
||||
} else {
|
||||
// Click outside any widget clears focus.
|
||||
self.update_focus(None, &mut frame);
|
||||
}
|
||||
}
|
||||
if input.mouse_released(button) {
|
||||
if let Some(pending_id) = self.pending.remove(&button) {
|
||||
if let Some(current) = self.hovered.clone() {
|
||||
frame
|
||||
.events
|
||||
.push(RouterEvent::Released(current.clone(), button));
|
||||
if current == pending_id {
|
||||
frame.events.push(RouterEvent::Clicked(current, button));
|
||||
}
|
||||
} else {
|
||||
// Drag-off then release: cancel the click. No
|
||||
// Released event has a target either, since we
|
||||
// require a hovered widget for that.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frame.captured_keyboard = self.focused.is_some();
|
||||
frame
|
||||
}
|
||||
|
||||
/// Move focus to `next` (or clear it when `None`), emitting `FocusLost`
|
||||
/// / `FocusGained` events. Idempotent when `next` matches the current
|
||||
/// focus.
|
||||
fn update_focus(&mut self, next: Option<WidgetId>, frame: &mut RouterFrame) {
|
||||
if next == self.focused {
|
||||
return;
|
||||
}
|
||||
if let Some(old) = self.focused.take() {
|
||||
frame.events.push(RouterEvent::FocusLost(old));
|
||||
}
|
||||
if let Some(new) = next.clone() {
|
||||
frame.events.push(RouterEvent::FocusGained(new));
|
||||
}
|
||||
self.focused = next;
|
||||
}
|
||||
}
|
||||
|
||||
/// Hit-test `point` against the laid-out widgets. Returns the topmost
|
||||
/// (most-recently-painted) [`LayoutNode`] with a non-empty id whose `rect`
|
||||
/// contains the point, or `None` if no addressable widget is under the
|
||||
/// point.
|
||||
///
|
||||
/// Anonymous widgets (empty `id`) are skipped so a decorative container
|
||||
/// doesn't block hits on the button it contains. Iteration is in reverse
|
||||
/// node order — children and later siblings (drawn on top) are tested
|
||||
/// before their parents.
|
||||
pub fn hit_test(tree: &LayoutTree, point: Vec2) -> Option<&LayoutNode> {
|
||||
for node in tree.nodes().iter().rev() {
|
||||
if node.id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if node.rect.contains_point(point) {
|
||||
return Some(node);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::layout::layout;
|
||||
use super::super::style::{LayoutStyle, Sizing};
|
||||
use super::super::widget::Widget;
|
||||
use super::*;
|
||||
use crate::math::Rect;
|
||||
|
||||
fn viewport(w: f32, h: f32) -> Rect {
|
||||
Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h))
|
||||
}
|
||||
|
||||
fn make_tree() -> (Widget, LayoutTree) {
|
||||
// Root container with two side-by-side leaves: "left" and "right".
|
||||
let root = Widget::row()
|
||||
.with_id("root")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(200.0),
|
||||
height: Sizing::Fixed(100.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(
|
||||
Widget::leaf(Vec2::new(100.0, 100.0))
|
||||
.with_id("left")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(100.0),
|
||||
height: Sizing::Fixed(100.0),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.with_child(
|
||||
Widget::leaf(Vec2::new(100.0, 100.0))
|
||||
.with_id("right")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(100.0),
|
||||
height: Sizing::Fixed(100.0),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let tree = layout(&root, viewport(400.0, 200.0), 1.0);
|
||||
(root, tree)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_returns_topmost_widget_with_id() {
|
||||
let (_root, tree) = make_tree();
|
||||
// Cursor over the left child → returns "left", not "root".
|
||||
let hit = hit_test(&tree, Vec2::new(50.0, 50.0)).unwrap();
|
||||
assert_eq!(hit.id.as_str(), "left");
|
||||
// Cursor over the right child → "right".
|
||||
let hit = hit_test(&tree, Vec2::new(150.0, 50.0)).unwrap();
|
||||
assert_eq!(hit.id.as_str(), "right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_falls_back_to_parent_when_children_dont_cover() {
|
||||
// Root 200×100 with 20-pixel padding, containing one 80×60 button.
|
||||
// The padding gutter is "root-only" space — clicks there should
|
||||
// resolve to "root", not the button.
|
||||
let root = Widget::row()
|
||||
.with_id("root")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(200.0),
|
||||
height: Sizing::Fixed(100.0),
|
||||
padding: super::super::style::Insets::all(20.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(
|
||||
Widget::leaf(Vec2::new(80.0, 60.0))
|
||||
.with_id("button")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(80.0),
|
||||
height: Sizing::Fixed(60.0),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let tree = layout(&root, viewport(400.0, 200.0), 1.0);
|
||||
// Inside the button.
|
||||
let hit = hit_test(&tree, Vec2::new(60.0, 50.0)).unwrap();
|
||||
assert_eq!(hit.id.as_str(), "button");
|
||||
// Inside root's padding gutter (10, 50) → root, not button.
|
||||
let hit = hit_test(&tree, Vec2::new(10.0, 50.0)).unwrap();
|
||||
assert_eq!(hit.id.as_str(), "root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_skips_anonymous_widgets() {
|
||||
// A button buried inside two anonymous containers should still hit.
|
||||
let root = Widget::row()
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(200.0),
|
||||
height: Sizing::Fixed(100.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(
|
||||
Widget::row()
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(100.0),
|
||||
height: Sizing::Fixed(100.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(
|
||||
Widget::leaf(Vec2::new(80.0, 80.0))
|
||||
.with_id("button")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(80.0),
|
||||
height: Sizing::Fixed(80.0),
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
);
|
||||
let tree = layout(&root, viewport(200.0, 100.0), 1.0);
|
||||
let hit = hit_test(&tree, Vec2::new(20.0, 20.0)).unwrap();
|
||||
assert_eq!(hit.id.as_str(), "button");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_returns_none_outside_root() {
|
||||
let (_root, tree) = make_tree();
|
||||
let hit = hit_test(&tree, Vec2::new(500.0, 500.0));
|
||||
assert!(hit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_moving_onto_widget_emits_hovered_event() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
|
||||
// First frame: cursor outside, no hover.
|
||||
input.set_cursor(Vec2::new(500.0, 500.0));
|
||||
let f = router.process(&tree, &input);
|
||||
assert!(f.events.is_empty());
|
||||
assert!(!f.captured_mouse);
|
||||
|
||||
// Move into the left widget.
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
let f = router.process(&tree, &input);
|
||||
assert_eq!(f.events, vec![RouterEvent::Hovered("left".into())]);
|
||||
assert!(f.captured_mouse);
|
||||
assert_eq!(router.hovered(), Some(&"left".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_moving_off_emits_unhovered() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
router.process(&tree, &input);
|
||||
|
||||
// Move off the widget.
|
||||
input.set_cursor(Vec2::new(500.0, 500.0));
|
||||
let f = router.process(&tree, &input);
|
||||
assert_eq!(f.events, vec![RouterEvent::Unhovered("left".into())]);
|
||||
assert!(!f.captured_mouse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_moving_between_widgets_swaps_hover() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
router.process(&tree, &input);
|
||||
|
||||
input.set_cursor(Vec2::new(150.0, 50.0));
|
||||
let f = router.process(&tree, &input);
|
||||
// Unhover left, then hover right (both this frame).
|
||||
assert_eq!(
|
||||
f.events,
|
||||
vec![
|
||||
RouterEvent::Unhovered("left".into()),
|
||||
RouterEvent::Hovered("right".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_over_widget_emits_pressed_and_focuses() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
// Frame 1: hover only.
|
||||
router.process(&tree, &input);
|
||||
// Frame 2: press the left button while hovering.
|
||||
input.press_mouse(MouseButton::Left);
|
||||
let f = router.process(&tree, &input);
|
||||
assert!(f
|
||||
.events
|
||||
.contains(&RouterEvent::Pressed("left".into(), MouseButton::Left)));
|
||||
assert!(f.events.contains(&RouterEvent::FocusGained("left".into())));
|
||||
assert_eq!(router.focused(), Some(&"left".into()));
|
||||
assert!(f.captured_keyboard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn press_then_release_on_same_widget_emits_click() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
router.process(&tree, &input);
|
||||
input.press_mouse(MouseButton::Left);
|
||||
router.process(&tree, &input);
|
||||
input.end_frame(); // clear the press edge
|
||||
input.release_mouse(MouseButton::Left);
|
||||
let f = router.process(&tree, &input);
|
||||
// Released and Clicked, both on "left".
|
||||
assert!(f
|
||||
.events
|
||||
.contains(&RouterEvent::Released("left".into(), MouseButton::Left)));
|
||||
assert!(f
|
||||
.events
|
||||
.contains(&RouterEvent::Clicked("left".into(), MouseButton::Left)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn press_then_drag_off_then_release_does_not_emit_click() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
router.process(&tree, &input);
|
||||
input.press_mouse(MouseButton::Left);
|
||||
router.process(&tree, &input);
|
||||
input.end_frame();
|
||||
|
||||
// Drag onto the right widget, then release.
|
||||
input.set_cursor(Vec2::new(150.0, 50.0));
|
||||
input.release_mouse(MouseButton::Left);
|
||||
let f = router.process(&tree, &input);
|
||||
let clicked = f
|
||||
.events
|
||||
.iter()
|
||||
.any(|e| matches!(e, RouterEvent::Clicked(_, _)));
|
||||
assert!(!clicked, "drag-off should cancel the click");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_outside_any_widget_clears_focus() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
router.set_focused(Some("left".into()));
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(500.0, 500.0));
|
||||
input.press_mouse(MouseButton::Left);
|
||||
let f = router.process(&tree, &input);
|
||||
assert!(f.events.contains(&RouterEvent::FocusLost("left".into())));
|
||||
assert_eq!(router.focused(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_flags_match_state() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
// No cursor, no focus → nothing captured.
|
||||
let f = router.process(&tree, &input);
|
||||
assert!(!f.captured_mouse);
|
||||
assert!(!f.captured_keyboard);
|
||||
|
||||
// Cursor over a widget → captures mouse.
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
let f = router.process(&tree, &input);
|
||||
assert!(f.captured_mouse);
|
||||
assert!(!f.captured_keyboard);
|
||||
|
||||
// Press → focuses, captures keyboard too.
|
||||
input.press_mouse(MouseButton::Left);
|
||||
let f = router.process(&tree, &input);
|
||||
assert!(f.captured_keyboard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_off_screen_does_not_hover() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let input = InputState::new(); // cursor unset
|
||||
let f = router.process(&tree, &input);
|
||||
assert!(f.events.is_empty());
|
||||
assert!(!f.captured_mouse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_frame_clicked_query_matches_button_and_id() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
router.process(&tree, &input);
|
||||
input.press_mouse(MouseButton::Left);
|
||||
router.process(&tree, &input);
|
||||
input.end_frame();
|
||||
input.release_mouse(MouseButton::Left);
|
||||
let f = router.process(&tree, &input);
|
||||
// Immediate-mode query: was "left" clicked with Left?
|
||||
assert!(f.clicked_left("left"));
|
||||
assert!(f.clicked("left", MouseButton::Left));
|
||||
// Different id or different button → false.
|
||||
assert!(!f.clicked_left("right"));
|
||||
assert!(!f.clicked("left", MouseButton::Right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_frame_query_methods_cover_each_event_kind() {
|
||||
// Build a frame manually with one of each event variant and
|
||||
// verify each query method matches exactly one.
|
||||
let f = RouterFrame {
|
||||
events: vec![
|
||||
RouterEvent::Hovered("a".into()),
|
||||
RouterEvent::Unhovered("b".into()),
|
||||
RouterEvent::Pressed("c".into(), MouseButton::Right),
|
||||
RouterEvent::Released("d".into(), MouseButton::Middle),
|
||||
RouterEvent::Clicked("e".into(), MouseButton::Left),
|
||||
RouterEvent::FocusGained("f".into()),
|
||||
RouterEvent::FocusLost("g".into()),
|
||||
],
|
||||
captured_mouse: true,
|
||||
captured_keyboard: true,
|
||||
};
|
||||
assert!(f.hovered_in("a"));
|
||||
assert!(f.hovered_out("b"));
|
||||
assert!(f.pressed("c", MouseButton::Right));
|
||||
assert!(f.released("d", MouseButton::Middle));
|
||||
assert!(f.clicked("e", MouseButton::Left));
|
||||
assert!(f.focus_gained("f"));
|
||||
assert!(f.focus_lost("g"));
|
||||
// Negative checks.
|
||||
assert!(!f.hovered_in("b"));
|
||||
assert!(!f.clicked_left("c")); // Pressed, not Clicked
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Layout style primitives — sizing, padding, margin, alignment, and anchors.
|
||||
//!
|
||||
//! Every Stage-8 widget carries a [`LayoutStyle`] that tells the layout
|
||||
//! algorithm how to size and position it inside its parent's content rect.
|
||||
//! The primitives here are deliberately small and orthogonal so they compose
|
||||
//! into the three layout modes (stack, grid, anchor) without each mode
|
||||
//! introducing its own bespoke parameters.
|
||||
//!
|
||||
//! All linear measurements (`Sizing::Fixed`, [`Insets`] fields, anchor
|
||||
//! offsets, stack/grid gaps) are in **logical pixels**. The layout function
|
||||
//! takes a separate `scale` factor (typically the window's DPI scale) and
|
||||
//! multiplies these values at resolve time, so one widget tree lays out
|
||||
//! sensibly on a 1× laptop and a 2× HiDPI monitor without per-widget rewrites.
|
||||
|
||||
use glam::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How a widget asks to be sized along one axis.
|
||||
///
|
||||
/// Sizing interacts with the parent's layout mode:
|
||||
///
|
||||
/// - In a stack, the **main axis** sums all `Fixed` and `FitContent` sizes,
|
||||
/// then divides leftover space among `Grow` siblings by weight. The
|
||||
/// **cross axis** sizes each child independently (`Grow` fills the parent's
|
||||
/// cross extent; the other variants behave like the main axis).
|
||||
/// - In a grid, every child fills its cell, but `Fixed`/`FitContent` cap the
|
||||
/// child's drawn size and let [`LayoutStyle::align_horizontal`] /
|
||||
/// [`LayoutStyle::align_vertical`] position the smaller rect inside the
|
||||
/// cell.
|
||||
/// - In an anchor parent, child sizing is **ignored** along axes the anchor
|
||||
/// actually constrains; the anchor + offsets fully determine the child's
|
||||
/// rect.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Sizing {
|
||||
/// A fixed size in logical pixels. Multiplied by the layout scale factor.
|
||||
Fixed(f32),
|
||||
/// Take a share of the parent's leftover space, weighted by `f32`.
|
||||
///
|
||||
/// Two siblings with `Grow(1.0)` split leftover space evenly; `Grow(2.0)`
|
||||
/// next to `Grow(1.0)` takes 2/3 of it. A non-positive weight contributes
|
||||
/// nothing and the child collapses to zero on that axis.
|
||||
Grow(f32),
|
||||
/// Size to fit the widget's own content — the intrinsic size for leaves,
|
||||
/// the recursive content extent for containers.
|
||||
#[default]
|
||||
FitContent,
|
||||
}
|
||||
|
||||
/// Per-side spacing in logical pixels — used for both padding (inside) and
|
||||
/// margin (outside).
|
||||
///
|
||||
/// Padding shrinks a widget's `content_rect` (children draw inside it); margin
|
||||
/// reserves space *around* the widget so siblings don't touch it. Both are
|
||||
/// scaled by the layout scale factor at resolve time.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct Insets {
|
||||
pub left: f32,
|
||||
pub right: f32,
|
||||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
}
|
||||
|
||||
impl Insets {
|
||||
pub const ZERO: Self = Self {
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
};
|
||||
|
||||
/// Same value on every side.
|
||||
pub const fn all(v: f32) -> Self {
|
||||
Self {
|
||||
left: v,
|
||||
right: v,
|
||||
top: v,
|
||||
bottom: v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Symmetric: one value for left+right, another for top+bottom.
|
||||
pub const fn symmetric(horizontal: f32, vertical: f32) -> Self {
|
||||
Self {
|
||||
left: horizontal,
|
||||
right: horizontal,
|
||||
top: vertical,
|
||||
bottom: vertical,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined horizontal extent (`left + right`).
|
||||
#[inline]
|
||||
pub fn horizontal(&self) -> f32 {
|
||||
self.left + self.right
|
||||
}
|
||||
|
||||
/// Combined vertical extent (`top + bottom`).
|
||||
#[inline]
|
||||
pub fn vertical(&self) -> f32 {
|
||||
self.top + self.bottom
|
||||
}
|
||||
|
||||
/// Component-wise scale (used internally by the layout algorithm to apply
|
||||
/// the DPI factor; exposed for tests that want to verify the scaling).
|
||||
#[inline]
|
||||
pub fn scaled(&self, scale: f32) -> Self {
|
||||
Self {
|
||||
left: self.left * scale,
|
||||
right: self.right * scale,
|
||||
top: self.top * scale,
|
||||
bottom: self.bottom * scale,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alignment along one axis when a widget is smaller than its slot.
|
||||
///
|
||||
/// In a row stack, `align_vertical` decides whether a short child docks to the
|
||||
/// top, middle, or bottom of the row's content rect. The stack's own
|
||||
/// [`Stack::main_align`](super::widget::Stack::main_align) does the analogous
|
||||
/// thing along the **main** axis when all children are sized but don't sum to
|
||||
/// the full main extent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum Align {
|
||||
/// Top / left edge.
|
||||
#[default]
|
||||
Start,
|
||||
/// Centered in the available space.
|
||||
Center,
|
||||
/// Bottom / right edge.
|
||||
End,
|
||||
}
|
||||
|
||||
/// How a child positions itself inside an [`AnchorGroup`](super::widget::AnchorGroup)
|
||||
/// parent.
|
||||
///
|
||||
/// Anchors are two normalized points in `[0, 1]²` (the **anchor rectangle**)
|
||||
/// plus per-corner offsets in logical pixels. The child's resulting rect is:
|
||||
///
|
||||
/// ```text
|
||||
/// rect.min = parent.content.min + parent.content.size * anchor.min + offset_min * scale
|
||||
/// rect.max = parent.content.min + parent.content.size * anchor.max + offset_max * scale
|
||||
/// ```
|
||||
///
|
||||
/// This is the standard Unity / Godot anchor formulation: pick two anchor
|
||||
/// corners (a single point for "follow that corner", a full rectangle for
|
||||
/// "dock to this edge / fill"), then nudge with offsets. The default is
|
||||
/// [`Anchor::FILL`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Anchor {
|
||||
pub min: Vec2,
|
||||
pub max: Vec2,
|
||||
pub offset_min: Vec2,
|
||||
pub offset_max: Vec2,
|
||||
}
|
||||
|
||||
impl Anchor {
|
||||
/// Fill the parent's content rect exactly. The default for new widgets.
|
||||
pub const FILL: Self = Self {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::ONE,
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Pin to the top-left corner with `offset_max` controlling the child's
|
||||
/// size (which is otherwise zero because `min == max`).
|
||||
pub const TOP_LEFT: Self = Self {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::ZERO,
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Pin to the top-right corner.
|
||||
pub const TOP_RIGHT: Self = Self {
|
||||
min: Vec2::new(1.0, 0.0),
|
||||
max: Vec2::new(1.0, 0.0),
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Pin to the bottom-left corner.
|
||||
pub const BOTTOM_LEFT: Self = Self {
|
||||
min: Vec2::new(0.0, 1.0),
|
||||
max: Vec2::new(0.0, 1.0),
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Pin to the bottom-right corner.
|
||||
pub const BOTTOM_RIGHT: Self = Self {
|
||||
min: Vec2::ONE,
|
||||
max: Vec2::ONE,
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Dock to the top edge — full width, child height controlled by
|
||||
/// `offset_max.y`.
|
||||
pub const TOP: Self = Self {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::new(1.0, 0.0),
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Dock to the bottom edge — full width, child height controlled by
|
||||
/// `offset_min.y` (negative pushes the top edge upward).
|
||||
pub const BOTTOM: Self = Self {
|
||||
min: Vec2::new(0.0, 1.0),
|
||||
max: Vec2::ONE,
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Dock to the left edge — full height, child width via `offset_max.x`.
|
||||
pub const LEFT: Self = Self {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::new(0.0, 1.0),
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Dock to the right edge — full height, child width via `offset_min.x`
|
||||
/// (negative widens the child leftward).
|
||||
pub const RIGHT: Self = Self {
|
||||
min: Vec2::new(1.0, 0.0),
|
||||
max: Vec2::ONE,
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Construct an anchor with explicit corner pair (offsets zero).
|
||||
pub const fn between(min: Vec2, max: Vec2) -> Self {
|
||||
Self {
|
||||
min,
|
||||
max,
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add fixed offsets in logical pixels to the resolved corners.
|
||||
pub const fn with_offsets(mut self, offset_min: Vec2, offset_max: Vec2) -> Self {
|
||||
self.offset_min = offset_min;
|
||||
self.offset_max = offset_max;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Anchor {
|
||||
fn default() -> Self {
|
||||
Self::FILL
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined style controlling how a widget sizes, spaces, and aligns itself
|
||||
/// inside its parent's slot.
|
||||
///
|
||||
/// `LayoutStyle` is deliberately one flat struct (rather than per-axis or
|
||||
/// per-mode sub-structs) because every widget needs the same fields and most
|
||||
/// of them are zero by default. Tests and authors can write
|
||||
/// `LayoutStyle::default()` and only set the fields they care about.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct LayoutStyle {
|
||||
/// Horizontal sizing rule.
|
||||
pub width: Sizing,
|
||||
/// Vertical sizing rule.
|
||||
pub height: Sizing,
|
||||
/// Space *inside* this widget's rect, before children are arranged.
|
||||
pub padding: Insets,
|
||||
/// Space *outside* this widget's rect, reserved in the parent's layout
|
||||
/// before computing leftover space.
|
||||
pub margin: Insets,
|
||||
/// Horizontal alignment when this widget's resolved width is smaller than
|
||||
/// the slot the parent gave it.
|
||||
pub align_horizontal: Align,
|
||||
/// Vertical alignment when this widget's resolved height is smaller than
|
||||
/// the slot the parent gave it.
|
||||
pub align_vertical: Align,
|
||||
/// Anchor — only consulted when this widget's parent is an
|
||||
/// [`AnchorGroup`](super::widget::AnchorGroup); ignored otherwise.
|
||||
pub anchor: Anchor,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_layout_style_is_fit_content_fill_anchor() {
|
||||
let s = LayoutStyle::default();
|
||||
assert_eq!(s.width, Sizing::FitContent);
|
||||
assert_eq!(s.height, Sizing::FitContent);
|
||||
assert_eq!(s.padding, Insets::ZERO);
|
||||
assert_eq!(s.margin, Insets::ZERO);
|
||||
assert_eq!(s.align_horizontal, Align::Start);
|
||||
assert_eq!(s.align_vertical, Align::Start);
|
||||
assert_eq!(s.anchor, Anchor::FILL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insets_helpers_are_correct() {
|
||||
let i = Insets::all(4.0);
|
||||
assert_eq!(i.left, 4.0);
|
||||
assert_eq!(i.right, 4.0);
|
||||
assert_eq!(i.top, 4.0);
|
||||
assert_eq!(i.bottom, 4.0);
|
||||
assert_eq!(i.horizontal(), 8.0);
|
||||
assert_eq!(i.vertical(), 8.0);
|
||||
|
||||
let s = Insets::symmetric(2.0, 6.0);
|
||||
assert_eq!(s.horizontal(), 4.0);
|
||||
assert_eq!(s.vertical(), 12.0);
|
||||
|
||||
let scaled = i.scaled(2.0);
|
||||
assert_eq!(scaled, Insets::all(8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_constants_match_doc_corners() {
|
||||
// FILL spans the whole parent.
|
||||
assert_eq!(Anchor::FILL.min, Vec2::ZERO);
|
||||
assert_eq!(Anchor::FILL.max, Vec2::ONE);
|
||||
// Each corner pin collapses to a point.
|
||||
assert_eq!(Anchor::TOP_LEFT.min, Anchor::TOP_LEFT.max);
|
||||
assert_eq!(Anchor::TOP_RIGHT.min, Vec2::new(1.0, 0.0));
|
||||
assert_eq!(Anchor::BOTTOM_LEFT.max, Vec2::new(0.0, 1.0));
|
||||
assert_eq!(Anchor::BOTTOM_RIGHT.min, Vec2::ONE);
|
||||
// Edge docks span one full axis.
|
||||
assert_eq!(Anchor::TOP.min, Vec2::ZERO);
|
||||
assert_eq!(Anchor::TOP.max, Vec2::new(1.0, 0.0));
|
||||
assert_eq!(Anchor::BOTTOM.min, Vec2::new(0.0, 1.0));
|
||||
assert_eq!(Anchor::LEFT.max, Vec2::new(0.0, 1.0));
|
||||
assert_eq!(Anchor::RIGHT.min, Vec2::new(1.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_style_round_trips_through_ron() {
|
||||
let s = LayoutStyle {
|
||||
width: Sizing::Grow(2.0),
|
||||
height: Sizing::Fixed(48.0),
|
||||
padding: Insets::all(8.0),
|
||||
margin: Insets::symmetric(4.0, 2.0),
|
||||
align_horizontal: Align::Center,
|
||||
align_vertical: Align::End,
|
||||
anchor: Anchor::TOP_RIGHT.with_offsets(Vec2::new(-100.0, 0.0), Vec2::ZERO),
|
||||
};
|
||||
let text = ron::ser::to_string_pretty(&s, ron::ser::PrettyConfig::default()).unwrap();
|
||||
let decoded: LayoutStyle = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(s, decoded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
//! Glyph atlas — packs rasterized glyphs into one R8 alpha texture, caches
|
||||
//! them by (font, glyph, size), and exposes UV regions the renderer draws as
|
||||
//! textured quads.
|
||||
//!
|
||||
//! The atlas **is** the cache: every glyph is rasterized exactly once per
|
||||
//! `(FontId, GlyphId, size_px)` triple and reused for the rest of the
|
||||
//! process's lifetime. The performance discussion in the Stage-8 design
|
||||
//! notes assumes this — a HUD that repaints the same characters every frame
|
||||
//! never re-rasterizes after warm-up.
|
||||
//!
|
||||
//! # Packer choice
|
||||
//!
|
||||
//! Piece 3 uses a **shelf packer**: glyphs are arranged in horizontal rows
|
||||
//! ("shelves") whose height is the height of the first glyph that opened the
|
||||
//! shelf. Subsequent glyphs either fit horizontally on an existing shelf
|
||||
//! (height ≤ shelf height) or start a new shelf below. This is the standard
|
||||
//! choice for monotonically-growing glyph atlases — simple, deterministic,
|
||||
//! near-optimal density for typically-uniform glyph heights, and easy to
|
||||
//! grow (later: multi-page atlases) when full.
|
||||
//!
|
||||
//! Piece 3 does **not** evict. With a 1024×1024 R8 atlas the typical Western
|
||||
//! UI uses a single-digit-percent fraction; CJK or many-size scenarios that
|
||||
//! actually run out are handled by piece-4 follow-ups (multi-page atlases
|
||||
//! or LRU per page).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use glam::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::font::{FontId, FontStore, GlyphId};
|
||||
|
||||
/// Cache key for one rasterized glyph.
|
||||
///
|
||||
/// `size_px` is rounded to the nearest pixel before being used as the key —
|
||||
/// distinct 23.4-pixel and 23.6-pixel renderings would otherwise produce
|
||||
/// different atlas entries despite being visually indistinguishable.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct GlyphKey {
|
||||
pub font: FontId,
|
||||
pub glyph: GlyphId,
|
||||
pub size_px: u16,
|
||||
}
|
||||
|
||||
impl GlyphKey {
|
||||
/// Build a key, rounding `size_px` to the nearest pixel.
|
||||
pub fn new(font: FontId, glyph: GlyphId, size_px: f32) -> Self {
|
||||
Self {
|
||||
font,
|
||||
glyph,
|
||||
size_px: size_px.round().max(1.0) as u16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One glyph's packed location inside the atlas plus the metrics the
|
||||
/// renderer needs to position its quad on a baseline.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AtlasEntry {
|
||||
/// Top-left UV (normalized to `[0, 1]`).
|
||||
pub uv_min: Vec2,
|
||||
/// Bottom-right UV.
|
||||
pub uv_max: Vec2,
|
||||
/// Width / height of the packed region in **pixels**, so the renderer
|
||||
/// can size the quad without re-querying the atlas dimensions.
|
||||
pub size_px: Vec2,
|
||||
/// Offset from the glyph's pen position to the top-left of the quad,
|
||||
/// in pixels (`bearing.x` left/right, `bearing.y` from the **baseline**;
|
||||
/// negative `y` means the glyph extends above the baseline).
|
||||
pub bearing: Vec2,
|
||||
/// Horizontal advance for the next glyph at this size.
|
||||
pub advance_px: f32,
|
||||
}
|
||||
|
||||
/// CPU-side glyph atlas — owns the alpha buffer, the packer state, and the
|
||||
/// `(GlyphKey -> AtlasEntry)` cache.
|
||||
///
|
||||
/// A piece-4 GPU follow-up will upload [`pixels`](Self::pixels) into a
|
||||
/// single R8 texture and re-upload only the dirty region when new glyphs are
|
||||
/// packed. Piece 3 stays pixel-buffer-only so every test runs headlessly.
|
||||
#[derive(Debug)]
|
||||
pub struct GlyphAtlas {
|
||||
width: u32,
|
||||
height: u32,
|
||||
pixels: Vec<u8>,
|
||||
cache: HashMap<GlyphKey, AtlasEntry>,
|
||||
packer: ShelfPacker,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
impl GlyphAtlas {
|
||||
/// Allocate a fresh `width × height` R8 atlas (one byte per pixel,
|
||||
/// initially zero).
|
||||
pub fn new(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
pixels: vec![0u8; (width as usize) * (height as usize)],
|
||||
cache: HashMap::new(),
|
||||
packer: ShelfPacker::new(width, height),
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `(width, height)` in pixels.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
/// Raw alpha buffer (`width * height` bytes, row-major). The piece-4
|
||||
/// render pass will upload this into an R8 texture; tests assert on it
|
||||
/// directly.
|
||||
pub fn pixels(&self) -> &[u8] {
|
||||
&self.pixels
|
||||
}
|
||||
|
||||
/// Look up an entry, rasterizing and packing if not yet present.
|
||||
///
|
||||
/// Returns `None` if the glyph has no outline (e.g., a space — the
|
||||
/// shaper still positions it via the font's advance) **or** the atlas
|
||||
/// has no room for the rasterized bitmap. A space-glyph miss is
|
||||
/// indistinguishable from a packing failure by signature; in practice
|
||||
/// the shaper handles both the same way (skip the quad, keep the
|
||||
/// advance).
|
||||
pub fn get_or_rasterize(&mut self, key: GlyphKey, fonts: &FontStore) -> Option<AtlasEntry> {
|
||||
if let Some(entry) = self.cache.get(&key) {
|
||||
return Some(*entry);
|
||||
}
|
||||
let font = fonts.get(key.font)?;
|
||||
let raster = font.rasterize(key.glyph, key.size_px as f32)?;
|
||||
let (x, y) = self.packer.pack(raster.width, raster.height)?;
|
||||
|
||||
// Blit the alpha mask into the atlas at (x, y).
|
||||
let aw = self.width as usize;
|
||||
for row in 0..raster.height as usize {
|
||||
let src_start = row * raster.width as usize;
|
||||
let dst_start = (y as usize + row) * aw + x as usize;
|
||||
self.pixels[dst_start..dst_start + raster.width as usize]
|
||||
.copy_from_slice(&raster.bitmap[src_start..src_start + raster.width as usize]);
|
||||
}
|
||||
self.dirty = true;
|
||||
|
||||
let w = self.width as f32;
|
||||
let h = self.height as f32;
|
||||
let entry = AtlasEntry {
|
||||
uv_min: Vec2::new(x as f32 / w, y as f32 / h),
|
||||
uv_max: Vec2::new(
|
||||
(x + raster.width) as f32 / w,
|
||||
(y + raster.height) as f32 / h,
|
||||
),
|
||||
size_px: Vec2::new(raster.width as f32, raster.height as f32),
|
||||
bearing: Vec2::new(raster.bearing_x, raster.bearing_y),
|
||||
advance_px: raster.advance_x,
|
||||
};
|
||||
self.cache.insert(key, entry);
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
/// Borrow an entry that's already cached, without triggering
|
||||
/// rasterization. Useful when the renderer wants to draw only glyphs the
|
||||
/// atlas already knows.
|
||||
pub fn get(&self, key: &GlyphKey) -> Option<&AtlasEntry> {
|
||||
self.cache.get(key)
|
||||
}
|
||||
|
||||
/// Number of cached glyphs.
|
||||
pub fn len(&self) -> usize {
|
||||
self.cache.len()
|
||||
}
|
||||
|
||||
/// `true` if no glyphs are cached.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.cache.is_empty()
|
||||
}
|
||||
|
||||
/// `true` if [`get_or_rasterize`](Self::get_or_rasterize) added at least
|
||||
/// one glyph since the last [`clear_dirty`](Self::clear_dirty). The
|
||||
/// piece-4 render pass checks this before re-uploading the texture.
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.dirty
|
||||
}
|
||||
|
||||
/// Clear the dirty flag. Call after uploading the texture.
|
||||
pub fn clear_dirty(&mut self) {
|
||||
self.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- shelf packer ----------
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ShelfPacker {
|
||||
width: u32,
|
||||
height: u32,
|
||||
shelves: Vec<Shelf>,
|
||||
next_y: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Shelf {
|
||||
y: u32,
|
||||
height: u32,
|
||||
cursor_x: u32,
|
||||
}
|
||||
|
||||
impl ShelfPacker {
|
||||
fn new(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
shelves: Vec::new(),
|
||||
next_y: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn pack(&mut self, w: u32, h: u32) -> Option<(u32, u32)> {
|
||||
if w > self.width || h > self.height {
|
||||
return None;
|
||||
}
|
||||
// Prefer the tightest-fitting existing shelf that still has
|
||||
// horizontal room — keeps shelf heights stable and packs short
|
||||
// glyphs against short glyphs.
|
||||
let mut best: Option<usize> = None;
|
||||
let mut best_waste = u32::MAX;
|
||||
for (i, shelf) in self.shelves.iter().enumerate() {
|
||||
if shelf.cursor_x + w <= self.width && h <= shelf.height {
|
||||
let waste = shelf.height - h;
|
||||
if waste < best_waste {
|
||||
best = Some(i);
|
||||
best_waste = waste;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(i) = best {
|
||||
let shelf = &mut self.shelves[i];
|
||||
let x = shelf.cursor_x;
|
||||
let y = shelf.y;
|
||||
shelf.cursor_x += w;
|
||||
return Some((x, y));
|
||||
}
|
||||
// No existing shelf fits — open a new one at `next_y` if there's
|
||||
// vertical room.
|
||||
if self.next_y + h > self.height {
|
||||
return None;
|
||||
}
|
||||
let y = self.next_y;
|
||||
self.next_y += h;
|
||||
self.shelves.push(Shelf {
|
||||
y,
|
||||
height: h,
|
||||
cursor_x: w,
|
||||
});
|
||||
Some((0, y))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::font::try_load_system_font;
|
||||
use super::*;
|
||||
use crate::ui::visual::FontRef;
|
||||
|
||||
#[test]
|
||||
fn key_rounds_size_to_nearest_pixel() {
|
||||
let k1 = GlyphKey::new(FontId(0), GlyphId(1), 23.4);
|
||||
let k2 = GlyphKey::new(FontId(0), GlyphId(1), 23.6);
|
||||
assert_eq!(k1.size_px, 23);
|
||||
assert_eq!(k2.size_px, 24);
|
||||
assert_ne!(k1, k2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_clamps_sub_pixel_size_to_one() {
|
||||
// A 0.4-pixel font would otherwise round to zero, producing a useless
|
||||
// key. The packer requires width ≥ 1.
|
||||
let k = GlyphKey::new(FontId(0), GlyphId(1), 0.4);
|
||||
assert_eq!(k.size_px, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shelf_packer_fits_glyphs_in_order() {
|
||||
let mut p = ShelfPacker::new(64, 64);
|
||||
// First glyph opens a shelf at y=0 with height 10.
|
||||
assert_eq!(p.pack(20, 10), Some((0, 0)));
|
||||
// Second glyph fits on the same shelf — same y, advanced cursor.
|
||||
assert_eq!(p.pack(20, 10), Some((20, 0)));
|
||||
// Third glyph: doesn't fit horizontally on shelf 0; opens shelf 1
|
||||
// at y=10.
|
||||
assert_eq!(p.pack(40, 8), Some((0, 10)));
|
||||
// Tall glyph that fits horizontally on neither existing shelf opens
|
||||
// shelf 2 at y=18.
|
||||
assert_eq!(p.pack(64, 20), Some((0, 18)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shelf_packer_prefers_tight_fit_among_existing_shelves() {
|
||||
let mut p = ShelfPacker::new(64, 64);
|
||||
// Open shelf 0 at y=0 with height 20, occupying width 50.
|
||||
assert_eq!(p.pack(50, 20), Some((0, 0)));
|
||||
// A 50-wide 8-tall glyph won't fit horizontally on shelf 0
|
||||
// (50 + 50 = 100 > 64) — that forces shelf 1 open at y=20 with
|
||||
// height 8.
|
||||
assert_eq!(p.pack(50, 8), Some((0, 20)));
|
||||
// Now pack a 10×8 glyph: shelf 0 (waste 12) and shelf 1 (waste 0)
|
||||
// both fit horizontally, so the tight-fit shelf 1 wins.
|
||||
assert_eq!(p.pack(10, 8), Some((50, 20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shelf_packer_rejects_overflow() {
|
||||
let mut p = ShelfPacker::new(32, 32);
|
||||
// First fills almost all the vertical room.
|
||||
assert_eq!(p.pack(32, 30), Some((0, 0)));
|
||||
// 4-tall glyph won't fit vertically.
|
||||
assert_eq!(p.pack(8, 4), None);
|
||||
// Anything wider than the atlas is also rejected.
|
||||
let mut p2 = ShelfPacker::new(32, 32);
|
||||
assert_eq!(p2.pack(40, 4), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_atlas_has_no_dirty_no_entries() {
|
||||
let atlas = GlyphAtlas::new(64, 64);
|
||||
assert_eq!(atlas.size(), (64, 64));
|
||||
assert!(!atlas.dirty());
|
||||
assert!(atlas.is_empty());
|
||||
assert!(atlas.pixels().iter().all(|&p| p == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_flag_lifecycle() {
|
||||
let Some(font) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let mut store = FontStore::new();
|
||||
let id = store.insert(font);
|
||||
let mut atlas = GlyphAtlas::new(256, 256);
|
||||
assert!(!atlas.dirty());
|
||||
|
||||
let glyph = store.get(id).unwrap().glyph_id('A');
|
||||
atlas
|
||||
.get_or_rasterize(GlyphKey::new(id, glyph, 24.0), &store)
|
||||
.unwrap();
|
||||
assert!(atlas.dirty());
|
||||
atlas.clear_dirty();
|
||||
assert!(!atlas.dirty());
|
||||
|
||||
// Second lookup of the same key is a cache hit — no rasterization,
|
||||
// no new dirty.
|
||||
atlas
|
||||
.get_or_rasterize(GlyphKey::new(id, glyph, 24.0), &store)
|
||||
.unwrap();
|
||||
assert!(!atlas.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_glyphs_get_distinct_regions() {
|
||||
let Some(font) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let mut store = FontStore::new();
|
||||
let id = store.insert_with_descriptor(FontRef::regular("System"), font);
|
||||
let mut atlas = GlyphAtlas::new(512, 512);
|
||||
|
||||
let a = store.get(id).unwrap().glyph_id('A');
|
||||
let b = store.get(id).unwrap().glyph_id('B');
|
||||
let e_a = atlas
|
||||
.get_or_rasterize(GlyphKey::new(id, a, 24.0), &store)
|
||||
.unwrap();
|
||||
let e_b = atlas
|
||||
.get_or_rasterize(GlyphKey::new(id, b, 24.0), &store)
|
||||
.unwrap();
|
||||
// Different glyphs → different UV rects.
|
||||
assert_ne!(e_a.uv_min, e_b.uv_min);
|
||||
// UV rects stay inside `[0, 1]`.
|
||||
assert!(e_a.uv_min.x >= 0.0 && e_a.uv_max.x <= 1.0);
|
||||
assert!(e_a.uv_min.y >= 0.0 && e_a.uv_max.y <= 1.0);
|
||||
|
||||
assert_eq!(atlas.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_glyph_returns_none_but_does_not_corrupt_atlas() {
|
||||
let Some(font) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let mut store = FontStore::new();
|
||||
let id = store.insert(font);
|
||||
let space = store.get(id).unwrap().glyph_id(' ');
|
||||
let mut atlas = GlyphAtlas::new(128, 128);
|
||||
assert!(atlas
|
||||
.get_or_rasterize(GlyphKey::new(id, space, 24.0), &store)
|
||||
.is_none());
|
||||
assert!(atlas.is_empty());
|
||||
assert!(!atlas.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atlas_pixels_match_rasterized_bitmap_at_packed_region() {
|
||||
let Some(font) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let mut store = FontStore::new();
|
||||
let id = store.insert(font);
|
||||
let mut atlas = GlyphAtlas::new(128, 128);
|
||||
let glyph = store.get(id).unwrap().glyph_id('A');
|
||||
let entry = atlas
|
||||
.get_or_rasterize(GlyphKey::new(id, glyph, 24.0), &store)
|
||||
.unwrap();
|
||||
// Convert the entry's UV back to a pixel rect and verify *some*
|
||||
// pixel inside it is opaque (i.e., the blit actually happened).
|
||||
let x = (entry.uv_min.x * 128.0).round() as usize;
|
||||
let y = (entry.uv_min.y * 128.0).round() as usize;
|
||||
let w = entry.size_px.x as usize;
|
||||
let h = entry.size_px.y as usize;
|
||||
let mut had_opaque = false;
|
||||
for row in 0..h {
|
||||
for col in 0..w {
|
||||
if atlas.pixels()[(y + row) * 128 + (x + col)] > 200 {
|
||||
had_opaque = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(had_opaque, "blitted region should contain opaque pixels");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Text shaping + glyph atlas — piece 3 of the Stage-8 in-game UI system.
|
||||
//!
|
||||
//! Three sub-modules cooperate:
|
||||
//!
|
||||
//! - [`font`] wraps `ab_glyph::FontVec` behind an engine-owned [`Font`] /
|
||||
//! [`FontStore`] surface so consumers never see the font crate directly.
|
||||
//! Adds descriptor-based lookup keyed by the piece-2
|
||||
//! [`FontRef`](super::visual::FontRef), so a theme's `font: Some(...)`
|
||||
//! resolves to a [`FontId`] the shaper can use.
|
||||
//! - [`atlas`] packs rasterized glyphs into one R8 alpha texture via a
|
||||
//! shelf packer and caches them by [`GlyphKey`]. The atlas **is** the
|
||||
//! cache — the chosen library never re-rasterizes a glyph that's already
|
||||
//! been packed, which is why this stage's choice between ab_glyph and
|
||||
//! fontdue is a one-time-startup decision, not a per-frame one.
|
||||
//! - [`shape`] turns a sequence of [`TextRun`]s into positioned
|
||||
//! [`ShapedGlyph`]s with line wrapping, alignment, multi-font runs, and
|
||||
//! DPI scaling. Pure CPU; never touches the atlas. The renderer
|
||||
//! (piece 4) walks the [`ShapedText`] output and queries the atlas per
|
||||
//! glyph to emit textured quads.
|
||||
//!
|
||||
//! # End-to-end shape → atlas
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use oxide_engine::ui::text::{
|
||||
//! shape, FontStore, GlyphAtlas, ShapeParams, ShapedText, TextStyle,
|
||||
//! };
|
||||
//! # use oxide_engine::ui::text::Font;
|
||||
//! # fn load_font() -> Font { todo!() }
|
||||
//!
|
||||
//! let mut fonts = FontStore::new();
|
||||
//! let id = fonts.insert(load_font());
|
||||
//! let style = TextStyle { font: id, size_px: 16.0 };
|
||||
//! let shaped: ShapedText = shape("Hello world", style, &ShapeParams::default(), &fonts);
|
||||
//!
|
||||
//! let mut atlas = GlyphAtlas::new(1024, 1024);
|
||||
//! for line in &shaped.lines {
|
||||
//! for glyph in &line.glyphs {
|
||||
//! // get_or_rasterize returns None for glyphs with no outline (e.g.
|
||||
//! // the space character). Real renderers skip emitting a quad.
|
||||
//! if let Some(entry) = atlas.get_or_rasterize(glyph.key, &fonts) {
|
||||
//! let _quad_top_left = glyph.position + entry.bearing;
|
||||
//! let _quad_size = entry.size_px;
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod atlas;
|
||||
pub mod font;
|
||||
pub mod shape;
|
||||
|
||||
pub use atlas::{AtlasEntry, GlyphAtlas, GlyphKey};
|
||||
pub use font::{
|
||||
common_system_font_paths, Font, FontError, FontId, FontLoader, FontStore, GlyphId,
|
||||
RasterizedGlyph,
|
||||
};
|
||||
pub use shape::{
|
||||
shape, shape_runs, ShapeParams, ShapedGlyph, ShapedLine, ShapedText, TextAlign, TextRun,
|
||||
TextStyle,
|
||||
};
|
||||
@@ -0,0 +1,726 @@
|
||||
//! Text shaping — turns a sequence of [`TextRun`]s into positioned glyphs,
|
||||
//! laid out on baselines, wrapped to a width, and aligned.
|
||||
//!
|
||||
//! The shaper does **not** rasterize: it only consults [`Font`](super::font::Font)
|
||||
//! metrics (ascender, descender, advance width). Each output [`ShapedGlyph`]
|
||||
//! carries a [`GlyphKey`] the renderer (piece 4) feeds into the atlas to
|
||||
//! resolve to a textured quad. This split keeps the shaper purely
|
||||
//! deterministic and CPU-cheap — every test in this module runs without a
|
||||
//! GPU and most without a font.
|
||||
//!
|
||||
//! # Algorithm
|
||||
//!
|
||||
//! 1. **Tokenize** each run into items: a `Word` (maximal run of non-
|
||||
//! whitespace), a `Whitespace` stretch, or a `Break` (`\n`). Each
|
||||
//! word/whitespace item caches its own width, computed once from the
|
||||
//! font's per-glyph advance.
|
||||
//! 2. **Greedy line break**: keep adding items to the current line; on a
|
||||
//! word that would overflow `max_width`, flush the line and start a new
|
||||
//! one. Pending inter-word whitespace at the wrap point is **discarded**
|
||||
//! (it was the gap between lines, not part of either line); leading
|
||||
//! whitespace on a wrapped line is dropped for the same reason. `\n`
|
||||
//! forces a flush regardless of width.
|
||||
//! 3. **Position**: for each line, find the line's `max_ascent` (across the
|
||||
//! fonts used on it) — that's the baseline offset from the line's top
|
||||
//! edge — then walk items left-to-right, emitting `ShapedGlyph`s at
|
||||
//! `(pen_x, baseline_y)` and advancing `pen_x` by each glyph's advance.
|
||||
//! 4. **Align**: per line, shift glyphs by `align_offset(max_width −
|
||||
//! line_width)` — Left/Center/Right. Without a `max_width`, alignment
|
||||
//! is degenerate (everything is left-aligned).
|
||||
//!
|
||||
//! # Multi-font runs
|
||||
//!
|
||||
//! Lines may mix items from different runs (and therefore different fonts).
|
||||
//! Line metrics (ascent, descent, line height) are taken from the *largest*
|
||||
//! contribution among the line's items. This is the CSS behavior: a small
|
||||
//! superscript run on the same line as body text doesn't collapse the
|
||||
//! baseline.
|
||||
//!
|
||||
//! # Limitations (deliberate, scoped to piece 3)
|
||||
//!
|
||||
//! - One glyph per `char` (no ligatures, no combining marks, no shaping).
|
||||
//! ab_glyph does not shape; full Unicode shaping is a `rustybuzz` /
|
||||
//! `harfbuzz` follow-up.
|
||||
//! - No BiDi or RTL — text flows left-to-right.
|
||||
//! - No hyphenation or character-level fallback inside an overflowing word.
|
||||
//! - Whitespace is ASCII (` `, `\t`, `\r`). `\t` and `\r` are treated as
|
||||
//! regular spaces.
|
||||
|
||||
use glam::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::atlas::GlyphKey;
|
||||
use super::font::{FontId, FontStore};
|
||||
|
||||
/// Per-run style — which font and what point size.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TextStyle {
|
||||
pub font: FontId,
|
||||
/// Logical font size in pixels. Multiplied by [`ShapeParams::scale`] at
|
||||
/// shape time, so the same `TextStyle` produces correctly-sized output
|
||||
/// at 1×, 2×, or any other DPI factor.
|
||||
pub size_px: f32,
|
||||
}
|
||||
|
||||
/// One run of text with a single [`TextStyle`].
|
||||
///
|
||||
/// `shape` takes a single run; `shape_runs` takes many for mixed styles
|
||||
/// (different fonts/sizes/etc. on the same line).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TextRun<'a> {
|
||||
pub text: &'a str,
|
||||
pub style: TextStyle,
|
||||
}
|
||||
|
||||
/// Horizontal alignment of each line within `max_width`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum TextAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Parameters that apply to the whole shape call: wrapping width, alignment,
|
||||
/// line-height factor, and the DPI scale factor.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ShapeParams {
|
||||
/// Maximum line width in **post-scale** pixels. `None` disables
|
||||
/// wrapping (and makes alignment a no-op).
|
||||
pub max_width: Option<f32>,
|
||||
/// Horizontal alignment within `max_width`.
|
||||
pub align: TextAlign,
|
||||
/// Multiplier applied to each line's natural line height. `1.0` is the
|
||||
/// font's own recommendation; `1.4` is a comfortable reading default.
|
||||
pub line_height: f32,
|
||||
/// DPI scale factor — multiplies every logical `size_px` from the
|
||||
/// runs. Same role as [`super::super::layout::layout`]'s `scale`.
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
impl Default for ShapeParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_width: None,
|
||||
align: TextAlign::Left,
|
||||
line_height: 1.0,
|
||||
scale: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One positioned glyph in the shaped output.
|
||||
///
|
||||
/// `position` is the **pen position at the baseline** — the renderer adds
|
||||
/// the atlas's per-glyph bearing to convert it into the top-left of the
|
||||
/// glyph quad. Keeping it at the baseline (rather than at the top-left) is
|
||||
/// what makes hit testing and caret positioning straightforward in pieces
|
||||
/// 5–6.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ShapedGlyph {
|
||||
pub key: GlyphKey,
|
||||
pub position: Vec2,
|
||||
}
|
||||
|
||||
/// One shaped line — the glyphs, the line's content width (trailing
|
||||
/// whitespace excluded), and the line's baseline / total height.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ShapedLine {
|
||||
pub glyphs: Vec<ShapedGlyph>,
|
||||
pub width: f32,
|
||||
pub baseline_y: f32,
|
||||
pub line_height: f32,
|
||||
}
|
||||
|
||||
/// Full shaped output — `lines` in vertical order and the overall bounding
|
||||
/// box `size`. `size.x` is the widest line's width (not `max_width`);
|
||||
/// `size.y` is the sum of line heights, which equals the height of the
|
||||
/// rectangle the text fits in.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ShapedText {
|
||||
pub lines: Vec<ShapedLine>,
|
||||
pub size: Vec2,
|
||||
}
|
||||
|
||||
/// Shape a single run of text. Convenience wrapper around [`shape_runs`].
|
||||
pub fn shape(text: &str, style: TextStyle, params: &ShapeParams, fonts: &FontStore) -> ShapedText {
|
||||
shape_runs(&[TextRun { text, style }], params, fonts)
|
||||
}
|
||||
|
||||
/// Shape one or more runs into a single output. Items from different runs
|
||||
/// share lines and share alignment, just as if they were one continuous
|
||||
/// string with mixed styles.
|
||||
pub fn shape_runs(runs: &[TextRun], params: &ShapeParams, fonts: &FontStore) -> ShapedText {
|
||||
let mut items: Vec<Item> = Vec::new();
|
||||
for run in runs {
|
||||
tokenize_run(run, params.scale, fonts, &mut items);
|
||||
}
|
||||
|
||||
let raw_lines = break_lines(items, params.max_width);
|
||||
position_lines(raw_lines, params, fonts)
|
||||
}
|
||||
|
||||
// ---------- internals ----------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Item {
|
||||
Word {
|
||||
font: FontId,
|
||||
size_px: f32,
|
||||
width: f32,
|
||||
// (char, glyph id, advance) — kept so the positioner doesn't have to
|
||||
// re-walk the source string.
|
||||
glyphs: Vec<GlyphAdvance>,
|
||||
},
|
||||
Whitespace {
|
||||
font: FontId,
|
||||
size_px: f32,
|
||||
width: f32,
|
||||
},
|
||||
Break,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct GlyphAdvance {
|
||||
glyph: super::font::GlyphId,
|
||||
advance: f32,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
fn width(&self) -> f32 {
|
||||
match self {
|
||||
Item::Word { width, .. } | Item::Whitespace { width, .. } => *width,
|
||||
Item::Break => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn font_size(&self) -> Option<(FontId, f32)> {
|
||||
match self {
|
||||
Item::Word { font, size_px, .. } | Item::Whitespace { font, size_px, .. } => {
|
||||
Some((*font, *size_px))
|
||||
}
|
||||
Item::Break => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_whitespace(&self) -> bool {
|
||||
matches!(self, Item::Whitespace { .. })
|
||||
}
|
||||
}
|
||||
|
||||
fn is_break(c: char) -> bool {
|
||||
c == '\n'
|
||||
}
|
||||
|
||||
fn is_space_like(c: char) -> bool {
|
||||
matches!(c, ' ' | '\t' | '\r')
|
||||
}
|
||||
|
||||
fn tokenize_run(run: &TextRun, scale: f32, fonts: &FontStore, out: &mut Vec<Item>) {
|
||||
let style = run.style;
|
||||
let size_px = style.size_px * scale;
|
||||
let Some(font) = fonts.get(style.font) else {
|
||||
// Unknown font id — skip the run rather than panicking. Tests in
|
||||
// piece 4 will catch missing fonts before rendering; for piece 3
|
||||
// we want shape to remain a total function.
|
||||
return;
|
||||
};
|
||||
|
||||
let mut buf_word: Vec<GlyphAdvance> = Vec::new();
|
||||
let mut buf_word_width: f32 = 0.0;
|
||||
let mut buf_ws_width: f32 = 0.0;
|
||||
let mut state = TokState::Empty;
|
||||
|
||||
for c in run.text.chars() {
|
||||
if is_break(c) {
|
||||
flush_buffers(
|
||||
&mut state,
|
||||
&mut buf_word,
|
||||
&mut buf_word_width,
|
||||
&mut buf_ws_width,
|
||||
style.font,
|
||||
size_px,
|
||||
out,
|
||||
);
|
||||
out.push(Item::Break);
|
||||
continue;
|
||||
}
|
||||
if is_space_like(c) {
|
||||
if matches!(state, TokState::Word) {
|
||||
out.push(Item::Word {
|
||||
font: style.font,
|
||||
size_px,
|
||||
width: buf_word_width,
|
||||
glyphs: std::mem::take(&mut buf_word),
|
||||
});
|
||||
buf_word_width = 0.0;
|
||||
}
|
||||
state = TokState::Whitespace;
|
||||
let glyph = font.glyph_id(' ');
|
||||
buf_ws_width += font.h_advance_px(glyph, size_px);
|
||||
continue;
|
||||
}
|
||||
// Non-whitespace.
|
||||
if matches!(state, TokState::Whitespace) {
|
||||
out.push(Item::Whitespace {
|
||||
font: style.font,
|
||||
size_px,
|
||||
width: buf_ws_width,
|
||||
});
|
||||
buf_ws_width = 0.0;
|
||||
}
|
||||
state = TokState::Word;
|
||||
let glyph = font.glyph_id(c);
|
||||
let advance = font.h_advance_px(glyph, size_px);
|
||||
buf_word.push(GlyphAdvance { glyph, advance });
|
||||
buf_word_width += advance;
|
||||
}
|
||||
|
||||
flush_buffers(
|
||||
&mut state,
|
||||
&mut buf_word,
|
||||
&mut buf_word_width,
|
||||
&mut buf_ws_width,
|
||||
style.font,
|
||||
size_px,
|
||||
out,
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum TokState {
|
||||
Empty,
|
||||
Word,
|
||||
Whitespace,
|
||||
}
|
||||
|
||||
fn flush_buffers(
|
||||
state: &mut TokState,
|
||||
word: &mut Vec<GlyphAdvance>,
|
||||
word_width: &mut f32,
|
||||
ws_width: &mut f32,
|
||||
font: FontId,
|
||||
size_px: f32,
|
||||
out: &mut Vec<Item>,
|
||||
) {
|
||||
match state {
|
||||
TokState::Word => {
|
||||
out.push(Item::Word {
|
||||
font,
|
||||
size_px,
|
||||
width: *word_width,
|
||||
glyphs: std::mem::take(word),
|
||||
});
|
||||
*word_width = 0.0;
|
||||
}
|
||||
TokState::Whitespace => {
|
||||
out.push(Item::Whitespace {
|
||||
font,
|
||||
size_px,
|
||||
width: *ws_width,
|
||||
});
|
||||
*ws_width = 0.0;
|
||||
}
|
||||
TokState::Empty => {}
|
||||
}
|
||||
*state = TokState::Empty;
|
||||
}
|
||||
|
||||
fn break_lines(items: Vec<Item>, max_width: Option<f32>) -> Vec<Vec<Item>> {
|
||||
let mut raw_lines: Vec<Vec<Item>> = Vec::new();
|
||||
let mut current: Vec<Item> = Vec::new();
|
||||
let mut current_width: f32 = 0.0;
|
||||
let mut pending_ws: Vec<Item> = Vec::new();
|
||||
let mut pending_ws_width: f32 = 0.0;
|
||||
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Break => {
|
||||
raw_lines.push(std::mem::take(&mut current));
|
||||
current_width = 0.0;
|
||||
pending_ws.clear();
|
||||
pending_ws_width = 0.0;
|
||||
}
|
||||
Item::Whitespace { width, .. } => {
|
||||
pending_ws_width += width;
|
||||
pending_ws.push(item);
|
||||
}
|
||||
Item::Word { width, .. } => {
|
||||
let fits = match max_width {
|
||||
Some(max) => {
|
||||
current.is_empty() || current_width + pending_ws_width + width <= max
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
if fits {
|
||||
current.append(&mut pending_ws);
|
||||
current_width += pending_ws_width;
|
||||
current_width += width;
|
||||
current.push(item);
|
||||
} else {
|
||||
raw_lines.push(std::mem::take(&mut current));
|
||||
// Leading whitespace on a wrapped line is dropped.
|
||||
pending_ws.clear();
|
||||
current_width = width;
|
||||
current.push(item);
|
||||
}
|
||||
pending_ws_width = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
raw_lines.push(current);
|
||||
}
|
||||
raw_lines
|
||||
}
|
||||
|
||||
fn position_lines(
|
||||
raw_lines: Vec<Vec<Item>>,
|
||||
params: &ShapeParams,
|
||||
fonts: &FontStore,
|
||||
) -> ShapedText {
|
||||
let mut lines: Vec<ShapedLine> = Vec::new();
|
||||
let mut cursor_y: f32 = 0.0;
|
||||
let mut widest: f32 = 0.0;
|
||||
|
||||
for line_items in raw_lines {
|
||||
// Line metrics from the largest contributing item.
|
||||
let mut max_ascent: f32 = 0.0;
|
||||
let mut min_descent: f32 = 0.0;
|
||||
let mut max_line_height: f32 = 0.0;
|
||||
for item in &line_items {
|
||||
if let Some((font_id, size_px)) = item.font_size() {
|
||||
if let Some(font) = fonts.get(font_id) {
|
||||
max_ascent = max_ascent.max(font.ascent_px(size_px));
|
||||
min_descent = min_descent.min(font.descent_px(size_px));
|
||||
max_line_height = max_line_height.max(font.line_height_px(size_px));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = min_descent; // descent reserved for vertical-extent queries later
|
||||
let line_height = max_line_height * params.line_height;
|
||||
|
||||
// Trailing whitespace is excluded from line width.
|
||||
let mut content_width: f32 = 0.0;
|
||||
let last_non_ws = line_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(_, it)| !it.is_whitespace())
|
||||
.map(|(i, _)| i);
|
||||
if let Some(end) = last_non_ws {
|
||||
for it in &line_items[..=end] {
|
||||
content_width += it.width();
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal alignment offset.
|
||||
let align_pad = match params.max_width {
|
||||
Some(max) => {
|
||||
let extra = (max - content_width).max(0.0);
|
||||
match params.align {
|
||||
TextAlign::Left => 0.0,
|
||||
TextAlign::Center => extra * 0.5,
|
||||
TextAlign::Right => extra,
|
||||
}
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
|
||||
let baseline_y = cursor_y + max_ascent;
|
||||
let mut pen_x = align_pad;
|
||||
let mut glyphs: Vec<ShapedGlyph> = Vec::new();
|
||||
for item in &line_items {
|
||||
match item {
|
||||
Item::Word {
|
||||
font,
|
||||
size_px,
|
||||
glyphs: g,
|
||||
..
|
||||
} => {
|
||||
for ga in g {
|
||||
glyphs.push(ShapedGlyph {
|
||||
key: GlyphKey::new(*font, ga.glyph, *size_px),
|
||||
position: Vec2::new(pen_x, baseline_y),
|
||||
});
|
||||
pen_x += ga.advance;
|
||||
}
|
||||
}
|
||||
Item::Whitespace { width, .. } => {
|
||||
pen_x += *width;
|
||||
}
|
||||
Item::Break => {}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(ShapedLine {
|
||||
glyphs,
|
||||
width: content_width,
|
||||
baseline_y,
|
||||
line_height,
|
||||
});
|
||||
cursor_y += line_height;
|
||||
widest = widest.max(content_width);
|
||||
}
|
||||
|
||||
ShapedText {
|
||||
lines,
|
||||
size: Vec2::new(widest, cursor_y),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::font::try_load_system_font;
|
||||
use super::*;
|
||||
|
||||
fn make_store_and_style(size_px: f32) -> Option<(FontStore, TextStyle)> {
|
||||
let font = try_load_system_font()?;
|
||||
let mut store = FontStore::new();
|
||||
let id = store.insert(font);
|
||||
Some((store, TextStyle { font: id, size_px }))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_text_produces_no_lines() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let out = shape("", style, &ShapeParams::default(), &store);
|
||||
assert!(out.lines.is_empty());
|
||||
assert_eq!(out.size, Vec2::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_word_emits_one_line_with_correct_glyph_count() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let out = shape("Hello", style, &ShapeParams::default(), &store);
|
||||
assert_eq!(out.lines.len(), 1);
|
||||
assert_eq!(out.lines[0].glyphs.len(), 5);
|
||||
// Glyphs are at the same baseline.
|
||||
let baseline = out.lines[0].baseline_y;
|
||||
for g in &out.lines[0].glyphs {
|
||||
assert_eq!(g.position.y, baseline);
|
||||
}
|
||||
// x positions are monotonically increasing.
|
||||
for w in out.lines[0].glyphs.windows(2) {
|
||||
assert!(w[1].position.x > w[0].position.x);
|
||||
}
|
||||
// Line width matches the last glyph's pen-end (advance sum).
|
||||
assert!(out.lines[0].width > 0.0);
|
||||
assert!(out.size.x >= out.lines[0].width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_newline_starts_new_line() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let out = shape("a\nb", style, &ShapeParams::default(), &store);
|
||||
assert_eq!(out.lines.len(), 2);
|
||||
assert_eq!(out.lines[0].glyphs.len(), 1);
|
||||
assert_eq!(out.lines[1].glyphs.len(), 1);
|
||||
// Second baseline is below the first by one line height.
|
||||
assert!(out.lines[1].baseline_y > out.lines[0].baseline_y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_wrap_splits_into_multiple_lines() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
// A line wide enough for "Hello" but not "Hello world".
|
||||
let one_word_width = shape("Hello", style, &ShapeParams::default(), &store).lines[0].width;
|
||||
let params = ShapeParams {
|
||||
max_width: Some(one_word_width + 2.0),
|
||||
..ShapeParams::default()
|
||||
};
|
||||
let out = shape("Hello world", style, ¶ms, &store);
|
||||
assert_eq!(out.lines.len(), 2);
|
||||
// First line is just "Hello" (5 glyphs).
|
||||
assert_eq!(out.lines[0].glyphs.len(), 5);
|
||||
// Second line is "world" (5 glyphs); leading whitespace dropped.
|
||||
assert_eq!(out.lines[1].glyphs.len(), 5);
|
||||
// Second line starts at x = 0 (Left align by default; no leading
|
||||
// whitespace consumed pen space).
|
||||
assert_eq!(out.lines[1].glyphs[0].position.x, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_whitespace_excluded_from_line_width() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let bare = shape("Hi", style, &ShapeParams::default(), &store);
|
||||
let trailing = shape("Hi ", style, &ShapeParams::default(), &store);
|
||||
assert_eq!(bare.lines[0].width, trailing.lines[0].width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_shifts_glyph_positions_within_max_width() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let left = shape(
|
||||
"Hi",
|
||||
style,
|
||||
&ShapeParams {
|
||||
max_width: Some(200.0),
|
||||
align: TextAlign::Left,
|
||||
..ShapeParams::default()
|
||||
},
|
||||
&store,
|
||||
);
|
||||
let center = shape(
|
||||
"Hi",
|
||||
style,
|
||||
&ShapeParams {
|
||||
max_width: Some(200.0),
|
||||
align: TextAlign::Center,
|
||||
..ShapeParams::default()
|
||||
},
|
||||
&store,
|
||||
);
|
||||
let right = shape(
|
||||
"Hi",
|
||||
style,
|
||||
&ShapeParams {
|
||||
max_width: Some(200.0),
|
||||
align: TextAlign::Right,
|
||||
..ShapeParams::default()
|
||||
},
|
||||
&store,
|
||||
);
|
||||
let l = left.lines[0].glyphs[0].position.x;
|
||||
let c = center.lines[0].glyphs[0].position.x;
|
||||
let r = right.lines[0].glyphs[0].position.x;
|
||||
assert_eq!(l, 0.0);
|
||||
assert!(c > l && c < r);
|
||||
// Centered + right cases place the line within `max_width = 200`.
|
||||
let width = left.lines[0].width;
|
||||
assert!((c - (200.0 - width) * 0.5).abs() < 0.001);
|
||||
assert!((r - (200.0 - width)).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dpi_scale_doubles_advance_widths_and_baseline_drop() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let at_1x = shape("Hello", style, &ShapeParams::default(), &store);
|
||||
let at_2x = shape(
|
||||
"Hello",
|
||||
style,
|
||||
&ShapeParams {
|
||||
scale: 2.0,
|
||||
..ShapeParams::default()
|
||||
},
|
||||
&store,
|
||||
);
|
||||
// Line width at 2× is ~2× at 1×.
|
||||
let ratio = at_2x.lines[0].width / at_1x.lines[0].width;
|
||||
assert!((ratio - 2.0).abs() < 0.05, "ratio = {ratio}");
|
||||
// First glyph's baseline drops at 2× by ~2× the 1× drop.
|
||||
let baseline_ratio = at_2x.lines[0].baseline_y / at_1x.lines[0].baseline_y;
|
||||
assert!((baseline_ratio - 2.0).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_height_multiplier_increases_vertical_spacing() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let single = shape("a\nb", style, &ShapeParams::default(), &store);
|
||||
let spaced = shape(
|
||||
"a\nb",
|
||||
style,
|
||||
&ShapeParams {
|
||||
line_height: 2.0,
|
||||
..ShapeParams::default()
|
||||
},
|
||||
&store,
|
||||
);
|
||||
let gap_1 = single.lines[1].baseline_y - single.lines[0].baseline_y;
|
||||
let gap_2 = spaced.lines[1].baseline_y - spaced.lines[0].baseline_y;
|
||||
// Doubling the line-height factor roughly doubles inter-baseline
|
||||
// distance — exact ratio depends on the font's gap fraction.
|
||||
assert!(
|
||||
(gap_2 / gap_1 - 2.0).abs() < 0.05,
|
||||
"gap_2/gap_1 = {}",
|
||||
gap_2 / gap_1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_keys_are_stable_across_calls() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let a = shape("X", style, &ShapeParams::default(), &store);
|
||||
let b = shape("X", style, &ShapeParams::default(), &store);
|
||||
assert_eq!(a.lines[0].glyphs[0].key, b.lines[0].glyphs[0].key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_font_run_takes_max_ascent_from_largest_size() {
|
||||
let Some(font) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let mut store = FontStore::new();
|
||||
let id = store.insert(font);
|
||||
let small = TextStyle {
|
||||
font: id,
|
||||
size_px: 12.0,
|
||||
};
|
||||
let big = TextStyle {
|
||||
font: id,
|
||||
size_px: 32.0,
|
||||
};
|
||||
let mixed = shape_runs(
|
||||
&[
|
||||
TextRun {
|
||||
text: "Hi ",
|
||||
style: small,
|
||||
},
|
||||
TextRun {
|
||||
text: "X",
|
||||
style: big,
|
||||
},
|
||||
],
|
||||
&ShapeParams::default(),
|
||||
&store,
|
||||
);
|
||||
let small_only = shape("Hi", small, &ShapeParams::default(), &store);
|
||||
// The big-size baseline must be at least as deep as the small-size
|
||||
// baseline because the line's ascent is the max of contributions.
|
||||
assert!(mixed.lines[0].baseline_y >= small_only.lines[0].baseline_y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_font_id_does_not_panic() {
|
||||
// No font registered → shape returns no lines instead of panicking.
|
||||
let store = FontStore::new();
|
||||
let style = TextStyle {
|
||||
font: FontId(99),
|
||||
size_px: 16.0,
|
||||
};
|
||||
let out = shape("Hello", style, &ShapeParams::default(), &store);
|
||||
assert!(out.lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_wrap_when_max_width_is_none() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let out = shape(
|
||||
"one two three four five",
|
||||
style,
|
||||
&ShapeParams::default(),
|
||||
&store,
|
||||
);
|
||||
assert_eq!(out.lines.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Theme — reusable named [`VisualStyle`]s plus a default fallback.
|
||||
//!
|
||||
//! A [`Theme`] is what a project ships to give every UI document a consistent
|
||||
//! look without hand-styling every widget. The resolution rule is a strict
|
||||
//! left-to-right cascade:
|
||||
//!
|
||||
//! 1. Start with `theme.default` (a `VisualStyle` whose `Some` fields are the
|
||||
//! project-wide defaults — body text color, border weight, …).
|
||||
//! 2. If the widget specifies `theme_style: Some("button")` and the theme
|
||||
//! contains a `"button"` entry, merge that on top.
|
||||
//! 3. Merge the widget's per-instance `visual` on top.
|
||||
//!
|
||||
//! Each merge is field-by-field: a `Some` on the right replaces the field;
|
||||
//! a `None` keeps what was there. The result is a single [`VisualStyle`]
|
||||
//! where any field that's still `None` means "the renderer's own hard-coded
|
||||
//! fallback applies" — that fallback lives in piece 4 (the 2D overlay pass).
|
||||
//!
|
||||
//! Why named styles instead of CSS-like selectors: it makes per-widget
|
||||
//! attribution explicit in the UI document (`theme_style: "button-primary"`)
|
||||
//! and keeps theme resolution constant-time per widget. CSS selectors and
|
||||
//! cascading rules are a richer model but their authoring cost dwarfs what
|
||||
//! Stage-8 game UIs actually need.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::visual::VisualStyle;
|
||||
|
||||
/// A named-style theme. Holds a `default` style applied to every widget plus
|
||||
/// a map of named styles widgets can opt into by their `theme_style` field.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Theme {
|
||||
/// Project-wide defaults — applied first to every widget before its
|
||||
/// `theme_style` and per-instance overrides.
|
||||
#[serde(default, skip_serializing_if = "VisualStyle::is_empty")]
|
||||
pub default: VisualStyle,
|
||||
/// Named style buckets — `theme_style: "button"` on a widget pulls the
|
||||
/// `"button"` entry here on top of `default`.
|
||||
///
|
||||
/// Stored as a `BTreeMap` (not `HashMap`) so RON output is in a
|
||||
/// deterministic order — important for diff-friendly UI documents and
|
||||
/// reproducible RON snapshots in tests.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub styles: BTreeMap<String, VisualStyle>,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// Empty theme — no default fields, no named styles. Every widget under
|
||||
/// this theme inherits only the renderer's hard-coded fallback.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
default: VisualStyle::EMPTY,
|
||||
styles: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert (or replace) a named style. Chainable for builder-style theme
|
||||
/// construction in tests and examples.
|
||||
pub fn with_style(mut self, name: impl Into<String>, style: VisualStyle) -> Self {
|
||||
self.styles.insert(name.into(), style);
|
||||
self
|
||||
}
|
||||
|
||||
/// Replace the project-wide default style.
|
||||
pub fn with_default(mut self, default: VisualStyle) -> Self {
|
||||
self.default = default;
|
||||
self
|
||||
}
|
||||
|
||||
/// Resolve the effective visual style for a widget that opts into
|
||||
/// `style_ref` (if any) and provides its own `override_with` per-instance
|
||||
/// fields.
|
||||
///
|
||||
/// Cascade: `self.default` → (`self.styles[style_ref]` if present) →
|
||||
/// `override_with`. A missing named style is treated as empty (no
|
||||
/// contribution) rather than an error — UI documents stay valid when a
|
||||
/// theme is swapped for a smaller one mid-development.
|
||||
pub fn resolve(&self, style_ref: Option<&str>, override_with: &VisualStyle) -> VisualStyle {
|
||||
let mut resolved = self.default.clone();
|
||||
if let Some(name) = style_ref {
|
||||
if let Some(named) = self.styles.get(name) {
|
||||
resolved = resolved.merged(named);
|
||||
}
|
||||
}
|
||||
resolved.merged(override_with)
|
||||
}
|
||||
|
||||
/// Serialize this theme to a pretty-printed RON string.
|
||||
pub fn to_ron(&self) -> Result<String, ron::Error> {
|
||||
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
|
||||
}
|
||||
|
||||
/// Parse a theme from a RON string.
|
||||
pub fn from_ron(text: &str) -> Result<Self, ron::de::SpannedError> {
|
||||
ron::de::from_str(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::Color;
|
||||
use crate::ui::visual::{Border, FontRef};
|
||||
|
||||
fn theme_with_three_styles() -> Theme {
|
||||
Theme::new()
|
||||
.with_default(VisualStyle {
|
||||
foreground: Some(Color::BLACK),
|
||||
background: Some(Color::WHITE),
|
||||
font: Some(FontRef::regular("Inter")),
|
||||
font_size: Some(14.0),
|
||||
..VisualStyle::EMPTY
|
||||
})
|
||||
.with_style(
|
||||
"button",
|
||||
VisualStyle {
|
||||
background: Some(Color::rgb(0.85, 0.85, 0.9)),
|
||||
border: Some(Border::new(Color::rgb(0.6, 0.6, 0.7), 1.0)),
|
||||
corner_radius: Some(4.0),
|
||||
..VisualStyle::EMPTY
|
||||
},
|
||||
)
|
||||
.with_style(
|
||||
"button-primary",
|
||||
VisualStyle {
|
||||
background: Some(Color::rgb(0.2, 0.4, 0.8)),
|
||||
foreground: Some(Color::WHITE),
|
||||
..VisualStyle::EMPTY
|
||||
},
|
||||
)
|
||||
.with_style(
|
||||
"label",
|
||||
VisualStyle {
|
||||
foreground: Some(Color::rgb(0.2, 0.2, 0.2)),
|
||||
..VisualStyle::EMPTY
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_default_for_no_style_or_overrides() {
|
||||
let theme = theme_with_three_styles();
|
||||
let resolved = theme.resolve(None, &VisualStyle::EMPTY);
|
||||
assert_eq!(resolved.foreground, Some(Color::BLACK));
|
||||
assert_eq!(resolved.background, Some(Color::WHITE));
|
||||
assert_eq!(resolved.font_size, Some(14.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_style_overrides_default() {
|
||||
let theme = theme_with_three_styles();
|
||||
let resolved = theme.resolve(Some("button"), &VisualStyle::EMPTY);
|
||||
assert_eq!(resolved.background, Some(Color::rgb(0.85, 0.85, 0.9)));
|
||||
// Foreground not set on "button" → kept from default.
|
||||
assert_eq!(resolved.foreground, Some(Color::BLACK));
|
||||
assert_eq!(resolved.corner_radius, Some(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_instance_override_takes_final_precedence() {
|
||||
let theme = theme_with_three_styles();
|
||||
let overlay = VisualStyle {
|
||||
background: Some(Color::RED),
|
||||
..VisualStyle::EMPTY
|
||||
};
|
||||
let resolved = theme.resolve(Some("button-primary"), &overlay);
|
||||
// Per-instance background wins over the named style.
|
||||
assert_eq!(resolved.background, Some(Color::RED));
|
||||
// The named style's foreground (WHITE) still beats the default (BLACK).
|
||||
assert_eq!(resolved.foreground, Some(Color::WHITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_named_style_falls_back_to_default() {
|
||||
let theme = theme_with_three_styles();
|
||||
let resolved = theme.resolve(Some("does-not-exist"), &VisualStyle::EMPTY);
|
||||
// Same as resolve(None, &EMPTY).
|
||||
assert_eq!(resolved, theme.resolve(None, &VisualStyle::EMPTY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_round_trips_through_ron() {
|
||||
let theme = theme_with_three_styles();
|
||||
let text = theme.to_ron().unwrap();
|
||||
let decoded = Theme::from_ron(&text).unwrap();
|
||||
assert_eq!(theme, decoded);
|
||||
// Named styles are alphabetised by BTreeMap, so "button" precedes
|
||||
// "button-primary" precedes "label" in the serialized form.
|
||||
let button_pos = text.find("\"button\"").unwrap();
|
||||
let primary_pos = text.find("\"button-primary\"").unwrap();
|
||||
let label_pos = text.find("\"label\"").unwrap();
|
||||
assert!(button_pos < primary_pos);
|
||||
assert!(primary_pos < label_pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_theme_round_trips_to_empty_ron() {
|
||||
let empty = Theme::new();
|
||||
let text = empty.to_ron().unwrap();
|
||||
let decoded = Theme::from_ron(&text).unwrap();
|
||||
assert_eq!(empty, decoded);
|
||||
// The empty theme should not mention either field.
|
||||
assert!(!text.contains("default:"));
|
||||
assert!(!text.contains("styles:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_chaining_inserts_styles_in_order() {
|
||||
let t = Theme::new()
|
||||
.with_style("a", VisualStyle::EMPTY)
|
||||
.with_style("b", VisualStyle::EMPTY);
|
||||
assert_eq!(t.styles.len(), 2);
|
||||
assert!(t.styles.contains_key("a"));
|
||||
assert!(t.styles.contains_key("b"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Per-widget typed value — the state interactive widgets carry.
|
||||
//!
|
||||
//! Stage 8's UI is data-driven: a slider knows its current position, a
|
||||
//! text input knows the string the user has typed, a checkbox knows
|
||||
//! whether it's checked. Rather than encoding "which kind of state does
|
||||
//! this widget have" inside the layout enum, every [`Widget`](super::widget::Widget)
|
||||
//! has an optional `value: Option<WidgetValue>` orthogonal to its `kind`.
|
||||
//! That keeps the layout algorithm simple (it doesn't care about state)
|
||||
//! and lets the same `Leaf` form a button (no value) or a checkbox
|
||||
//! (`Bool` value).
|
||||
//!
|
||||
//! # Data binding model
|
||||
//!
|
||||
//! Stage-8 piece-6 uses the **immediate-mode** pattern (the same as
|
||||
//! `egui` and Bevy UI): the widget tree is the source of truth for the
|
||||
//! frame. Each frame the host:
|
||||
//!
|
||||
//! 1. Pulls latest game data into the matching widget values (e.g.,
|
||||
//! `root.set_value("volume", WidgetValue::Float(audio.master_volume as f64))`).
|
||||
//! 2. Runs the [`Router`](super::routing::Router).
|
||||
//! 3. Reads back any widget values that interactive widgets may have
|
||||
//! changed, and pushes them into game data
|
||||
//! (`audio.master_volume = root.value("volume")?.as_float()? as f32`).
|
||||
//!
|
||||
//! No callback storage, no `Rc<RefCell<...>>` for state, no lifetime
|
||||
//! gymnastics — exactly what a game's main loop wants.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A typed value carried on an interactive widget — the slider's
|
||||
/// position, a checkbox's check, a text-input's string.
|
||||
///
|
||||
/// Variants are intentionally minimal; richer types (Color, Vec2, etc.)
|
||||
/// can be added as widget needs grow. RON round-trips so a UI document
|
||||
/// can ship default values inline.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum WidgetValue {
|
||||
Bool(bool),
|
||||
Int(i64),
|
||||
Float(f64),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
impl WidgetValue {
|
||||
/// Borrow as a bool if this is a [`Bool`](Self::Bool).
|
||||
pub fn as_bool(&self) -> Option<bool> {
|
||||
match self {
|
||||
Self::Bool(v) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow as an i64 if this is an [`Int`](Self::Int).
|
||||
pub fn as_int(&self) -> Option<i64> {
|
||||
match self {
|
||||
Self::Int(v) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow as an f64 if this is a [`Float`](Self::Float).
|
||||
pub fn as_float(&self) -> Option<f64> {
|
||||
match self {
|
||||
Self::Float(v) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow as a string slice if this is a [`Text`](Self::Text).
|
||||
pub fn as_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Text(s) => Some(s.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bool> for WidgetValue {
|
||||
fn from(v: bool) -> Self {
|
||||
Self::Bool(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for WidgetValue {
|
||||
fn from(v: i64) -> Self {
|
||||
Self::Int(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for WidgetValue {
|
||||
fn from(v: i32) -> Self {
|
||||
Self::Int(v as i64)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for WidgetValue {
|
||||
fn from(v: f64) -> Self {
|
||||
Self::Float(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f32> for WidgetValue {
|
||||
fn from(v: f32) -> Self {
|
||||
Self::Float(v as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for WidgetValue {
|
||||
fn from(v: String) -> Self {
|
||||
Self::Text(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for WidgetValue {
|
||||
fn from(v: &str) -> Self {
|
||||
Self::Text(v.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn as_accessors_match_variants() {
|
||||
assert_eq!(WidgetValue::Bool(true).as_bool(), Some(true));
|
||||
assert_eq!(WidgetValue::Bool(true).as_int(), None);
|
||||
assert_eq!(WidgetValue::Int(42).as_int(), Some(42));
|
||||
assert_eq!(WidgetValue::Float(1.5).as_float(), Some(1.5));
|
||||
assert_eq!(WidgetValue::Text("hi".into()).as_text(), Some("hi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primitive_conversions() {
|
||||
let v: WidgetValue = true.into();
|
||||
assert_eq!(v, WidgetValue::Bool(true));
|
||||
let v: WidgetValue = 7_i32.into();
|
||||
assert_eq!(v, WidgetValue::Int(7));
|
||||
let v: WidgetValue = 1.5_f32.into();
|
||||
assert!((v.as_float().unwrap() - 1.5_f64).abs() < 1e-5);
|
||||
let v: WidgetValue = "label".into();
|
||||
assert_eq!(v.as_text(), Some("label"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ron_round_trips_each_variant() {
|
||||
for v in [
|
||||
WidgetValue::Bool(true),
|
||||
WidgetValue::Int(-99),
|
||||
WidgetValue::Float(0.42),
|
||||
WidgetValue::Text("hello".into()),
|
||||
] {
|
||||
let text = ron::ser::to_string(&v).unwrap();
|
||||
let decoded: WidgetValue = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(v, decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Visual style — colors, borders, fonts. The *what does it look like* layer.
|
||||
//!
|
||||
//! [`VisualStyle`] is orthogonal to the Stage-8 [`LayoutStyle`](super::style::LayoutStyle):
|
||||
//! layout decides where a widget *is*; visual decides what it *looks like*.
|
||||
//! Every field is `Option<T>`. `None` means **inherit** — from a [`Theme`](super::theme::Theme)
|
||||
//! when present, otherwise from the renderer's hard-coded fallback in piece 4.
|
||||
//! `Some` means **override**: this widget (or this named theme style) wants
|
||||
//! exactly this value, regardless of what the theme provides.
|
||||
//!
|
||||
//! Why optional fields instead of full values: it lets a tiny per-widget
|
||||
//! override stay tiny in RON (one line for "button-pressed has a brighter
|
||||
//! background") without re-stating every color/border/font the theme already
|
||||
//! provides. The same merging rule works equally well for theme cascades
|
||||
//! (default → named style → per-instance) and for runtime state changes
|
||||
//! (hover/focus/press overlays in piece 5).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::asset::AssetRef;
|
||||
use crate::math::Color;
|
||||
|
||||
use super::text::Font;
|
||||
|
||||
/// Optional per-widget visual properties. `None` on a field means "inherit";
|
||||
/// `Some` means "override".
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VisualStyle {
|
||||
/// Filled background color drawn behind the widget's `content_rect`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<Color>,
|
||||
/// Foreground color — text, icons, anything drawn *on top of* the
|
||||
/// background.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub foreground: Option<Color>,
|
||||
/// Border drawn around the widget's `rect`. `Some(border)` with a
|
||||
/// `width <= 0.0` is treated as "no border" by the renderer, the same as
|
||||
/// `None`, but the value still serializes — useful for theme overrides
|
||||
/// that explicitly *suppress* a border.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub border: Option<Border>,
|
||||
/// Corner radius in logical pixels (zero means square). Applies to both
|
||||
/// background fill and border.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub corner_radius: Option<f32>,
|
||||
/// Font family + weight + italic flag. Piece 3 turns this into a
|
||||
/// shaped glyph stream.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub font: Option<FontRef>,
|
||||
/// A specific font **asset** to draw with, chosen in the editor's UI canvas
|
||||
/// from the project's `fonts/`. When set it takes precedence over the
|
||||
/// portable [`font`](Self::font) descriptor (the renderer resolves the
|
||||
/// [`AssetRef`] to a loaded face via the asset database); when `None` the
|
||||
/// descriptor / theme path applies as before. This is the engine's first
|
||||
/// `AssetRef<T>` field — the asset-picker's end-to-end target.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub font_asset: Option<AssetRef<Font>>,
|
||||
/// Font size in logical pixels.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub font_size: Option<f32>,
|
||||
}
|
||||
|
||||
impl VisualStyle {
|
||||
/// Empty style — every field `None`. Equivalent to [`Default::default`];
|
||||
/// `EMPTY` exists as a `const` for places that want it as an associated
|
||||
/// constant.
|
||||
pub const EMPTY: Self = Self {
|
||||
background: None,
|
||||
foreground: None,
|
||||
border: None,
|
||||
corner_radius: None,
|
||||
font: None,
|
||||
font_asset: None,
|
||||
font_size: None,
|
||||
};
|
||||
|
||||
/// Returns a style where every `Some` field in `override_with` replaces
|
||||
/// the corresponding field in `self`.
|
||||
///
|
||||
/// This is the merge primitive themes and runtime state use: build a
|
||||
/// resolved style by cascading default → named-style → per-instance →
|
||||
/// state-overlay, each call replacing only the fields the caller cared
|
||||
/// about.
|
||||
pub fn merged(&self, override_with: &VisualStyle) -> VisualStyle {
|
||||
VisualStyle {
|
||||
background: override_with.background.or(self.background),
|
||||
foreground: override_with.foreground.or(self.foreground),
|
||||
border: override_with.border.or(self.border),
|
||||
corner_radius: override_with.corner_radius.or(self.corner_radius),
|
||||
font: override_with.font.clone().or_else(|| self.font.clone()),
|
||||
font_asset: override_with.font_asset.or(self.font_asset),
|
||||
font_size: override_with.font_size.or(self.font_size),
|
||||
}
|
||||
}
|
||||
|
||||
/// True if every field is `None`. Handy as a `skip_serializing_if` test
|
||||
/// when embedding a `VisualStyle` in a host struct that wants the empty
|
||||
/// case to vanish from RON entirely.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == Self::EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
/// Border drawn around a widget's `rect`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Border {
|
||||
pub color: Color,
|
||||
pub width: f32,
|
||||
}
|
||||
|
||||
impl Border {
|
||||
pub const fn new(color: Color, width: f32) -> Self {
|
||||
Self { color, width }
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference to a font face the renderer will load and shape with.
|
||||
///
|
||||
/// Piece 2 stores the descriptor only; piece 3 (text shaping & glyph atlas)
|
||||
/// resolves it to an actual loaded face. Keeping the descriptor as plain
|
||||
/// `family` + `weight` + `italic` (rather than a path or a handle) means UI
|
||||
/// documents are portable: a theme can ask for `"Inter"` and the runtime can
|
||||
/// pick the platform's best match for that name without rewriting the
|
||||
/// document.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct FontRef {
|
||||
pub family: String,
|
||||
#[serde(default, skip_serializing_if = "FontWeight::is_default")]
|
||||
pub weight: FontWeight,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub italic: bool,
|
||||
}
|
||||
|
||||
impl FontRef {
|
||||
/// Regular-weight, upright font of the given family.
|
||||
pub fn regular(family: impl Into<String>) -> Self {
|
||||
Self {
|
||||
family: family.into(),
|
||||
weight: FontWeight::Regular,
|
||||
italic: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bold-weight, upright font of the given family.
|
||||
pub fn bold(family: impl Into<String>) -> Self {
|
||||
Self {
|
||||
family: family.into(),
|
||||
weight: FontWeight::Bold,
|
||||
italic: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Font weight — the named buckets the OpenType weight axis snaps to.
|
||||
///
|
||||
/// Stored as a discrete enum (rather than a `u16` 100–900) because the
|
||||
/// editor's style inspector and a hand-edited RON file both want
|
||||
/// `weight: Bold` to round-trip exactly. Renderers can map each variant to
|
||||
/// its OpenType weight value in piece 3.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum FontWeight {
|
||||
Thin,
|
||||
Light,
|
||||
#[default]
|
||||
Regular,
|
||||
Medium,
|
||||
Bold,
|
||||
Black,
|
||||
}
|
||||
|
||||
impl FontWeight {
|
||||
/// OpenType weight value (100..=900) for this bucket.
|
||||
pub fn opentype_value(self) -> u16 {
|
||||
match self {
|
||||
Self::Thin => 100,
|
||||
Self::Light => 300,
|
||||
Self::Regular => 400,
|
||||
Self::Medium => 500,
|
||||
Self::Bold => 700,
|
||||
Self::Black => 900,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_style_has_no_set_fields() {
|
||||
let s = VisualStyle::default();
|
||||
assert!(s.is_empty());
|
||||
assert_eq!(s, VisualStyle::EMPTY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_overrides_only_set_fields() {
|
||||
let base = VisualStyle {
|
||||
background: Some(Color::WHITE),
|
||||
foreground: Some(Color::BLACK),
|
||||
border: Some(Border::new(Color::BLACK, 1.0)),
|
||||
corner_radius: Some(4.0),
|
||||
font: Some(FontRef::regular("Inter")),
|
||||
font_asset: None,
|
||||
font_size: Some(14.0),
|
||||
};
|
||||
let overlay = VisualStyle {
|
||||
background: Some(Color::rgb(0.9, 0.9, 0.9)),
|
||||
font_size: Some(16.0),
|
||||
..VisualStyle::EMPTY
|
||||
};
|
||||
let merged = base.merged(&overlay);
|
||||
assert_eq!(merged.background, Some(Color::rgb(0.9, 0.9, 0.9))); // overlaid
|
||||
assert_eq!(merged.foreground, Some(Color::BLACK)); // kept from base
|
||||
assert_eq!(merged.font_size, Some(16.0)); // overlaid
|
||||
assert_eq!(merged.corner_radius, Some(4.0)); // kept from base
|
||||
assert_eq!(merged.font, Some(FontRef::regular("Inter")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_with_empty_overlay_is_identity() {
|
||||
let base = VisualStyle {
|
||||
background: Some(Color::WHITE),
|
||||
foreground: Some(Color::BLACK),
|
||||
..VisualStyle::EMPTY
|
||||
};
|
||||
assert_eq!(base.merged(&VisualStyle::EMPTY), base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_into_empty_base_takes_overlay() {
|
||||
let overlay = VisualStyle {
|
||||
background: Some(Color::RED),
|
||||
..VisualStyle::EMPTY
|
||||
};
|
||||
assert_eq!(VisualStyle::EMPTY.merged(&overlay), overlay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_ref_helpers_match_fields() {
|
||||
let r = FontRef::regular("Inter");
|
||||
assert_eq!(r.family, "Inter");
|
||||
assert_eq!(r.weight, FontWeight::Regular);
|
||||
assert!(!r.italic);
|
||||
|
||||
let b = FontRef::bold("Inter");
|
||||
assert_eq!(b.weight, FontWeight::Bold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_weight_opentype_value() {
|
||||
assert_eq!(FontWeight::Thin.opentype_value(), 100);
|
||||
assert_eq!(FontWeight::Regular.opentype_value(), 400);
|
||||
assert_eq!(FontWeight::Bold.opentype_value(), 700);
|
||||
assert_eq!(FontWeight::Black.opentype_value(), 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_style_round_trips_through_ron_compactly() {
|
||||
let s = VisualStyle {
|
||||
background: Some(Color::WHITE),
|
||||
corner_radius: Some(8.0),
|
||||
font: Some(FontRef::bold("Inter")),
|
||||
..VisualStyle::EMPTY
|
||||
};
|
||||
let text = ron::ser::to_string(&s).unwrap();
|
||||
// Fields that are `None` must not appear in the serialized form.
|
||||
assert!(!text.contains("foreground"));
|
||||
assert!(!text.contains("border"));
|
||||
assert!(!text.contains("font_size"));
|
||||
let decoded: VisualStyle = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(s, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_asset_overrides_and_round_trips() {
|
||||
use crate::asset::{AssetRef, AssetUid};
|
||||
|
||||
// An overlay's font_asset replaces the base's, like the other fields.
|
||||
let base = VisualStyle {
|
||||
font_asset: Some(AssetRef::new(AssetUid(1))),
|
||||
..VisualStyle::EMPTY
|
||||
};
|
||||
let overlay = VisualStyle {
|
||||
font_asset: Some(AssetRef::new(AssetUid(2))),
|
||||
..VisualStyle::EMPTY
|
||||
};
|
||||
assert_eq!(
|
||||
base.merged(&overlay).font_asset,
|
||||
Some(AssetRef::new(AssetUid(2)))
|
||||
);
|
||||
// An empty overlay keeps the base reference (inherit semantics).
|
||||
assert_eq!(base.merged(&VisualStyle::EMPTY).font_asset, base.font_asset);
|
||||
|
||||
// Round-trips compactly and is skipped when unset.
|
||||
let text = ron::ser::to_string(&base).unwrap();
|
||||
assert!(text.contains("font_asset"));
|
||||
assert_eq!(ron::de::from_str::<VisualStyle>(&text).unwrap(), base);
|
||||
let empty_text = ron::ser::to_string(&VisualStyle::EMPTY).unwrap();
|
||||
assert!(!empty_text.contains("font_asset"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_style_round_trips_to_empty_ron() {
|
||||
let text = ron::ser::to_string(&VisualStyle::EMPTY).unwrap();
|
||||
// No fields set → the struct should serialize to its empty form.
|
||||
let decoded: VisualStyle = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(decoded, VisualStyle::EMPTY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_ref_defaults_skip_in_ron() {
|
||||
let f = FontRef::regular("Inter");
|
||||
let text = ron::ser::to_string(&f).unwrap();
|
||||
// Regular weight and non-italic should be skipped.
|
||||
assert!(!text.contains("Regular"));
|
||||
assert!(!text.contains("italic"));
|
||||
let decoded: FontRef = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(f, decoded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
//! Widget tree — the data structure laid out by [`super::layout`].
|
||||
//!
|
||||
//! Stage 8 splits widgets cleanly into **what** (the [`WidgetKind`]) and
|
||||
//! **how** (the [`LayoutStyle`] held on every node). The kind decides whether
|
||||
//! a node has children and how they're arranged; the style is the same fields
|
||||
//! on every widget so the layout algorithm has one place to look.
|
||||
//!
|
||||
//! Piece 1 ships only what the layout algorithm needs: a [`Leaf`](WidgetKind::Leaf)
|
||||
//! placeholder with an intrinsic size, and three container kinds — [`Stack`]
|
||||
//! (row/column), [`Grid`], and [`AnchorGroup`]. Interactive widgets (button,
|
||||
//! checkbox, slider, text input, …) are layered on top in later pieces by
|
||||
//! decorating leaves with kind-specific style/state; they all participate in
|
||||
//! the same layout pass without the algorithm having to know about them.
|
||||
//!
|
||||
//! # Building a tree
|
||||
//!
|
||||
//! ```
|
||||
//! use glam::Vec2;
|
||||
//! use oxide_engine::ui::{Insets, LayoutStyle, Sizing, Widget};
|
||||
//!
|
||||
//! let panel = Widget::row()
|
||||
//! .with_id("toolbar")
|
||||
//! .with_style(LayoutStyle {
|
||||
//! width: Sizing::Grow(1.0),
|
||||
//! height: Sizing::Fixed(32.0),
|
||||
//! padding: Insets::all(4.0),
|
||||
//! ..Default::default()
|
||||
//! })
|
||||
//! .with_child(Widget::leaf(Vec2::new(80.0, 24.0)).with_id("file"))
|
||||
//! .with_child(Widget::leaf(Vec2::new(80.0, 24.0)).with_id("edit"));
|
||||
//! assert_eq!(panel.children().len(), 2);
|
||||
//! ```
|
||||
|
||||
use glam::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::style::LayoutStyle;
|
||||
use super::value::WidgetValue;
|
||||
use super::visual::VisualStyle;
|
||||
|
||||
/// Stable identifier for a widget — used to look up its laid-out rect in a
|
||||
/// [`LayoutTree`](super::layout::LayoutTree) and (in later pieces) to wire up
|
||||
/// input routing and data binding.
|
||||
///
|
||||
/// Stored as `String` so UI documents can ship author-facing names (`"play"`,
|
||||
/// `"volume-slider"`) straight through RON. The empty id (`""`) is the default
|
||||
/// and means "anonymous"; multiple anonymous widgets are allowed and lookups
|
||||
/// by empty id are rejected by [`LayoutTree::find`](super::layout::LayoutTree::find).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct WidgetId(pub String);
|
||||
|
||||
impl WidgetId {
|
||||
/// `true` if the id string is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// Borrow the underlying string.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for WidgetId {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// A path from a root [`Widget`] to one of its descendants: the sequence of
|
||||
/// child indices to follow from the root. The **empty** path denotes the root
|
||||
/// itself.
|
||||
///
|
||||
/// Unlike [`WidgetId`] (optional, author-facing, possibly absent or duplicated)
|
||||
/// a path addresses *exactly one* node positionally, so it is what the editor's
|
||||
/// UI canvas uses to target structural edits — insert, remove, move — and to
|
||||
/// record them on the undo stack. Paths are only valid against the tree they
|
||||
/// were derived from; an edit that changes sibling order invalidates the paths
|
||||
/// after it (the move helper accounts for this itself).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct WidgetPath(pub Vec<usize>);
|
||||
|
||||
impl WidgetPath {
|
||||
/// The root path (addresses the tree's root widget).
|
||||
pub fn root() -> Self {
|
||||
Self(Vec::new())
|
||||
}
|
||||
|
||||
/// Whether this path addresses the root (is empty).
|
||||
pub fn is_root(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// Depth from the root (number of indices).
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
/// Whether the path is empty — alias of [`is_root`](Self::is_root), provided
|
||||
/// for the clippy `len`/`is_empty` pairing.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// A child path one level deeper, selecting child `index`.
|
||||
pub fn child(&self, index: usize) -> Self {
|
||||
let mut v = self.0.clone();
|
||||
v.push(index);
|
||||
Self(v)
|
||||
}
|
||||
|
||||
/// Splits into `(parent_path, last_index)`, or `None` for the root.
|
||||
pub fn split_last(&self) -> Option<(WidgetPath, usize)> {
|
||||
let (last, rest) = self.0.split_last()?;
|
||||
Some((WidgetPath(rest.to_vec()), *last))
|
||||
}
|
||||
|
||||
/// Whether `self` is `other` or lies underneath it (prefix test). Used to
|
||||
/// reject moving a subtree into its own descendant.
|
||||
pub fn starts_with(&self, other: &WidgetPath) -> bool {
|
||||
self.0.starts_with(&other.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for WidgetId {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget tree node — id, layout style, optional visual style + theme
|
||||
/// reference, and a kind that decides what children it holds.
|
||||
///
|
||||
/// `style` (Stage-8 piece 1) controls layout — where the widget is.
|
||||
/// `visual` (piece 2) carries per-instance visual overrides — what the
|
||||
/// widget looks like — and `theme_style` opts into a named entry in the
|
||||
/// project's [`Theme`](super::theme::Theme). Both default to empty so a
|
||||
/// piece-1 UI document still parses unchanged.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Widget {
|
||||
#[serde(default, skip_serializing_if = "WidgetId::is_empty")]
|
||||
pub id: WidgetId,
|
||||
#[serde(default)]
|
||||
pub style: LayoutStyle,
|
||||
#[serde(default, skip_serializing_if = "VisualStyle::is_empty")]
|
||||
pub visual: VisualStyle,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub theme_style: Option<String>,
|
||||
/// Text content shaped inside this widget's `content_rect`. Orthogonal
|
||||
/// to `kind`: a button is a `Leaf` with `text` + `visual.background`; a
|
||||
/// label is a `Leaf` with `text` only. Renderers shape this string
|
||||
/// against the resolved [`VisualStyle::font`] and [`VisualStyle::font_size`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
/// Per-widget typed state — `Bool` for a checkbox, `Float` for a
|
||||
/// slider, `Text` for a text input. Orthogonal to `kind`; absent
|
||||
/// means "no state". See [`super::value::WidgetValue`] and the
|
||||
/// piece-6 [`Widget::value`](Self::value) / [`set_value`](Self::set_value)
|
||||
/// helpers.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<WidgetValue>,
|
||||
pub kind: WidgetKind,
|
||||
}
|
||||
|
||||
/// What a widget *is* — leaf or one of three container layout modes.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum WidgetKind {
|
||||
/// A childless node with an intrinsic logical size. Real interactive
|
||||
/// widgets (label, button, image) layer on top of this in later pieces.
|
||||
Leaf { intrinsic: Vec2 },
|
||||
/// Row or column container.
|
||||
Stack(Stack),
|
||||
/// Equal-cell grid container.
|
||||
Grid(Grid),
|
||||
/// Container that positions each child via the child's own
|
||||
/// [`Anchor`](super::style::Anchor).
|
||||
Anchor(AnchorGroup),
|
||||
}
|
||||
|
||||
impl Default for WidgetKind {
|
||||
fn default() -> Self {
|
||||
Self::Leaf {
|
||||
intrinsic: Vec2::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stack container — arranges children along a main axis.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Stack {
|
||||
pub direction: StackDirection,
|
||||
/// Logical-pixel gap between adjacent children.
|
||||
#[serde(default)]
|
||||
pub gap: f32,
|
||||
/// How leftover space on the main axis is distributed *after* children
|
||||
/// have been sized. Ignored when any child uses [`Sizing::Grow`](super::style::Sizing::Grow),
|
||||
/// since `Grow` consumes the leftover space directly.
|
||||
#[serde(default)]
|
||||
pub main_align: super::style::Align,
|
||||
#[serde(default)]
|
||||
pub children: Vec<Widget>,
|
||||
}
|
||||
|
||||
/// Direction of a [`Stack`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum StackDirection {
|
||||
/// Children flow left-to-right.
|
||||
#[default]
|
||||
Row,
|
||||
/// Children flow top-to-bottom.
|
||||
Column,
|
||||
}
|
||||
|
||||
/// Equal-cell grid container — `cols × rows` cells filled in row-major order.
|
||||
///
|
||||
/// Piece-1 grids are intentionally simple: every cell is the same size,
|
||||
/// computed from the parent's content rect. More flexible grids (auto-sized
|
||||
/// rows/columns, spans) are a follow-up; the use cases the editor's Stage-7
|
||||
/// preferences page and the Stage-8 settings examples actually need are all
|
||||
/// served by the equal-cell case.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Grid {
|
||||
pub cols: u32,
|
||||
pub rows: u32,
|
||||
/// `gap.x` between columns, `gap.y` between rows (logical pixels).
|
||||
#[serde(default)]
|
||||
pub gap: Vec2,
|
||||
#[serde(default)]
|
||||
pub children: Vec<Widget>,
|
||||
}
|
||||
|
||||
/// Anchor container — each child is placed according to its own
|
||||
/// [`LayoutStyle::anchor`](super::style::LayoutStyle::anchor).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnchorGroup {
|
||||
#[serde(default)]
|
||||
pub children: Vec<Widget>,
|
||||
}
|
||||
|
||||
impl Widget {
|
||||
/// Build a leaf widget with the given intrinsic logical size.
|
||||
pub fn leaf(intrinsic: Vec2) -> Self {
|
||||
Self {
|
||||
kind: WidgetKind::Leaf { intrinsic },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an empty stack with the given direction (gap 0, default align).
|
||||
pub fn stack(direction: StackDirection) -> Self {
|
||||
Self {
|
||||
kind: WidgetKind::Stack(Stack {
|
||||
direction,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shortcut for `Widget::stack(StackDirection::Row)`.
|
||||
pub fn row() -> Self {
|
||||
Self::stack(StackDirection::Row)
|
||||
}
|
||||
|
||||
/// Shortcut for `Widget::stack(StackDirection::Column)`.
|
||||
pub fn column() -> Self {
|
||||
Self::stack(StackDirection::Column)
|
||||
}
|
||||
|
||||
/// Build an empty grid container.
|
||||
pub fn grid(cols: u32, rows: u32) -> Self {
|
||||
Self {
|
||||
kind: WidgetKind::Grid(Grid {
|
||||
cols,
|
||||
rows,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an empty anchor container.
|
||||
pub fn anchor() -> Self {
|
||||
Self {
|
||||
kind: WidgetKind::Anchor(AnchorGroup::default()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the widget id (builder).
|
||||
pub fn with_id(mut self, id: impl Into<WidgetId>) -> Self {
|
||||
self.id = id.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Replace the whole [`LayoutStyle`] (builder).
|
||||
pub fn with_style(mut self, style: LayoutStyle) -> Self {
|
||||
self.style = style;
|
||||
self
|
||||
}
|
||||
|
||||
/// Replace the per-instance [`VisualStyle`] (builder).
|
||||
pub fn with_visual(mut self, visual: VisualStyle) -> Self {
|
||||
self.visual = visual;
|
||||
self
|
||||
}
|
||||
|
||||
/// Opt this widget into a named entry of the active
|
||||
/// [`Theme`](super::theme::Theme) (builder). Pass `""` or call
|
||||
/// [`Widget::clear_theme_style`] to remove the reference.
|
||||
pub fn with_theme_style(mut self, name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
self.theme_style = if name.is_empty() { None } else { Some(name) };
|
||||
self
|
||||
}
|
||||
|
||||
/// Drop any `theme_style` reference (builder).
|
||||
pub fn clear_theme_style(mut self) -> Self {
|
||||
self.theme_style = None;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set this widget's text content (builder). Pass `""` to clear it. The
|
||||
/// text is shaped at paint time against the widget's resolved font and
|
||||
/// font size from the active theme.
|
||||
pub fn with_text(mut self, text: impl Into<String>) -> Self {
|
||||
let s = text.into();
|
||||
self.text = if s.is_empty() { None } else { Some(s) };
|
||||
self
|
||||
}
|
||||
|
||||
/// Set this widget's typed value (builder).
|
||||
pub fn with_value(mut self, value: impl Into<WidgetValue>) -> Self {
|
||||
self.value = Some(value.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the stack gap (builder). Panics if not a stack — surfaces author
|
||||
/// mistakes during construction rather than producing a silently
|
||||
/// misshapen UI at layout time.
|
||||
pub fn with_gap(mut self, gap: f32) -> Self {
|
||||
match &mut self.kind {
|
||||
WidgetKind::Stack(s) => s.gap = gap,
|
||||
_ => panic!("with_gap is only valid on Stack widgets"),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the stack main-axis alignment (builder). Panics if not a stack.
|
||||
pub fn with_main_align(mut self, align: super::style::Align) -> Self {
|
||||
match &mut self.kind {
|
||||
WidgetKind::Stack(s) => s.main_align = align,
|
||||
_ => panic!("with_main_align is only valid on Stack widgets"),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the grid gap vector (builder). Panics if not a grid.
|
||||
pub fn with_grid_gap(mut self, gap: Vec2) -> Self {
|
||||
match &mut self.kind {
|
||||
WidgetKind::Grid(g) => g.gap = gap,
|
||||
_ => panic!("with_grid_gap is only valid on Grid widgets"),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Append a single child to a container widget (builder). Panics on a
|
||||
/// leaf so the misuse is caught at construction.
|
||||
pub fn with_child(mut self, child: Widget) -> Self {
|
||||
children_mut(&mut self.kind, |c| c.push(child));
|
||||
self
|
||||
}
|
||||
|
||||
/// Append many children (builder).
|
||||
pub fn with_children(mut self, children: impl IntoIterator<Item = Widget>) -> Self {
|
||||
children_mut(&mut self.kind, |c| c.extend(children));
|
||||
self
|
||||
}
|
||||
|
||||
/// Borrow the direct children of this widget. Empty for leaves.
|
||||
pub fn children(&self) -> &[Widget] {
|
||||
match &self.kind {
|
||||
WidgetKind::Leaf { .. } => &[],
|
||||
WidgetKind::Stack(s) => &s.children,
|
||||
WidgetKind::Grid(g) => &g.children,
|
||||
WidgetKind::Anchor(a) => &a.children,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow the direct children mutably. Empty slice for leaves.
|
||||
///
|
||||
/// Underpins [`find_by_id_mut`](Self::find_by_id_mut) and the piece-6
|
||||
/// data-binding helpers; safer than reaching into `kind` because all
|
||||
/// container kinds funnel through one accessor.
|
||||
pub fn children_mut(&mut self) -> &mut [Widget] {
|
||||
match &mut self.kind {
|
||||
WidgetKind::Leaf { .. } => &mut [],
|
||||
WidgetKind::Stack(s) => &mut s.children,
|
||||
WidgetKind::Grid(g) => &mut g.children,
|
||||
WidgetKind::Anchor(a) => &mut a.children,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow this widget's children as the owning `Vec`, or `None` for a
|
||||
/// [`Leaf`](WidgetKind::Leaf) (which cannot hold children). Unlike
|
||||
/// [`children_mut`](Self::children_mut) this exposes the `Vec` itself, so
|
||||
/// callers can insert/remove — the basis of the structural edits below.
|
||||
pub fn children_vec_mut(&mut self) -> Option<&mut Vec<Widget>> {
|
||||
match &mut self.kind {
|
||||
WidgetKind::Leaf { .. } => None,
|
||||
WidgetKind::Stack(s) => Some(&mut s.children),
|
||||
WidgetKind::Grid(g) => Some(&mut g.children),
|
||||
WidgetKind::Anchor(a) => Some(&mut a.children),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this widget is a container (can hold children) rather than a leaf.
|
||||
pub fn is_container(&self) -> bool {
|
||||
!matches!(self.kind, WidgetKind::Leaf { .. })
|
||||
}
|
||||
|
||||
/// Borrow the widget addressed by `path` (the root for the empty path), or
|
||||
/// `None` if any index along the way is out of range.
|
||||
pub fn get_path(&self, path: &WidgetPath) -> Option<&Widget> {
|
||||
let mut node = self;
|
||||
for &i in &path.0 {
|
||||
node = node.children().get(i)?;
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// Mutable counterpart of [`get_path`](Self::get_path).
|
||||
pub fn get_path_mut(&mut self, path: &WidgetPath) -> Option<&mut Widget> {
|
||||
let mut node = self;
|
||||
for &i in &path.0 {
|
||||
node = node.children_mut().get_mut(i)?;
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// Inserts `child` at `index` among the children of the widget addressed by
|
||||
/// `parent`, returning whether it succeeded. `index` is clamped to the
|
||||
/// child count (so it can append). Fails if `parent` does not resolve or is
|
||||
/// a leaf.
|
||||
pub fn insert_child(&mut self, parent: &WidgetPath, index: usize, child: Widget) -> bool {
|
||||
let Some(parent) = self.get_path_mut(parent) else {
|
||||
return false;
|
||||
};
|
||||
let Some(children) = parent.children_vec_mut() else {
|
||||
return false;
|
||||
};
|
||||
children.insert(index.min(children.len()), child);
|
||||
true
|
||||
}
|
||||
|
||||
/// Appends `child` to the children of the widget addressed by `parent`.
|
||||
/// Convenience over [`insert_child`](Self::insert_child) with a trailing
|
||||
/// index.
|
||||
pub fn push_child_at(&mut self, parent: &WidgetPath, child: Widget) -> bool {
|
||||
self.insert_child(parent, usize::MAX, child)
|
||||
}
|
||||
|
||||
/// Removes and returns the widget addressed by `path`. The root cannot be
|
||||
/// removed (returns `None` for the empty path), nor can an out-of-range or
|
||||
/// unreachable path.
|
||||
pub fn remove_path(&mut self, path: &WidgetPath) -> Option<Widget> {
|
||||
let (parent, index) = path.split_last()?;
|
||||
let children = self.get_path_mut(&parent)?.children_vec_mut()?;
|
||||
(index < children.len()).then(|| children.remove(index))
|
||||
}
|
||||
|
||||
/// Moves the subtree at `from` to be child `index` of `to_parent`,
|
||||
/// returning whether it succeeded. Rejects moving the root, or moving a node
|
||||
/// into itself or one of its own descendants. Sibling indices shift when the
|
||||
/// node is detached, so both `to_parent` and `index` are adjusted internally
|
||||
/// to mean what the caller intended *before* the move.
|
||||
pub fn move_subtree(
|
||||
&mut self,
|
||||
from: &WidgetPath,
|
||||
to_parent: &WidgetPath,
|
||||
index: usize,
|
||||
) -> bool {
|
||||
if from.is_root() || to_parent.starts_with(from) {
|
||||
return false;
|
||||
}
|
||||
// The destination must exist and be a container; check before detaching
|
||||
// (removing `from`, which is not an ancestor of `to_parent`, leaves the
|
||||
// destination node itself unchanged — only its path may shift).
|
||||
if !self.get_path(to_parent).is_some_and(Widget::is_container) {
|
||||
return false;
|
||||
}
|
||||
let Some(node) = self.remove_path(from) else {
|
||||
return false;
|
||||
};
|
||||
let to_parent = adjust_path_for_removal(to_parent, from);
|
||||
let (from_parent, from_index) = from.split_last().expect("non-root checked above");
|
||||
// Inserting back into the same parent after the detach point shifts the
|
||||
// target slot down by one.
|
||||
let index = if from_parent.0 == to_parent.0 && from_index < index {
|
||||
index - 1
|
||||
} else {
|
||||
index
|
||||
};
|
||||
self.insert_child(&to_parent, index, node)
|
||||
}
|
||||
|
||||
/// Find a descendant (or self) with this id. Returns the first match
|
||||
/// in pre-order. `None` if no widget matches (or `id` is empty).
|
||||
pub fn find_by_id(&self, id: &WidgetId) -> Option<&Widget> {
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if self.id == *id {
|
||||
return Some(self);
|
||||
}
|
||||
for child in self.children() {
|
||||
if let Some(found) = child.find_by_id(id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Mutable counterpart of [`find_by_id`](Self::find_by_id).
|
||||
pub fn find_by_id_mut(&mut self, id: &WidgetId) -> Option<&mut Widget> {
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if self.id == *id {
|
||||
return Some(self);
|
||||
}
|
||||
for child in self.children_mut() {
|
||||
if let Some(found) = child.find_by_id_mut(id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Borrow the [`WidgetValue`] of the descendant with this id, if any.
|
||||
/// One half of the piece-6 data-binding loop: read what the UI says.
|
||||
pub fn value(&self, id: &WidgetId) -> Option<&WidgetValue> {
|
||||
self.find_by_id(id).and_then(|w| w.value.as_ref())
|
||||
}
|
||||
|
||||
/// Set the [`WidgetValue`] of the descendant with this id, returning
|
||||
/// `true` if such a widget exists. The other half of the piece-6
|
||||
/// data-binding loop: write game state into the UI.
|
||||
pub fn set_value(&mut self, id: &WidgetId, value: impl Into<WidgetValue>) -> bool {
|
||||
match self.find_by_id_mut(id) {
|
||||
Some(w) => {
|
||||
w.value = Some(value.into());
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursive count of nodes including `self`. Handy for sanity checks
|
||||
/// in tests when comparing against a [`LayoutTree::nodes`](super::layout::LayoutTree::nodes)
|
||||
/// length.
|
||||
pub fn node_count(&self) -> usize {
|
||||
1 + self
|
||||
.children()
|
||||
.iter()
|
||||
.map(Widget::node_count)
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
/// Resolve this widget's effective [`VisualStyle`] under a given theme,
|
||||
/// cascading `theme.default` → `theme.styles[self.theme_style]` →
|
||||
/// `self.visual`. See [`Theme::resolve`](super::theme::Theme::resolve)
|
||||
/// for the merge rules. Children are *not* recursively resolved here —
|
||||
/// piece 4 walks the tree pairing each [`super::layout::LayoutNode`] with
|
||||
/// its resolved style.
|
||||
pub fn resolve_visual(&self, theme: &super::theme::Theme) -> VisualStyle {
|
||||
theme.resolve(self.theme_style.as_deref(), &self.visual)
|
||||
}
|
||||
|
||||
/// Serialize this widget tree to a pretty-printed RON string — the
|
||||
/// canonical UI-document format an editor saves and the runtime loads.
|
||||
pub fn to_ron(&self) -> Result<String, ron::Error> {
|
||||
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
|
||||
}
|
||||
|
||||
/// Parse a widget tree from a RON string produced by [`to_ron`](Self::to_ron).
|
||||
pub fn from_ron(text: &str) -> Result<Self, ron::de::SpannedError> {
|
||||
ron::de::from_str(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites `path` to stay valid after the widget at `removed` is detached.
|
||||
///
|
||||
/// Detaching shifts the later siblings of `removed` down by one. A path is
|
||||
/// affected only if it descends through `removed`'s parent and its index at
|
||||
/// that depth is *after* the removed index; then that one index decrements.
|
||||
/// `path` must not be `removed` or beneath it (the caller guarantees this).
|
||||
fn adjust_path_for_removal(path: &WidgetPath, removed: &WidgetPath) -> WidgetPath {
|
||||
let Some((removed_parent, removed_index)) = removed.split_last() else {
|
||||
return path.clone();
|
||||
};
|
||||
let depth = removed_parent.0.len();
|
||||
let mut out = path.0.clone();
|
||||
if out.len() > depth && out[..depth] == removed_parent.0[..] && out[depth] > removed_index {
|
||||
out[depth] -= 1;
|
||||
}
|
||||
WidgetPath(out)
|
||||
}
|
||||
|
||||
fn children_mut(kind: &mut WidgetKind, f: impl FnOnce(&mut Vec<Widget>)) {
|
||||
match kind {
|
||||
WidgetKind::Stack(s) => f(&mut s.children),
|
||||
WidgetKind::Grid(g) => f(&mut g.children),
|
||||
WidgetKind::Anchor(a) => f(&mut a.children),
|
||||
WidgetKind::Leaf { .. } => panic!("cannot add children to a Leaf widget"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A row root with three leaf children id'd "a","b","c".
|
||||
fn abc_tree() -> Widget {
|
||||
Widget::row()
|
||||
.with_id("root")
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("a"))
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("b"))
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("c"))
|
||||
}
|
||||
|
||||
fn ids_of(children: &[Widget]) -> Vec<&str> {
|
||||
children.iter().map(|w| w.id.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_path_addresses_nodes() {
|
||||
let root = abc_tree();
|
||||
assert_eq!(
|
||||
root.get_path(&WidgetPath::root()).unwrap().id.as_str(),
|
||||
"root"
|
||||
);
|
||||
assert_eq!(
|
||||
root.get_path(&WidgetPath(vec![1])).unwrap().id.as_str(),
|
||||
"b"
|
||||
);
|
||||
assert!(root.get_path(&WidgetPath(vec![9])).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_remove_children_by_path() {
|
||||
let mut root = abc_tree();
|
||||
// Insert "x" between a and b.
|
||||
assert!(root.insert_child(
|
||||
&WidgetPath::root(),
|
||||
1,
|
||||
Widget::leaf(Vec2::ZERO).with_id("x")
|
||||
));
|
||||
assert_eq!(ids_of(root.children()), ["a", "x", "b", "c"]);
|
||||
// Append "z" via the clamping path.
|
||||
assert!(root.push_child_at(&WidgetPath::root(), Widget::leaf(Vec2::ZERO).with_id("z")));
|
||||
assert_eq!(ids_of(root.children()), ["a", "x", "b", "c", "z"]);
|
||||
// A leaf rejects children; the root cannot be removed.
|
||||
assert!(!root.insert_child(&WidgetPath(vec![0]), 0, Widget::default()));
|
||||
assert!(root.remove_path(&WidgetPath::root()).is_none());
|
||||
// Remove "x".
|
||||
let removed = root.remove_path(&WidgetPath(vec![1])).unwrap();
|
||||
assert_eq!(removed.id.as_str(), "x");
|
||||
assert_eq!(ids_of(root.children()), ["a", "b", "c", "z"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_subtree_reorders_within_parent() {
|
||||
let mut root = abc_tree();
|
||||
// Move "a" (index 0) to the end (index 3 in pre-removal terms).
|
||||
assert!(root.move_subtree(&WidgetPath(vec![0]), &WidgetPath::root(), 3));
|
||||
assert_eq!(ids_of(root.children()), ["b", "c", "a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_subtree_across_branches_adjusts_paths() {
|
||||
// root[ col(0) [a], b(1), c(2) ]: move c into the column before a.
|
||||
let mut root = Widget::row()
|
||||
.with_id("root")
|
||||
.with_child(
|
||||
Widget::column()
|
||||
.with_id("col")
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("a")),
|
||||
)
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("b"))
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("c"));
|
||||
assert!(root.move_subtree(&WidgetPath(vec![2]), &WidgetPath(vec![0]), 0));
|
||||
// c now leads the column; root has col + b left.
|
||||
assert_eq!(
|
||||
ids_of(root.get_path(&WidgetPath(vec![0])).unwrap().children()),
|
||||
["c", "a"]
|
||||
);
|
||||
assert_eq!(ids_of(root.children()), ["col", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_subtree_rejects_into_own_descendant_and_root() {
|
||||
let mut root = Widget::row().with_id("root").with_child(
|
||||
Widget::column()
|
||||
.with_id("col")
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("a")),
|
||||
);
|
||||
// Can't move "col" (path [0]) under its own child "a" (path [0,0]).
|
||||
assert!(!root.move_subtree(&WidgetPath(vec![0]), &WidgetPath(vec![0, 0]), 0));
|
||||
// Can't move the root.
|
||||
assert!(!root.move_subtree(&WidgetPath::root(), &WidgetPath(vec![0]), 0));
|
||||
// Tree is unchanged.
|
||||
assert_eq!(ids_of(root.children()), ["col"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_id_from_str_and_string() {
|
||||
let a: WidgetId = "abc".into();
|
||||
let b: WidgetId = String::from("abc").into();
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a.as_str(), "abc");
|
||||
assert!(!a.is_empty());
|
||||
assert!(WidgetId::default().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_widget_is_zero_leaf() {
|
||||
let w = Widget::default();
|
||||
assert_eq!(w.id, WidgetId::default());
|
||||
assert_eq!(w.style, LayoutStyle::default());
|
||||
assert!(matches!(w.kind, WidgetKind::Leaf { intrinsic } if intrinsic == Vec2::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_methods_compose() {
|
||||
let w = Widget::row()
|
||||
.with_id("toolbar")
|
||||
.with_gap(4.0)
|
||||
.with_main_align(super::super::style::Align::Center)
|
||||
.with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("a"))
|
||||
.with_children([Widget::leaf(Vec2::new(20.0, 10.0)).with_id("b")]);
|
||||
assert_eq!(w.id.as_str(), "toolbar");
|
||||
let WidgetKind::Stack(s) = &w.kind else {
|
||||
panic!("expected stack");
|
||||
};
|
||||
assert_eq!(s.direction, StackDirection::Row);
|
||||
assert_eq!(s.gap, 4.0);
|
||||
assert_eq!(s.main_align, super::super::style::Align::Center);
|
||||
assert_eq!(s.children.len(), 2);
|
||||
assert_eq!(s.children[0].id.as_str(), "a");
|
||||
assert_eq!(s.children[1].id.as_str(), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "cannot add children to a Leaf widget")]
|
||||
fn adding_child_to_leaf_panics() {
|
||||
let _ = Widget::leaf(Vec2::new(1.0, 1.0)).with_child(Widget::leaf(Vec2::ONE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "with_gap is only valid on Stack widgets")]
|
||||
fn gap_on_non_stack_panics() {
|
||||
let _ = Widget::grid(2, 2).with_gap(4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_count_recurses() {
|
||||
let tree = Widget::row()
|
||||
.with_child(Widget::leaf(Vec2::ONE))
|
||||
.with_child(
|
||||
Widget::column()
|
||||
.with_child(Widget::leaf(Vec2::ONE))
|
||||
.with_child(Widget::leaf(Vec2::ONE)),
|
||||
);
|
||||
// root + leaf + (column + 2 leaves) = 5
|
||||
assert_eq!(tree.node_count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_round_trips_through_ron() {
|
||||
let w = Widget::row()
|
||||
.with_id("root")
|
||||
.with_gap(8.0)
|
||||
.with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a"))
|
||||
.with_child(Widget::anchor().with_child(Widget::leaf(Vec2::new(10.0, 10.0))));
|
||||
let text = ron::ser::to_string_pretty(&w, ron::ser::PrettyConfig::default()).unwrap();
|
||||
let decoded: Widget = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(w, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_and_theme_style_builders_set_fields() {
|
||||
use super::super::visual::VisualStyle;
|
||||
use crate::math::Color;
|
||||
|
||||
let w = Widget::leaf(Vec2::ONE)
|
||||
.with_id("a")
|
||||
.with_visual(VisualStyle {
|
||||
background: Some(Color::RED),
|
||||
..VisualStyle::EMPTY
|
||||
})
|
||||
.with_theme_style("button");
|
||||
assert_eq!(w.visual.background, Some(Color::RED));
|
||||
assert_eq!(w.theme_style.as_deref(), Some("button"));
|
||||
|
||||
// Passing an empty string drops the reference.
|
||||
let cleared = w.clone().with_theme_style("");
|
||||
assert_eq!(cleared.theme_style, None);
|
||||
|
||||
let explicitly_cleared = w.clear_theme_style();
|
||||
assert_eq!(explicitly_cleared.theme_style, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_visual_cascades_theme_named_overrides() {
|
||||
use super::super::theme::Theme;
|
||||
use super::super::visual::VisualStyle;
|
||||
use crate::math::Color;
|
||||
|
||||
let theme = Theme::new()
|
||||
.with_default(VisualStyle {
|
||||
foreground: Some(Color::BLACK),
|
||||
background: Some(Color::WHITE),
|
||||
..VisualStyle::EMPTY
|
||||
})
|
||||
.with_style(
|
||||
"button",
|
||||
VisualStyle {
|
||||
background: Some(Color::rgb(0.85, 0.85, 0.9)),
|
||||
..VisualStyle::EMPTY
|
||||
},
|
||||
);
|
||||
let w = Widget::leaf(Vec2::ONE)
|
||||
.with_theme_style("button")
|
||||
.with_visual(VisualStyle {
|
||||
foreground: Some(Color::RED),
|
||||
..VisualStyle::EMPTY
|
||||
});
|
||||
let resolved = w.resolve_visual(&theme);
|
||||
assert_eq!(resolved.foreground, Some(Color::RED)); // per-instance
|
||||
assert_eq!(resolved.background, Some(Color::rgb(0.85, 0.85, 0.9))); // named
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_with_visual_and_theme_style_round_trips_through_ron() {
|
||||
use super::super::visual::{FontRef, VisualStyle};
|
||||
use crate::math::Color;
|
||||
|
||||
let w = Widget::row()
|
||||
.with_id("toolbar")
|
||||
.with_theme_style("toolbar")
|
||||
.with_visual(VisualStyle {
|
||||
background: Some(Color::rgb(0.1, 0.1, 0.1)),
|
||||
font: Some(FontRef::bold("Inter")),
|
||||
..VisualStyle::EMPTY
|
||||
})
|
||||
.with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_theme_style("button"));
|
||||
let text = w.to_ron().unwrap();
|
||||
let decoded = Widget::from_ron(&text).unwrap();
|
||||
assert_eq!(w, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_widget_serializes_without_new_fields() {
|
||||
// The new `visual` and `theme_style` fields skip when empty/None, so
|
||||
// a piece-1 default widget should still serialize to the piece-1
|
||||
// form (no `visual:` or `theme_style:` keys in the output).
|
||||
let w = Widget::default();
|
||||
let text = w.to_ron().unwrap();
|
||||
assert!(!text.contains("visual:"));
|
||||
assert!(!text.contains("theme_style:"));
|
||||
// And re-parsing yields the same value.
|
||||
assert_eq!(Widget::from_ron(&text).unwrap(), w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_by_id_walks_the_subtree() {
|
||||
let tree = Widget::row()
|
||||
.with_id("root")
|
||||
.with_child(Widget::leaf(Vec2::ONE).with_id("a"))
|
||||
.with_child(
|
||||
Widget::column()
|
||||
.with_id("group")
|
||||
.with_child(Widget::leaf(Vec2::ONE).with_id("buried")),
|
||||
);
|
||||
assert_eq!(tree.find_by_id(&"root".into()).unwrap().id.as_str(), "root");
|
||||
assert_eq!(tree.find_by_id(&"a".into()).unwrap().id.as_str(), "a");
|
||||
assert_eq!(
|
||||
tree.find_by_id(&"buried".into()).unwrap().id.as_str(),
|
||||
"buried"
|
||||
);
|
||||
assert!(tree.find_by_id(&"missing".into()).is_none());
|
||||
// Empty id is never a match.
|
||||
assert!(tree.find_by_id(&WidgetId::default()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_value_updates_a_descendant() {
|
||||
let mut tree = Widget::row()
|
||||
.with_id("root")
|
||||
.with_child(Widget::leaf(Vec2::ONE).with_id("volume"))
|
||||
.with_child(Widget::leaf(Vec2::ONE).with_id("invert_y"));
|
||||
assert!(tree.set_value(&"volume".into(), 0.75_f32));
|
||||
assert!(tree.set_value(&"invert_y".into(), true));
|
||||
assert_eq!(
|
||||
tree.value(&"volume".into()).and_then(|v| v.as_float()),
|
||||
Some(0.75_f32 as f64)
|
||||
);
|
||||
assert_eq!(
|
||||
tree.value(&"invert_y".into()).and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
// Unknown id: returns false, tree unchanged.
|
||||
assert!(!tree.set_value(&"missing".into(), 0.0_f32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_value_builder_sets_value() {
|
||||
let w = Widget::leaf(Vec2::ONE).with_id("checkbox").with_value(true);
|
||||
assert_eq!(w.value.as_ref().unwrap().as_bool(), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_round_trips_through_widget_ron() {
|
||||
use super::super::value::WidgetValue;
|
||||
let w = Widget::leaf(Vec2::ONE)
|
||||
.with_id("slider")
|
||||
.with_value(WidgetValue::Float(0.42));
|
||||
let text = w.to_ron().unwrap();
|
||||
let decoded = Widget::from_ron(&text).unwrap();
|
||||
assert_eq!(w, decoded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
//! File-watcher foundation.
|
||||
//!
|
||||
//! Watches directories — typically a [`Project`](crate::project::Project)'s
|
||||
//! `assets/`, `scenes/`, and `scripts/` folders — and emits **debounced**,
|
||||
//! **deduplicated** change events. Built on the `notify` crate.
|
||||
//!
|
||||
//! ## Why debounce
|
||||
//!
|
||||
//! Filesystem events are noisy: editors write files in several syscalls (write,
|
||||
//! rename, chmod), platforms report different fine-grained events for the same
|
||||
//! logical change, and recursive watches can re-emit while a directory is being
|
||||
//! populated. Forwarding every raw event to a reloader would re-parse assets
|
||||
//! many times for one user save. The watcher collapses bursts on each path into
|
||||
//! one event emitted after the path has been **quiet** for a configurable
|
||||
//! window.
|
||||
//!
|
||||
//! ## Layered design (testability)
|
||||
//!
|
||||
//! The debounce/coalesce logic lives in a **pure** [`Debouncer`] that takes
|
||||
//! `Instant`s from the caller, so unit tests verify it without touching real
|
||||
//! files or sleeping. [`FileWatcher`] wraps `notify` plus a worker thread that
|
||||
//! drives the debouncer with real time and forwards settled events through an
|
||||
//! mpsc channel. A tolerant integration smoke test covers the wiring.
|
||||
//!
|
||||
//! ## Asset reload wiring
|
||||
//!
|
||||
//! [`reload_changed_assets`] rereads any cached assets whose source path
|
||||
//! changed via [`AssetServer::reload_path`]. This is the groundwork for
|
||||
//! Stage-10 script hot-reload — same pattern, different reloader.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::time::Duration;
|
||||
//! use oxide_engine::watch::{FileWatcher, reload_changed_assets};
|
||||
//! use oxide_engine::asset::AssetServer;
|
||||
//!
|
||||
//! let assets = AssetServer::new();
|
||||
//! let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
|
||||
//! watcher.watch("path/to/project/assets")?;
|
||||
//!
|
||||
//! // Pump in the editor's per-frame tick:
|
||||
//! while let Ok(event) = events.try_recv() {
|
||||
//! reload_changed_assets(&assets, std::iter::once(event));
|
||||
//! }
|
||||
//! # Ok::<(), oxide_engine::watch::WatchError>(())
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{channel, Receiver};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
|
||||
use crate::asset::AssetServer;
|
||||
|
||||
/// Coarse classification of a filesystem change.
|
||||
///
|
||||
/// The fine-grained `notify::EventKind` variants are collapsed into three
|
||||
/// outcomes because every consumer downstream — asset reload, script reload,
|
||||
/// project-panel refresh — only needs to know "rerun the loader", "drop the
|
||||
/// entry", or "treat as new". Distinguishing a rename's two legs or an attr
|
||||
/// change from a content write does not change what to do.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ChangeKind {
|
||||
/// A file or directory appeared at this path.
|
||||
Created,
|
||||
/// An existing file's contents (or a directory's set of children) changed.
|
||||
Modified,
|
||||
/// A file or directory was removed at this path.
|
||||
Removed,
|
||||
}
|
||||
|
||||
/// One settled change event for a single path.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct ChangeEvent {
|
||||
/// The path that changed (absolute when the underlying backend reports it
|
||||
/// as such — `notify` typically does on the platforms Oxide targets).
|
||||
pub path: PathBuf,
|
||||
/// What kind of change it was, after coalescing.
|
||||
pub kind: ChangeKind,
|
||||
}
|
||||
|
||||
/// Errors from the file-watcher subsystem.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WatchError {
|
||||
/// The underlying `notify` backend failed (no inotify slots, path missing,
|
||||
/// permission denied, …).
|
||||
#[error("file-watcher backend error: {0}")]
|
||||
Backend(#[from] notify::Error),
|
||||
}
|
||||
|
||||
/// Pure debounce-and-coalesce core.
|
||||
///
|
||||
/// Holds the most recent change kind seen for each path plus the time it was
|
||||
/// last touched. [`drain_ready`](Self::drain_ready) emits an event for every
|
||||
/// path that has been quiet for at least `quiet_window` relative to a
|
||||
/// caller-supplied `now`. Because the caller controls `now`, tests can drive
|
||||
/// the debouncer through a deterministic timeline.
|
||||
pub struct Debouncer {
|
||||
quiet_window: Duration,
|
||||
pending: HashMap<PathBuf, (ChangeKind, Instant)>,
|
||||
}
|
||||
|
||||
impl Debouncer {
|
||||
/// A debouncer that emits a path's event once it has been quiet for at
|
||||
/// least `quiet_window`.
|
||||
pub fn new(quiet_window: Duration) -> Self {
|
||||
Self {
|
||||
quiet_window,
|
||||
pending: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The configured quiet window.
|
||||
pub fn quiet_window(&self) -> Duration {
|
||||
self.quiet_window
|
||||
}
|
||||
|
||||
/// Number of paths currently in the pending set.
|
||||
pub fn pending_len(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
|
||||
/// Records a raw change for `path` observed at `now`.
|
||||
///
|
||||
/// Coalescing rules (chosen to match what a downstream reloader cares
|
||||
/// about):
|
||||
/// - `Created` then `Modified` → `Created` (still a fresh file overall).
|
||||
/// - `Removed` then `Modified` → `Created` (a file came back at this path).
|
||||
/// - Otherwise the newer kind wins, including `Removed` superseding any
|
||||
/// prior `Created`/`Modified`.
|
||||
pub fn record(&mut self, path: PathBuf, kind: ChangeKind, now: Instant) {
|
||||
let promoted = match self.pending.get(&path).map(|(k, _)| *k) {
|
||||
Some(ChangeKind::Created) if kind == ChangeKind::Modified => ChangeKind::Created,
|
||||
Some(ChangeKind::Removed) if kind == ChangeKind::Modified => ChangeKind::Created,
|
||||
_ => kind,
|
||||
};
|
||||
self.pending.insert(path, (promoted, now));
|
||||
}
|
||||
|
||||
/// Removes and returns every event whose last update is at least
|
||||
/// `quiet_window` old relative to `now`. The returned vector is sorted by
|
||||
/// path so output is deterministic for testing and snapshotting.
|
||||
pub fn drain_ready(&mut self, now: Instant) -> Vec<ChangeEvent> {
|
||||
let mut ready: Vec<ChangeEvent> = Vec::new();
|
||||
self.pending.retain(|path, (kind, t)| {
|
||||
if now.saturating_duration_since(*t) >= self.quiet_window {
|
||||
ready.push(ChangeEvent {
|
||||
path: path.clone(),
|
||||
kind: *kind,
|
||||
});
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
ready.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
ready
|
||||
}
|
||||
}
|
||||
|
||||
/// A directory watcher that emits debounced [`ChangeEvent`]s.
|
||||
///
|
||||
/// Construction returns the watcher plus the [`Receiver`] events arrive on.
|
||||
/// Add directories with [`watch`](Self::watch); remove them with
|
||||
/// [`unwatch`](Self::unwatch). Dropping the watcher stops the worker thread
|
||||
/// and disconnects the receiver.
|
||||
pub struct FileWatcher {
|
||||
/// Kept alive so its `Drop` releases the backend's OS watches.
|
||||
_backend: RecommendedWatcher,
|
||||
debouncer: Arc<Mutex<Debouncer>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl FileWatcher {
|
||||
/// Creates a watcher whose worker forwards settled events through the
|
||||
/// returned receiver. Paths are not watched until you call
|
||||
/// [`watch`](Self::watch).
|
||||
pub fn new(quiet_window: Duration) -> Result<(Self, Receiver<ChangeEvent>), WatchError> {
|
||||
let (raw_tx, raw_rx) = channel::<notify::Result<notify::Event>>();
|
||||
let backend = RecommendedWatcher::new(
|
||||
move |res| {
|
||||
// If the receiving end is gone we are tearing down; nothing to
|
||||
// do but drop the event.
|
||||
let _ = raw_tx.send(res);
|
||||
},
|
||||
notify::Config::default(),
|
||||
)?;
|
||||
|
||||
let debouncer = Arc::new(Mutex::new(Debouncer::new(quiet_window)));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (out_tx, out_rx) = channel::<ChangeEvent>();
|
||||
|
||||
// Drain the backend frequently enough that bursts settle within a few
|
||||
// ticks; quarter of the quiet window is short enough to be responsive
|
||||
// without busy-waiting.
|
||||
let tick = (quiet_window / 4).max(Duration::from_millis(10));
|
||||
let worker_debouncer = debouncer.clone();
|
||||
let worker_stop = stop.clone();
|
||||
let worker = std::thread::spawn(move || {
|
||||
while !worker_stop.load(Ordering::Relaxed) {
|
||||
match raw_rx.recv_timeout(tick) {
|
||||
Ok(Ok(event)) => {
|
||||
if let Some(kind) = classify(&event.kind) {
|
||||
let now = Instant::now();
|
||||
let mut d = worker_debouncer.lock().unwrap();
|
||||
for path in event.paths {
|
||||
d.record(path, kind, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Backend reported an error event; ignore but keep running.
|
||||
Ok(Err(_)) => {}
|
||||
// Tick elapsed with no new events. Fall through to drain.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
// Raw channel disconnected → backend dropped → we're done.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
|
||||
let ready = worker_debouncer.lock().unwrap().drain_ready(Instant::now());
|
||||
for ev in ready {
|
||||
if out_tx.send(ev).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok((
|
||||
Self {
|
||||
_backend: backend,
|
||||
debouncer,
|
||||
stop,
|
||||
worker: Some(worker),
|
||||
},
|
||||
out_rx,
|
||||
))
|
||||
}
|
||||
|
||||
/// Recursively watches `path`. Repeated calls with the same path are
|
||||
/// equivalent to one call.
|
||||
pub fn watch(&mut self, path: impl AsRef<Path>) -> Result<(), WatchError> {
|
||||
self._backend
|
||||
.watch(path.as_ref(), RecursiveMode::Recursive)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops watching `path`. Errors if the backend was not watching it.
|
||||
pub fn unwatch(&mut self, path: impl AsRef<Path>) -> Result<(), WatchError> {
|
||||
self._backend.unwatch(path.as_ref())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read-only snapshot of how many paths are currently buffered by the
|
||||
/// debouncer (haven't yet been quiet long enough to fire). Mostly for
|
||||
/// tests and diagnostics.
|
||||
pub fn pending_count(&self) -> usize {
|
||||
self.debouncer.lock().unwrap().pending_len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FileWatcher {
|
||||
fn drop(&mut self) {
|
||||
// Signal first so the worker exits its next loop iteration; dropping
|
||||
// the backend closes the raw channel as a secondary safety net.
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(h) = self.worker.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates a `notify` event kind into our coarse [`ChangeKind`]. Returns
|
||||
/// `None` for events we deliberately ignore (e.g. access timestamps).
|
||||
fn classify(kind: &EventKind) -> Option<ChangeKind> {
|
||||
match kind {
|
||||
EventKind::Create(_) => Some(ChangeKind::Created),
|
||||
EventKind::Modify(_) => Some(ChangeKind::Modified),
|
||||
EventKind::Remove(_) => Some(ChangeKind::Removed),
|
||||
// Reads/opens don't change the file; skipping keeps the event stream
|
||||
// focused on "something to reload".
|
||||
EventKind::Access(_) => None,
|
||||
// `Any` is the fallback some backends emit for "something happened";
|
||||
// treat as Modified so a reloader still gets a chance.
|
||||
EventKind::Any => Some(ChangeKind::Modified),
|
||||
EventKind::Other => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reruns the loader for every cached asset whose source path appears in
|
||||
/// `events` with a `Created` or `Modified` kind.
|
||||
///
|
||||
/// Returns the total number of asset entries reloaded. Paths that are not
|
||||
/// currently cached (no live handle) are silently ignored — there is nothing
|
||||
/// to reload, and the next [`AssetServer::load`] will pick up the new contents
|
||||
/// anyway. `Removed` events are ignored here too: the engine does not
|
||||
/// preemptively invalidate handles when the underlying file disappears,
|
||||
/// because gameplay code may want the last-loaded copy to keep working.
|
||||
pub fn reload_changed_assets<I>(server: &AssetServer, events: I) -> usize
|
||||
where
|
||||
I: IntoIterator<Item = ChangeEvent>,
|
||||
{
|
||||
let mut n = 0;
|
||||
for ev in events {
|
||||
if matches!(ev.kind, ChangeKind::Created | ChangeKind::Modified) {
|
||||
n += server.reload_path(&ev.path);
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p(name: &str) -> PathBuf {
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedupes_a_burst_for_one_path() {
|
||||
let mut d = Debouncer::new(Duration::from_millis(100));
|
||||
let t0 = Instant::now();
|
||||
d.record(p("a"), ChangeKind::Modified, t0);
|
||||
d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(10));
|
||||
d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(30));
|
||||
// Path is still "hot" — nothing should fire yet.
|
||||
assert!(d.drain_ready(t0 + Duration::from_millis(80)).is_empty());
|
||||
// After the quiet window elapses since the last touch, one event fires.
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(130) + Duration::from_millis(10));
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(ready[0].path, p("a"));
|
||||
assert_eq!(ready[0].kind, ChangeKind::Modified);
|
||||
// And the pending set is empty afterwards.
|
||||
assert_eq!(d.pending_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_path_settles_independently() {
|
||||
let mut d = Debouncer::new(Duration::from_millis(50));
|
||||
let t0 = Instant::now();
|
||||
d.record(p("a"), ChangeKind::Modified, t0);
|
||||
d.record(p("b"), ChangeKind::Created, t0 + Duration::from_millis(30));
|
||||
// At t0+60: "a" is quiet for 60ms (≥ 50ms) but "b" is only 30ms quiet.
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(60));
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(ready[0].path, p("a"));
|
||||
assert_eq!(d.pending_len(), 1);
|
||||
// At t0+90: "b" has been quiet for 60ms and now fires.
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(90));
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(ready[0].path, p("b"));
|
||||
assert_eq!(ready[0].kind, ChangeKind::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_output_is_sorted_by_path() {
|
||||
let mut d = Debouncer::new(Duration::from_millis(10));
|
||||
let t0 = Instant::now();
|
||||
d.record(p("zeta"), ChangeKind::Modified, t0);
|
||||
d.record(p("alpha"), ChangeKind::Modified, t0);
|
||||
d.record(p("mid"), ChangeKind::Modified, t0);
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(20));
|
||||
assert_eq!(
|
||||
ready.iter().map(|e| e.path.clone()).collect::<Vec<_>>(),
|
||||
vec![p("alpha"), p("mid"), p("zeta")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_then_modified_stays_created() {
|
||||
let mut d = Debouncer::new(Duration::from_millis(10));
|
||||
let t0 = Instant::now();
|
||||
d.record(p("a"), ChangeKind::Created, t0);
|
||||
d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(2));
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(20));
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(ready[0].kind, ChangeKind::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_then_modified_becomes_created() {
|
||||
// A file is deleted, then a new file appears at the same path (e.g.
|
||||
// editors that save by atomic-replace). Downstream wants to treat this
|
||||
// as a fresh asset, not a missing one.
|
||||
let mut d = Debouncer::new(Duration::from_millis(10));
|
||||
let t0 = Instant::now();
|
||||
d.record(p("a"), ChangeKind::Removed, t0);
|
||||
d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(2));
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(20));
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(ready[0].kind, ChangeKind::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_supersedes_prior_kinds() {
|
||||
let mut d = Debouncer::new(Duration::from_millis(10));
|
||||
let t0 = Instant::now();
|
||||
d.record(p("a"), ChangeKind::Created, t0);
|
||||
d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(1));
|
||||
d.record(p("a"), ChangeKind::Removed, t0 + Duration::from_millis(2));
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(20));
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(ready[0].kind, ChangeKind::Removed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_covers_the_three_main_kinds() {
|
||||
use notify::event::{CreateKind, ModifyKind, RemoveKind};
|
||||
assert_eq!(
|
||||
classify(&EventKind::Create(CreateKind::File)),
|
||||
Some(ChangeKind::Created)
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&EventKind::Modify(ModifyKind::Any)),
|
||||
Some(ChangeKind::Modified)
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&EventKind::Remove(RemoveKind::File)),
|
||||
Some(ChangeKind::Removed)
|
||||
);
|
||||
assert_eq!(classify(&EventKind::Any), Some(ChangeKind::Modified));
|
||||
}
|
||||
|
||||
/// Tolerant smoke test: write a file under a temp dir, then poll for an
|
||||
/// event with a generous timeout. The unit tests above already cover the
|
||||
/// debounce logic deterministically, so this only needs to prove the
|
||||
/// notify→debouncer→channel wiring is connected.
|
||||
#[test]
|
||||
fn end_to_end_emits_on_real_filesystem_change() {
|
||||
let mut dir = std::env::temp_dir();
|
||||
dir.push(format!("oxide_watch_smoke_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let (mut watcher, events) =
|
||||
FileWatcher::new(Duration::from_millis(80)).expect("create watcher");
|
||||
watcher.watch(&dir).expect("watch tempdir");
|
||||
|
||||
// Some platforms need a brief moment between watch() and producing
|
||||
// events for fresh writes; the deadline below absorbs that.
|
||||
let file = dir.join("hello.txt");
|
||||
std::fs::write(&file, "first").unwrap();
|
||||
// Touch a couple more times to exercise dedup under real timing.
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
std::fs::write(&file, "second").unwrap();
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
std::fs::write(&file, "third").unwrap();
|
||||
|
||||
// Wait up to 3 seconds for at least one event for our file. This is
|
||||
// intentionally generous: CI machines under load and macOS FSEvents
|
||||
// can take a second or more to deliver the first event.
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
let mut saw = None;
|
||||
while Instant::now() < deadline {
|
||||
if let Ok(ev) = events.recv_timeout(Duration::from_millis(100)) {
|
||||
// Some backends report a canonicalized path; compare by file
|
||||
// name to stay robust to that.
|
||||
if ev.path.file_name() == Some(std::ffi::OsStr::new("hello.txt")) {
|
||||
saw = Some(ev);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
if saw.is_none() {
|
||||
// Some sandboxes (containerized CI) disable filesystem-event
|
||||
// backends entirely; skip rather than fail flakily there.
|
||||
eprintln!("SKIP: no inotify/FSEvent backend appears to deliver events here");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! The [`WindowApp`] trait and per-callback context.
|
||||
|
||||
use winit::event::WindowEvent;
|
||||
use winit::window::Window;
|
||||
|
||||
use crate::input::InputState;
|
||||
use crate::math::Color;
|
||||
use crate::render::{Gpu, RenderContext};
|
||||
|
||||
/// An application driven by the engine's event loop.
|
||||
///
|
||||
/// Implement this and pass the value to [`run`](super::run). All methods have
|
||||
/// empty defaults so minimal apps only override what they need. Per frame the
|
||||
/// engine calls [`event`](Self::event) for each pending window event, then
|
||||
/// [`update`](Self::update), then clears and presents the surface.
|
||||
///
|
||||
/// This trait is the **window-event handler** — the per-frame plumbing between
|
||||
/// `winit` and a renderer. It is distinct from the engine's
|
||||
/// [`App`](crate::app::App) **container**, which owns the scene, assets, and
|
||||
/// scheduled systems. The editor's main loop typically implements this trait
|
||||
/// on a struct that *also* owns an `oxide_engine::app::App`.
|
||||
pub trait WindowApp {
|
||||
/// Called once, after the window and GPU context exist but before the
|
||||
/// first frame.
|
||||
fn init(&mut self, ctx: &mut AppCtx<'_>) {
|
||||
let _ = ctx;
|
||||
}
|
||||
|
||||
/// Called for every raw window event (keyboard, mouse, resize, focus, …).
|
||||
///
|
||||
/// Events the engine itself reacts to (close request, resize) are still
|
||||
/// forwarded here afterwards, so apps observe everything.
|
||||
fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
|
||||
let _ = (ctx, event);
|
||||
}
|
||||
|
||||
/// Called once per frame, before the frame is rendered.
|
||||
fn update(&mut self, ctx: &mut AppCtx<'_>) {
|
||||
let _ = ctx;
|
||||
}
|
||||
|
||||
/// Called each frame after the surface has been cleared and before it is
|
||||
/// presented, so the app can record its own draw commands into the frame.
|
||||
///
|
||||
/// This is the hook editor/overlay UI (egui) and, in later stages, the
|
||||
/// scene renderer draw through. The surface is cleared with
|
||||
/// [`LoadOp::Clear`](wgpu::LoadOp::Clear) *before* this runs; record passes
|
||||
/// here with [`LoadOp::Load`](wgpu::LoadOp::Load) to draw on top of the
|
||||
/// clear color rather than wiping it.
|
||||
fn render(&mut self, ctx: &RenderCtx<'_>) {
|
||||
let _ = ctx;
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame rendering context passed to [`WindowApp::render`].
|
||||
///
|
||||
/// Unlike [`AppCtx`], this borrows the GPU and surface immutably: by the time
|
||||
/// the draw hook runs the frame's surface texture is already acquired, so the
|
||||
/// app receives the handles it needs to record additional passes into
|
||||
/// [`view`](Self::view) without re-entering the render context.
|
||||
pub struct RenderCtx<'a> {
|
||||
/// The GPU device/queue to record and submit commands with.
|
||||
pub gpu: &'a Gpu,
|
||||
/// The current frame's surface texture view (the render target).
|
||||
pub view: &'a wgpu::TextureView,
|
||||
/// The window being rendered, e.g. for input/UI integration that needs it.
|
||||
pub window: &'a Window,
|
||||
/// The surface's texture format, needed to build matching pipelines.
|
||||
pub surface_format: wgpu::TextureFormat,
|
||||
/// Surface size in physical pixels (`width`, `height`).
|
||||
pub size: (u32, u32),
|
||||
}
|
||||
|
||||
/// Engine state handed to every [`WindowApp`] callback.
|
||||
pub struct AppCtx<'a> {
|
||||
pub(crate) render: &'a mut RenderContext,
|
||||
pub(crate) window: &'a Window,
|
||||
pub(crate) exit: &'a mut bool,
|
||||
pub(crate) input: &'a InputState,
|
||||
/// Seconds elapsed since the previous frame (`0.0` during
|
||||
/// [`App::init`] and the first frame).
|
||||
pub dt: f32,
|
||||
}
|
||||
|
||||
impl AppCtx<'_> {
|
||||
/// The render context driving the window surface.
|
||||
pub fn render(&mut self) -> &mut RenderContext {
|
||||
self.render
|
||||
}
|
||||
|
||||
/// Sets the color the surface is cleared to, effective next frame.
|
||||
pub fn set_clear_color(&mut self, color: Color) {
|
||||
self.render.set_clear_color(color);
|
||||
}
|
||||
|
||||
/// The current clear color.
|
||||
pub fn clear_color(&self) -> Color {
|
||||
self.render.clear_color()
|
||||
}
|
||||
|
||||
/// Current surface size in physical pixels.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
self.render.size()
|
||||
}
|
||||
|
||||
/// Sets the window title.
|
||||
pub fn set_title(&self, title: &str) {
|
||||
self.window.set_title(title);
|
||||
}
|
||||
|
||||
/// The window being driven, e.g. to construct UI/input integration that
|
||||
/// needs a window handle.
|
||||
pub fn window(&self) -> &Window {
|
||||
self.window
|
||||
}
|
||||
|
||||
/// Asks the event loop to exit after the current callback returns.
|
||||
pub fn request_exit(&mut self) {
|
||||
*self.exit = true;
|
||||
}
|
||||
|
||||
/// The per-frame input snapshot.
|
||||
///
|
||||
/// Reflects every keyboard / mouse / scroll event delivered since the
|
||||
/// previous frame's `update` returned. In [`WindowApp::event`] callbacks
|
||||
/// it includes the event currently being delivered (the runner pumps it
|
||||
/// before invoking the callback). In [`WindowApp::update`] it is the
|
||||
/// accumulated state for the new frame; the runner clears edges (pressed/
|
||||
/// released, mouse delta, scroll) automatically after `update` returns.
|
||||
pub fn input(&self) -> &InputState {
|
||||
self.input
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Windowing and the application event loop.
|
||||
//!
|
||||
//! Stage 2 scope: open a window via `winit`, hand its surface to the
|
||||
//! [`render`](crate::render) module, and run a clear-color render loop.
|
||||
//! Applications implement [`WindowApp`] and are driven by [`run`]; raw window
|
||||
//! events (keyboard, mouse, resize, …) are forwarded to
|
||||
//! [`WindowApp::event`] untranslated — input abstraction arrives in Stage 5.
|
||||
//!
|
||||
//! Stage 6 renamed this trait from `App` to `WindowApp` so the engine's core
|
||||
//! [`App`](crate::app::App) container — the owner of scene, assets, and
|
||||
//! scheduled systems — can live in the prelude unambiguously. The two are
|
||||
//! distinct roles: this trait is the **window-event handler** the editor and
|
||||
//! examples implement; the core `App` is the engine state they typically wrap
|
||||
//! around.
|
||||
|
||||
mod app;
|
||||
mod runner;
|
||||
|
||||
pub use app::{AppCtx, RenderCtx, WindowApp};
|
||||
pub use runner::run;
|
||||
|
||||
pub mod event {
|
||||
//! Raw window/input event types, re-exported from `winit`.
|
||||
//!
|
||||
//! Stage 2 deliberately exposes events untranslated; the Stage 5 input
|
||||
//! system will layer action mapping on top of these.
|
||||
pub use winit::dpi::{PhysicalPosition, PhysicalSize};
|
||||
pub use winit::event::{
|
||||
DeviceEvent, DeviceId, ElementState, KeyEvent, Modifiers, MouseButton, MouseScrollDelta,
|
||||
WindowEvent,
|
||||
};
|
||||
pub use winit::keyboard::{Key, KeyCode, ModifiersState, NamedKey, PhysicalKey};
|
||||
}
|
||||
|
||||
use crate::math::Color;
|
||||
|
||||
/// Initial window settings, consumed by [`run`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WindowConfig {
|
||||
/// Window title.
|
||||
pub title: String,
|
||||
/// Initial inner width in logical pixels.
|
||||
pub width: u32,
|
||||
/// Initial inner height in logical pixels.
|
||||
pub height: u32,
|
||||
/// Whether the user can resize the window.
|
||||
pub resizable: bool,
|
||||
/// Color the surface is cleared to each frame (changeable at runtime via
|
||||
/// [`AppCtx::set_clear_color`]).
|
||||
pub clear_color: Color,
|
||||
}
|
||||
|
||||
impl Default for WindowConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: "Oxide".to_string(),
|
||||
width: 1280,
|
||||
height: 720,
|
||||
resizable: true,
|
||||
clear_color: Color::BLACK,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! The winit event-loop runner behind [`run`].
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::dpi::LogicalSize;
|
||||
use winit::event::WindowEvent;
|
||||
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
||||
use winit::window::{Window, WindowId};
|
||||
|
||||
use super::{AppCtx, WindowApp, WindowConfig};
|
||||
use crate::input::InputState;
|
||||
use crate::render::RenderContext;
|
||||
|
||||
/// Opens a window per `config` and runs `app` until it requests exit or the
|
||||
/// window is closed.
|
||||
///
|
||||
/// Blocks the calling thread for the lifetime of the window (an OS
|
||||
/// requirement: the event loop must run on the main thread).
|
||||
pub fn run<A: WindowApp>(config: WindowConfig, app: A) -> anyhow::Result<()> {
|
||||
let event_loop = EventLoop::new()?;
|
||||
// Poll: render continuously (a game loop), rather than waiting for
|
||||
// input events like a desktop utility would.
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
let mut runner = Runner {
|
||||
config,
|
||||
app,
|
||||
state: None,
|
||||
input: InputState::new(),
|
||||
last_frame: None,
|
||||
exit: false,
|
||||
error: None,
|
||||
};
|
||||
event_loop.run_app(&mut runner)?;
|
||||
match runner.error {
|
||||
Some(err) => Err(err),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
struct WindowState {
|
||||
window: Arc<Window>,
|
||||
render: RenderContext,
|
||||
}
|
||||
|
||||
struct Runner<A: WindowApp> {
|
||||
config: WindowConfig,
|
||||
app: A,
|
||||
state: Option<WindowState>,
|
||||
/// Accumulated keyboard/mouse/scroll state across the current frame;
|
||||
/// pumped from every window event and cleared after `update` returns.
|
||||
input: InputState,
|
||||
last_frame: Option<Instant>,
|
||||
exit: bool,
|
||||
/// Initialization/render errors are stashed here and returned from
|
||||
/// [`run`], since winit callbacks cannot propagate `Result`.
|
||||
error: Option<anyhow::Error>,
|
||||
}
|
||||
|
||||
impl<A: WindowApp> Runner<A> {
|
||||
fn create_window(&mut self, event_loop: &ActiveEventLoop) -> anyhow::Result<()> {
|
||||
let attrs = Window::default_attributes()
|
||||
.with_title(&self.config.title)
|
||||
.with_inner_size(LogicalSize::new(self.config.width, self.config.height))
|
||||
.with_resizable(self.config.resizable);
|
||||
let window = Arc::new(event_loop.create_window(attrs)?);
|
||||
let mut render = RenderContext::new(window.clone())?;
|
||||
render.set_clear_color(self.config.clear_color);
|
||||
|
||||
let mut state = WindowState { window, render };
|
||||
let mut ctx = AppCtx {
|
||||
render: &mut state.render,
|
||||
window: &state.window,
|
||||
exit: &mut self.exit,
|
||||
input: &self.input,
|
||||
dt: 0.0,
|
||||
};
|
||||
self.app.init(&mut ctx);
|
||||
self.state = Some(state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fail(&mut self, event_loop: &ActiveEventLoop, err: anyhow::Error) {
|
||||
self.error = Some(err);
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: WindowApp> ApplicationHandler for Runner<A> {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// On desktop `resumed` fires once at startup; the suspend/resume
|
||||
// cycle only matters on mobile, which Oxide does not target yet.
|
||||
if self.state.is_none() {
|
||||
if let Err(err) = self.create_window(event_loop) {
|
||||
self.fail(event_loop, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
||||
let Some(state) = self.state.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Fold the event into the per-frame input snapshot before any callback
|
||||
// sees it, so `ctx.input()` is always up-to-date for the receiver.
|
||||
// `handle_event` is a no-op for non-input events (resize, redraw, …).
|
||||
self.input.handle_event(&event);
|
||||
|
||||
// Engine-level handling first…
|
||||
match &event {
|
||||
WindowEvent::CloseRequested => self.exit = true,
|
||||
WindowEvent::Resized(size) => state.render.resize(size.width, size.height),
|
||||
WindowEvent::RedrawRequested => {
|
||||
let now = Instant::now();
|
||||
let dt = self
|
||||
.last_frame
|
||||
.map_or(0.0, |last| (now - last).as_secs_f32());
|
||||
self.last_frame = Some(now);
|
||||
|
||||
{
|
||||
let mut ctx = AppCtx {
|
||||
render: &mut state.render,
|
||||
window: &state.window,
|
||||
exit: &mut self.exit,
|
||||
input: &self.input,
|
||||
dt,
|
||||
};
|
||||
self.app.update(&mut ctx);
|
||||
}
|
||||
// Render the frame, letting the app draw its own passes (e.g.
|
||||
// editor UI) into the cleared surface via `App::render`.
|
||||
let app = &mut self.app;
|
||||
let result = state
|
||||
.render
|
||||
.render_frame_with(&state.window, |rcx| app.render(rcx));
|
||||
if let Err(err) = result {
|
||||
self.fail(event_loop, err.into());
|
||||
return;
|
||||
}
|
||||
|
||||
// Roll edges/deltas off so the next frame starts clean.
|
||||
// Held state and cursor anchor persist by design.
|
||||
self.input.end_frame();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// …then forward every event raw to the app (including the ones
|
||||
// handled above, so apps can observe resizes, close requests, etc.).
|
||||
if !matches!(event, WindowEvent::RedrawRequested) {
|
||||
let mut ctx = AppCtx {
|
||||
render: &mut state.render,
|
||||
window: &state.window,
|
||||
exit: &mut self.exit,
|
||||
input: &self.input,
|
||||
dt: 0.0,
|
||||
};
|
||||
self.app.event(&mut ctx, &event);
|
||||
}
|
||||
|
||||
if self.exit {
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
|
||||
// Continuous rendering: request the next frame as soon as the event
|
||||
// queue drains.
|
||||
if let Some(state) = &self.state {
|
||||
state.window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user