Compare commits
8 Commits
dev
...
775ba8a8d2
| Author | SHA1 | Date | |
|---|---|---|---|
| 775ba8a8d2 | |||
| c47efa876f | |||
| 48003ffea4 | |||
| d08bee1361 | |||
| 4f1e9a48d7 | |||
| d6cb9947b2 | |||
| e88a7e2bb1 | |||
| 9eead719b0 |
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# Rust / Cargo
|
||||||
|
/target/
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# Editor and IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Build artifacts and packages
|
||||||
|
*.deb
|
||||||
|
*.rpm
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Personal scratch notes (tracked docs live in docs/ and are NOT ignored)
|
||||||
|
*.local.md
|
||||||
|
/scratch/
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Oxide Engine — Claude Code Context
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**Oxide** is a general-purpose 3D game engine written in Rust. It is built to make **any** 3D game,
|
||||||
|
scaling from stylized low-poly (e.g. with a VCR/CRT post filter) to realistic graphics, and to **ship
|
||||||
|
only what each game uses**. It ships with an **in-engine editor** (`oxide-editor`) developed alongside
|
||||||
|
the engine and gaining capabilities at each stage.
|
||||||
|
|
||||||
|
The work is split into two phases (see `PLAN.md`):
|
||||||
|
- **Phase 1 — general-purpose engine (Stages 0–16):** everything needed to build *and export* any
|
||||||
|
game. Stage 16 (game export to Linux + Windows) is the milestone.
|
||||||
|
- **Phase 2 — built-in modules (Stages 17+):** optional, self-contained capabilities (ray-traced
|
||||||
|
audio, developer console, procedural toolkit, terrain, open world, pathfinding/AI, water), each a
|
||||||
|
feature-gated **module** built on Phase-1 systems.
|
||||||
|
|
||||||
|
Simulation, open world, and procedural generation are **capabilities the engine supports through
|
||||||
|
modules**, not design drivers. The guiding idea is **build tools, not games**.
|
||||||
|
|
||||||
|
### Core Feature Goals (Phase 1 — the general-purpose engine)
|
||||||
|
- Scene graph and entity management
|
||||||
|
- **Engine core framework**: a **module/plugin system** (compile-time feature-gated crates + a
|
||||||
|
runtime `Module` trait + `rhai` script modules), system scheduling, a **layers & tags** system
|
||||||
|
(`LayerMask` for physics/render/query filtering + gameplay tags), a central **asset server** with
|
||||||
|
handles, a **reflection/type registry**, and a **data-driven render pass pipeline**
|
||||||
|
- **Editor framework & project system**: top menu, dockable panels, undo/redo command stack, a
|
||||||
|
**module→editor extension API** (modules add panels/menus/tools/inspectors), a **settings/
|
||||||
|
preferences framework** (engine + editor + per-module settings, enable/disable modules), and a
|
||||||
|
**project system** (create/open/save projects, file watching)
|
||||||
|
- **Comprehensive input mapping**: map any key to press/up/down, *and* named remappable actions
|
||||||
|
(e.g. a `Jump` action defaulting to `Space` that game code references by name while players rebind
|
||||||
|
the physical key in settings)
|
||||||
|
- **Comprehensive in-game UI system**: widgets, layout, theming, text, and input routing that ship
|
||||||
|
inside exported games (distinct from the editor's `egui`); UI documents are serializable and
|
||||||
|
dual-editable, authored in a visual editor canvas
|
||||||
|
- **Comprehensive** physics simulation (`rapier3d`: colliders, joints, scene queries, sensors,
|
||||||
|
layer-filtered collision/triggers, kinematic character controller — not a thin wrapper)
|
||||||
|
- **Scripting + live reload + in-editor terminal**: game scripts are watched and hot-reloaded; the
|
||||||
|
editor hosts a terminal that can run tools and AI agents which edit game code live
|
||||||
|
- Animation system · particle engine · shader support (simple → advanced, scalable fidelity)
|
||||||
|
- **Standard audio**: mixer/spatial system (ray-traced spatial sound is a Phase-2 module)
|
||||||
|
- **Dual-editable types**: every engine object/component type (`Transform`, `Script`, materials,
|
||||||
|
colliders, …) is editable from both the editor UI and from scripts/code via one reflected/
|
||||||
|
serializable representation, so any editor or AI agent can author game code and data in real time
|
||||||
|
- Built-in content kit (prototyping primitives, shaders, character controller) for fast starts
|
||||||
|
- **Game export** to standalone Linux + Windows binaries (ships only the modules a project uses)
|
||||||
|
- **In-engine editor** (first-class; not an afterthought)
|
||||||
|
|
||||||
|
### Built-in Modules (Phase 2 — optional, feature-gated)
|
||||||
|
- Ray-traced spatial audio (wave propagation, occlusion, reverb)
|
||||||
|
- Developer console & cheats (drop-in dev interface; compiled out of release)
|
||||||
|
- Procedural toolkit (noise + composable modifier stack — tools, not a fixed world generator)
|
||||||
|
- Terrain system (generate, sculpt, paint splat layers, scatter foliage/objects)
|
||||||
|
- Open world support (streaming, LOD, chunking)
|
||||||
|
- Pathfinding & NPC AI (navmesh, agents, behavior trees/state machines, perception)
|
||||||
|
- Water (rendering + buoyancy/swim/flow mechanics)
|
||||||
|
|
||||||
|
Anyone can write a module; each ships docs for **both** using it **and** authoring one.
|
||||||
|
|
||||||
|
### Platform & Targets
|
||||||
|
- **Linux is the primary platform, on both Wayland and Xorg (X11)** — keep both backends working at
|
||||||
|
every stage (`winit` `wayland` + `x11` features).
|
||||||
|
- **Windows support is added later**, once the engine is substantial; avoid Linux-only assumptions.
|
||||||
|
- **Game export targets both Linux and Windows** standalone binaries (editor runs on Linux and can
|
||||||
|
cross-export). See PLAN.md Stage 16.
|
||||||
|
|
||||||
|
## Development Philosophy
|
||||||
|
|
||||||
|
- Build in stages; each stage must be tested and stable before the next begins
|
||||||
|
- **Build tools, not games** — ship composable building blocks; genre-specific behavior lives in
|
||||||
|
game code or optional modules
|
||||||
|
- **Ship only what's used** — subsystems are feature-gated modules; an exported game compiles in only
|
||||||
|
the modules it registers
|
||||||
|
- **Scalable fidelity** — the data-driven render pass pipeline lets a project run anything from a flat
|
||||||
|
low-poly/stylized look to a full realistic stack, paying only for the passes it enables
|
||||||
|
- **Modules are the primary extension point** — a module registers engine logic *and* editor UI *and*
|
||||||
|
its own settings through one documented API; anyone (including AI agents) can write one
|
||||||
|
- Every system should be composable and independently usable
|
||||||
|
- Prefer correctness and clarity over premature optimization
|
||||||
|
- Keep public APIs minimal — internal complexity is fine, external surface should be clean
|
||||||
|
- No feature creep between stages; additions go into the backlog for later stages
|
||||||
|
- The editor (`oxide-editor`) grows with the engine — each stage adds editor support for new systems
|
||||||
|
through the Stage-6 editor framework (panels, menus, undo stack, settings pages)
|
||||||
|
|
||||||
|
## Documentation & File Maintenance
|
||||||
|
|
||||||
|
- **Always update** `CLAUDE.md`, `PLAN.md`, `README.md`, and `.gitignore` when project rules, goals, stage definitions, or project structure changes
|
||||||
|
- `PLAN.md` is the authoritative roadmap — keep it accurate and up-to-date
|
||||||
|
- `README.md` must reflect the current build/install instructions and feature list at all times. Keep it a **short overview** — detailed documentation belongs in `docs/`, not the README
|
||||||
|
- `.gitignore` must be updated whenever new tools, output formats, or file types are introduced that should not be tracked (e.g. new build targets, generated files, editor temp files)
|
||||||
|
- Do not let these files become stale after structural or process changes
|
||||||
|
|
||||||
|
### Full documentation in `docs/`
|
||||||
|
|
||||||
|
- The `docs/` directory holds the full documentation of the engine — both **usage** (how to call each system) and **inner workings** (how/why it works). The project is too large to fit this in `README.md`.
|
||||||
|
- **Write documentation as you work, not after.** A stage is not complete until its systems are documented under `docs/`.
|
||||||
|
- One topic per file; link between files rather than duplicating. `docs/README.md` is the documentation index — add new docs to it.
|
||||||
|
- When an API changes, update the affected doc and its code snippets **in the same change** so docs never drift from the code.
|
||||||
|
- Current docs: `architecture.md`, `conventions.md`, `getting-started.md`, `development.md`, and per-system references (e.g. `math.md`, `windowing.md`, `render-context.md`, `scene.md`).
|
||||||
|
|
||||||
|
## Packaging, Installation & Export
|
||||||
|
|
||||||
|
- The project must be compilable to a Linux installable package
|
||||||
|
- `install.sh` at the repo root builds in release mode and installs the editor binary plus assets to the system (`/usr/local` by default, overridable via `PREFIX`)
|
||||||
|
- Keep `install.sh` updated whenever new binaries or assets are added
|
||||||
|
- The installed binary name is `oxide-editor`
|
||||||
|
- The editor must run on Linux under **both Wayland and Xorg**
|
||||||
|
- A later stage adds a **Windows build** of the editor/engine and **game export** to standalone
|
||||||
|
Linux *and* Windows binaries (the exported game links the engine runtime without the editor) — see
|
||||||
|
PLAN.md Stage 16; keep packaging docs current when that lands
|
||||||
|
|
||||||
|
## Language & Tooling
|
||||||
|
|
||||||
|
- Language: Rust (stable toolchain)
|
||||||
|
- Build: Cargo workspace (`engine/`, `editor/`, `examples/`, `tests/`)
|
||||||
|
- Graphics: `wgpu` (portability across Vulkan, Metal, DX12)
|
||||||
|
- Physics: `rapier3d`
|
||||||
|
- Math: `glam`
|
||||||
|
- ECS: `hecs` (preferred lightweight approach)
|
||||||
|
- Editor UI: `egui` (integrated into `oxide-editor`)
|
||||||
|
- Scripting: `rhai` (preferred — embeddable, sandboxed); file watching via `notify` for live reload
|
||||||
|
- Audio: standard system via `kira`/`rodio`; ray-traced model built on the engine's own ray casts
|
||||||
|
- Windowing backends: `winit` with both `wayland` and `x11` enabled (Linux); Win32 later
|
||||||
|
|
||||||
|
## Repository
|
||||||
|
|
||||||
|
- Remote: `https://git.houmeres.sk/Houmeres/Oxide.git`
|
||||||
|
- Local: `/home/homer/Oxide`
|
||||||
|
|
||||||
|
## Git Workflow & Branching
|
||||||
|
|
||||||
|
Two long-lived branches: **`dev`** (integration) and **`main`** (stable). Full
|
||||||
|
detail in `docs/development.md`; the rules Claude follows:
|
||||||
|
|
||||||
|
- **Auto-commit and push to `dev`** every new piece of work that can be **fully
|
||||||
|
verified automatically** (it builds and its unit/integration/fuzz tests and
|
||||||
|
benchmarks pass). Do this as soon as the work is complete and green — no need
|
||||||
|
to ask first for these.
|
||||||
|
- **Manual-test gate before `main`.** If a change cannot be fully verified by
|
||||||
|
automated tests — anything involving the GUI, rendering, audio, input feel, or
|
||||||
|
otherwise needing a human to run the engine and observe it — push it to `dev`
|
||||||
|
only, then ask the maintainer to test it. Promote to `main` **only after the
|
||||||
|
maintainer explicitly approves** it works.
|
||||||
|
- **Fully-automated changes may go to both `dev` and `main` together**, because
|
||||||
|
the passing automated suite is the sign-off (e.g. pure-logic modules like the
|
||||||
|
math system).
|
||||||
|
- Decision rule: *"Can a test prove this works without a human looking at it?"*
|
||||||
|
Yes → eligible for `main`. No → stop at `dev` and request manual testing. When
|
||||||
|
in doubt, treat it as needing manual testing.
|
||||||
|
- Always run the local gate before committing:
|
||||||
|
`cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test`.
|
||||||
|
- End AI-assisted commit messages with the Claude co-author trailer.
|
||||||
|
|
||||||
|
## Working with Claude
|
||||||
|
|
||||||
|
- Read `PLAN.md` for the full staged roadmap before starting any new stage
|
||||||
|
- Each stage has defined deliverables and test criteria — do not skip testing phases
|
||||||
|
- When implementing a system, prefer small focused modules over large monolithic files
|
||||||
|
- Breaking changes between stages are acceptable; backward compatibility is not a goal during early stages
|
||||||
|
- After completing work that changes project structure, goals, or process, update `CLAUDE.md`, `PLAN.md`, and `README.md` before considering the task done
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"engine",
|
||||||
|
"engine-derive",
|
||||||
|
"physics",
|
||||||
|
"script",
|
||||||
|
"editor",
|
||||||
|
"examples",
|
||||||
|
"tests",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Jaroslav Beneš"]
|
||||||
|
license = "MIT"
|
||||||
|
rust-version = "1.75"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# Math
|
||||||
|
glam = { version = "0.28", features = ["serde"] }
|
||||||
|
|
||||||
|
# ECS
|
||||||
|
hecs = "0.10"
|
||||||
|
|
||||||
|
# Windowing & graphics
|
||||||
|
# Linux is the primary target on BOTH Wayland and Xorg (X11): keep both winit
|
||||||
|
# backends explicitly enabled so neither can be dropped by a default-feature
|
||||||
|
# change. (On by default today; listing them makes the contract explicit —
|
||||||
|
# see PLAN.md "Platform & Target Strategy".)
|
||||||
|
winit = { version = "0.30", features = ["x11", "wayland", "serde"] }
|
||||||
|
wgpu = "29"
|
||||||
|
pollster = "0.4"
|
||||||
|
# Plain-old-data casting for GPU vertex/uniform buffers.
|
||||||
|
bytemuck = { version = "1", features = ["derive"] }
|
||||||
|
# glTF import (static meshes). `utils` enables the attribute reader helpers.
|
||||||
|
gltf = { version = "1.4", features = ["utils"] }
|
||||||
|
|
||||||
|
# TrueType / OpenType font parsing + glyph outline rasterization for the
|
||||||
|
# Stage-8 in-game UI text system. Chosen over `fontdue` for its minimal
|
||||||
|
# scope (parsing + rasterization only) — the engine writes its own atlas,
|
||||||
|
# layout, wrapping, and alignment on top, which keeps the door open for
|
||||||
|
# richer text features in later pieces (editor caret, rich markup, SDF).
|
||||||
|
ab_glyph = "0.2"
|
||||||
|
|
||||||
|
# Editor UI (egui — integrated into oxide-editor only)
|
||||||
|
# Native file/folder dialogs (New/Open Project). The default `xdg-portal`
|
||||||
|
# backend is pure Rust and talks to xdg-desktop-portal over D-Bus, so one
|
||||||
|
# build serves both Wayland and X11 with no GTK link-time dependency.
|
||||||
|
rfd = "0.15"
|
||||||
|
egui = "0.34"
|
||||||
|
egui-wgpu = "0.34"
|
||||||
|
egui-winit = "0.34"
|
||||||
|
egui_dock = "0.19"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log = "0.4"
|
||||||
|
env_logger = "0.11"
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
anyhow = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
ron = "0.8"
|
||||||
|
|
||||||
|
# Proc-macro toolkit for `oxide-engine-derive` (the `#[derive(Reflect)]` macro
|
||||||
|
# behind the reflection-driven editor inspector — Stage 8.5).
|
||||||
|
syn = { version = "2", features = ["full"] }
|
||||||
|
quote = "1"
|
||||||
|
proc-macro2 = "1"
|
||||||
|
|
||||||
|
# Filesystem change events (Stage 6 file-watcher foundation; Stage 10 hot-reload).
|
||||||
|
notify = "8"
|
||||||
|
|
||||||
|
# Benchmarking
|
||||||
|
criterion = "0.5"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
+291
@@ -0,0 +1,291 @@
|
|||||||
|
# Oxide — Session Handoff
|
||||||
|
|
||||||
|
Drop this file into a new session and say "continue from HANDOFF.md". It
|
||||||
|
captures where we are, how we work, and exactly what to do next. Authoritative
|
||||||
|
roadmap is `PLAN.md`; project rules are `CLAUDE.md`; this is the "current
|
||||||
|
state + how to continue" snapshot.
|
||||||
|
|
||||||
|
_Last updated: 2026-07-10. **`dev` is 4 GUI commits ahead of `main` — the whole
|
||||||
|
Stage-10 editor-UX batch is DONE and on `dev`, awaiting the maintainer's
|
||||||
|
eye-check** (New Script button, open-script-in-editor + External Editor
|
||||||
|
preference, native rfd folder picker on New/Open Project, Unity-style file
|
||||||
|
explorer in the Project panel). Pure-logic groundwork (AssetDatabase
|
||||||
|
uid-preserving file ops `move_asset`/`move_folder`/`delete_asset`, whole-tree
|
||||||
|
`scan`, script template/creation helpers, Rust-1.97 lint fixes) is already on
|
||||||
|
`main`. After the maintainer approves the batch:
|
||||||
|
`git checkout main -q && git merge --ff-only dev -q && git push origin main && git checkout dev -q`.
|
||||||
|
That closes Stage 10 (parked: editor-authored joint components need a
|
||||||
|
serializable entity-reference type). Next up per PLAN.md: Stage 11._
|
||||||
|
|
||||||
|
_Older status (2026-06-17): **Stage 9 — Physics
|
||||||
|
is ✅ COMPLETE on `main`.** **Stage 10 — Scripting, Live Reload & Editor Terminal:
|
||||||
|
core ✅ on `main`** (all eye-checked & approved 2026-06-17 in the `untitled`
|
||||||
|
project): the `oxide-script` crate (`Script` + `.rhai` loader + sandboxed
|
||||||
|
`ScriptEngine`), the `init`/`update(dt)` lifecycle via `ScriptHost`, the
|
||||||
|
`Vec3`/transform engine API, live reload (`examples/script_spin`), editor
|
||||||
|
integration (`Script` addable; play-loop shares the editor
|
||||||
|
`AssetServer`/`AssetDatabase` so a live `.rhai` edit updates a *playing* scene),
|
||||||
|
the **Console** (captures the `log` stream — script `print`/errors), a **command
|
||||||
|
terminal** (`$` → `sh -c`), and an **interactive PTY terminal** (`portable-pty` +
|
||||||
|
`vt100`; tabbed, auto-closes a tab when its program exits, Tab/arrows/Esc routed
|
||||||
|
to the program — runs shells / TUIs / `claude`), and the **richer script API**
|
||||||
|
(scripts now `spawn_entity`/`despawn` and `add_component`/`set_component`/
|
||||||
|
`remove_component` on any entity via RON through the reflection registry —
|
||||||
|
closing the last PLAN round-trip criterion; pure-logic + headless tests → on
|
||||||
|
`main`). **What's left in Stage 10:** the maintainer's editor-UX batch —
|
||||||
|
Unity-style file explorer, native New/Open-Project dialog (typing the path by
|
||||||
|
hand is too hard), a **New Script** button on the `Script` inspector,
|
||||||
|
open-script-in-editor. See §5 to start. Parked, not blockers: editor-authored
|
||||||
|
joint **components** need a serializable entity-reference type._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Where we are
|
||||||
|
|
||||||
|
- **Phase 1 / Stages 5–8.7: ✅ on `main`.** Engine core, editor framework,
|
||||||
|
input + gizmos, comprehensive UI, Reflection v2 + asset database (Stage 8.5),
|
||||||
|
and editor Play Mode (Stage 8.7).
|
||||||
|
- **Phase 1 / Stage 9 — Physics: ✅ complete on `main`.** All pieces 1–8c done
|
||||||
|
and promoted (eye-checked & approved 2026-06-17).
|
||||||
|
- **Phase 1 / Stage 10 — Scripting: 🚧 core done on `main`** (crate, lifecycle,
|
||||||
|
live reload, editor integration, Console, command terminal, interactive PTY
|
||||||
|
terminal — all eye-checked & approved; **richer script API**
|
||||||
|
spawn/despawn/component-edit — pure-logic, on `main`). One follow-up remains
|
||||||
|
(the editor-UX batch — GUI, needs eye-check). Piece table + what's next in §5;
|
||||||
|
details in `docs/scripting.md`.
|
||||||
|
|
||||||
|
### Stage 9 — piece status (all on `main`)
|
||||||
|
|
||||||
|
| # | Piece | Where |
|
||||||
|
|---|-------|-------|
|
||||||
|
| 1 | Component data model + module wiring: `RigidBody`/`RigidBodyKind`, `Collider`/`ColliderShape`, `PhysicsModule`, `PhysicsSettings` | `physics/` (the `oxide-physics` crate) |
|
||||||
|
| 2 | Rapier-backed sim: build the world from components, step on `FixedUpdate`, write transforms back; by-entity forces/velocities/sleep | `physics/src/world.rs` |
|
||||||
|
| 3 | Collision groups/masks via `LayerMask`, sensors, collision/trigger events (enter/stay/exit) | `physics/src/world.rs` |
|
||||||
|
| 4 | Scene queries: raycast, sphere-cast, point/overlap with `LayerMask` filtering | `physics/src/world.rs` |
|
||||||
|
| 5 | Joints/constraints: fixed, spherical, revolute, prismatic (programmatic API) | `physics/src/world.rs` |
|
||||||
|
| 6 | Kinematic capsule character controller: move-and-slide, step offset, slope limit, grounded | `physics/src/{character,world}.rs` |
|
||||||
|
| 7 | `examples/physics_stack` + `examples/character_capsule` (headless console demos) | `examples/` |
|
||||||
|
| 8a | Editor integration: register RigidBody/Collider/CharacterController (addable, reflected) + enums; wire `PhysicsModule` into the play `App` (physics = first real consumer of Play; Stop reverts via snapshot) | `editor/src/state.rs`, `editor/src/main.rs` |
|
||||||
|
| 8b | **Collider wireframe gizmos**: box/sphere/capsule/cylinder outlines over the viewport; green=solid, amber=sensor, selected drawn thicker; matches the sim (ignores `Transform::scale`); **View ▸ Show Colliders** toggle (on by default) | `editor/src/shell.rs` |
|
||||||
|
| 8c | **Raycast debug probe**: `PhysicsWorld::sync_to_scene` (query the edited scene with no Play) + **View ▸ Raycast Probe** — click freezes a camera→cursor ray into the world (orbit to see it as a 3D line), cyan ray + magenta hit dot + normal whisker, status hint on cast. Overlay `view_proj` decoupled from selection (colliders/probe show with nothing selected) | `physics/src/world.rs`, `editor/src/{shell,main}.rs` |
|
||||||
|
| — | Bug fix: namespace every inspector field widget per component (`ui.push_id(component_name)`) so a field name shared by two components (e.g. both `MeshRenderer` and `Collider` have `shape`) can't collide egui ids and block edits | `editor/src/shell.rs` |
|
||||||
|
|
||||||
|
The `oxide-physics` crate: the **ECS is the source of truth**; the rapier world
|
||||||
|
is a transient resource rebuilt from components each step, so play-mode
|
||||||
|
snapshot/restore works for free. Full usage + internals in `docs/physics.md`.
|
||||||
|
|
||||||
|
### Key design decisions (also in memory + PLAN.md)
|
||||||
|
|
||||||
|
- **Layers = Unity "Model A"** ([[layer-group-model-decision]]): an entity is on
|
||||||
|
**one** `Layer` (single index, render/physics filter slot); multi-category
|
||||||
|
needs are served by **Groups** = the multi-valued `Tags` component + a
|
||||||
|
project-level `GroupRegistry`. **Do NOT reintroduce multi-valued `Layer`
|
||||||
|
membership.** Physics `Collider.membership`/`filter` are `LayerMask`s.
|
||||||
|
- **Component multiplicity = hybrid** ([[component-multiplicity-decision]]):
|
||||||
|
one component of a type per entity (`hecs` archetypal). For "multiple things on
|
||||||
|
one node": a **local-offset field** for naturally-single positioned things, and
|
||||||
|
**child entities** for genuine multiples. NOT internal multi-instance lists.
|
||||||
|
- **`AssetRef<T>` not `Handle<T>` in components**: a component stores a
|
||||||
|
serializable `AssetRef<T>` (= `Option<AssetUid>`); `AssetRef::resolve(db,
|
||||||
|
server)` yields the process-local `Handle<T>` at runtime. The inspector
|
||||||
|
recognises both spellings via `asset_ref_target`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. How we work (follow this exactly)
|
||||||
|
|
||||||
|
- **Build each stage as small, independently-tested pieces.** One piece = one
|
||||||
|
green commit. Split a piece into a pure-logic part (→ `main`) and a GUI part
|
||||||
|
(→ `dev`) when cleaner.
|
||||||
|
- **Local gate before every commit:**
|
||||||
|
`cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test`.
|
||||||
|
(`#![deny(warnings)]` is on in the editor — clippy lints are hard errors, e.g.
|
||||||
|
`too_many_arguments` fires at >7 params; bundle args into tuples/structs.)
|
||||||
|
- **Branch promotion rule (from CLAUDE.md):**
|
||||||
|
- Pure-logic, fully proven by automated tests → commit to `dev` **and**
|
||||||
|
fast-forward `main`. Continue without asking. (Headless GPU pixel tests count
|
||||||
|
as proof — run them yourself.)
|
||||||
|
- Interactive GUI / rendering-feel / shell polish (anything a human must
|
||||||
|
**look at**) → push to `dev` only, **ask the maintainer to run + eye-check**,
|
||||||
|
promote after explicit approval.
|
||||||
|
- Decision rule: "Can an automated test prove this without a human looking?"
|
||||||
|
Yes → `main`-eligible. No → stop at `dev`.
|
||||||
|
- **Promotion commands** (from `dev`, after committing + pushing dev):
|
||||||
|
`git checkout main -q && git merge --ff-only dev -q && git push origin main && git checkout dev -q`
|
||||||
|
- **Commit messages** end with: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`
|
||||||
|
- **Document as you build:** keep `PLAN.md`/`README.md`/`docs/` current in the
|
||||||
|
**same** change. Physics docs live in `docs/physics.md`.
|
||||||
|
|
||||||
|
### egui gotcha
|
||||||
|
egui's bundled font has a **limited glyph set**. Confirmed-rendering: `🗑 ➕ ⚙ ✏
|
||||||
|
▾ … ↺`. Confirmed-tofu (avoid): `✕` (U+2715), `⧉`, `⣿` (braille). Reuse an icon
|
||||||
|
already in the codebase or test it before shipping; prefer plain text if unsure.
|
||||||
|
Also: egui assigns widget ids from a hash of the call path + a salt — when the
|
||||||
|
same logical widget appears twice (two components with a same-named field), scope
|
||||||
|
it under `ui.push_id(unique_key)` or it errors and edits silently break.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Conventions (match the existing code)
|
||||||
|
|
||||||
|
- Heavy rustdoc: each public type/fn gets a one-line summary + the "why".
|
||||||
|
Module-level `//!` docs explain the piece's role.
|
||||||
|
- Unit tests in `#[cfg(test)] mod tests` at the bottom of each file; integration
|
||||||
|
tests in `tests/src/lib.rs`. Even for visual pieces, factor the math into pure
|
||||||
|
helpers and unit-test those (e.g. `collider_wire_segments`/`push_arc` in 8b),
|
||||||
|
so only the painting itself needs the eye-check.
|
||||||
|
- Serialization is `serde` + RON throughout. New optional fields use
|
||||||
|
`#[serde(default, skip_serializing_if = ...)]` so older documents still parse.
|
||||||
|
- Errors are `thiserror` enums with specific variants.
|
||||||
|
- Reflection: **public fields only**; `#[reflect(skip)]` drops a public field;
|
||||||
|
`#[reflect(min=, max=)]` on an f32 → slider. Register editor-visible types in
|
||||||
|
`oxide_editor::state::register_builtin_types`; addable ones via
|
||||||
|
`register_addable::<T>` (needs `Default`); enums via `register_enum::<E>`.
|
||||||
|
- New deferred ideas → `PLAN.md` backlog, not the current piece.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Key architecture facts
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Per-field reflection on any component (zero per-type editor code):
|
||||||
|
#[derive(Reflect, Serialize, Deserialize)]
|
||||||
|
struct Timer { pub repeating: bool, pub duration: f32, #[reflect(skip)] pub elapsed: f32 }
|
||||||
|
registry.register_reflected::<Timer>("Timer"); // editor + scripts
|
||||||
|
|
||||||
|
// Inspector renders generically: components_on -> field_infos -> get_field/set_field,
|
||||||
|
// edits routed through SetFieldCmd (undoable, drag-coalesced).
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`oxide_engine::reflect`**: whole-value (`get_ron`/`set_ron`) + per-field
|
||||||
|
(`Reflect` + `register_reflected`) layers. `register_addable` adds an "Add
|
||||||
|
Component" constructor; `register_enum` lists variants for combo widgets.
|
||||||
|
- **`oxide_engine::prefab`**: `Prefab { name, components }`,
|
||||||
|
`ComponentSpec { type_name, ron }`; editor seeds built-ins (Empty/Cube/…).
|
||||||
|
- **`oxide_engine::layer`**: single-valued `Layer { index }`; `LayerMask`
|
||||||
|
(filter); `LayerRegistry` (names); `Tags` + `GroupRegistry` (groups).
|
||||||
|
- **Editor inspector** (`oxide_editor::shell::ShellTabViewer`): node-baked
|
||||||
|
section (Layer/Groups/Transform) then modular components (drag-reorder,
|
||||||
|
enable/disable, remove, Add Component menu). `field_widget` dispatches on
|
||||||
|
`FieldInfo.type_name`.
|
||||||
|
- **Viewport overlay** (`editor/src/shell.rs`): the host feeds a
|
||||||
|
`ViewportOverlay { view_proj, gizmo_size }` each frame; the Viewport tab paints
|
||||||
|
2D over the 3D scene via `project(world, &view_proj, tab_rect) -> Pos2`.
|
||||||
|
Transform gizmo handles, the play-state border, **and the new collider
|
||||||
|
wireframes** all paint this way. Reuse `project` + `painter.line_segment` for
|
||||||
|
any new world-space overlay (e.g. piece 8c's raycast viz).
|
||||||
|
- **Play loop** (`oxide_editor::main::drive_play` + `oxide_engine::scene::
|
||||||
|
SceneSnapshot`): owns a play `App` (DefaultModules + PhysicsModule +
|
||||||
|
ScriptModule; **`app.assets` is set to a clone of the editor's `AssetServer`**
|
||||||
|
and the project `AssetDatabase` is inserted as a resource so scripts resolve +
|
||||||
|
live-reload reaches a playing scene), built on Play / dropped on Stop, **swaps
|
||||||
|
`state.scene` in/out per tick** so the editor scene stays the single source of
|
||||||
|
truth. Snapshot covers reflected components + `Tags`/`DisabledComponents`. Undo
|
||||||
|
cleared on Play/Stop.
|
||||||
|
- **Host input** (`oxide_editor::main`): viewport orbit/pan/zoom + WASD gated on
|
||||||
|
`cursor_over_viewport && !pointer_over_floating`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Exactly what to do next — finish Stage 10 (Scripting)
|
||||||
|
|
||||||
|
**Stage 10's core is done and on `main`** (all eye-checked & approved). The
|
||||||
|
`oxide-script` crate is the worked-out twin of `oxide-physics` (same
|
||||||
|
"ECS-as-truth, transient runtime resource, snapshot-for-free" shape). Full usage
|
||||||
|
+ internals: `docs/scripting.md`. What shipped (all on `main`):
|
||||||
|
|
||||||
|
| Piece | Where |
|
||||||
|
|-------|-------|
|
||||||
|
| `oxide-script` crate: `Script`, `ScriptAsset` + `.rhai` loader (`AssetKind::Script`), sandboxed `ScriptEngine` (compile/run, op cap, `ScriptError`), `ScriptModule` | `script/` |
|
||||||
|
| Lifecycle: `ScriptHost` + `run_scripts` (Update schedule); `init`/`update(dt)`; **engine API** (`bridge.rs`) — scripts read/write their `Transform` via a staged shared context (`position`/`translate`/`rotate_*`/`Vec3`), `rhai` `f32_float` | `script/src/{host,engine,bridge}.rs` |
|
||||||
|
| **Live reload**: `ScriptHost` holds each script's `Handle<ScriptAsset>` alive → watcher's in-place reload recompiles (no restart); `examples/script_spin` | `script/src/host.rs`, `examples/` |
|
||||||
|
| **Editor integration**: `Script` addable in `register_builtin_types`; `drive_play` adds `ScriptModule` + **shares the editor `AssetServer`** + a db snapshot → live `.rhai` edits update a *playing* scene | `editor/src/{state,main}.rs` |
|
||||||
|
| **Console**: capturing logger mirrors the `log` stream into a ring buffer the panel renders (script `print` + paused-script errors via `target:"oxide_script"`) | `editor/src/console.rs`, `shell.rs::console` |
|
||||||
|
| **Command terminal**: `$` prompt → `sh -c <cmd>` in the project root, streams stdout/stderr into the Console | `editor/src/terminal.rs` |
|
||||||
|
| **Interactive PTY terminal**: `PanelKind::Terminal` — tabbed, auto-closes a tab when its program exits, Tab/arrows/Esc routed to the program; runs shells / TUIs / `claude` | `editor/src/pty.rs`, `shell.rs::terminal_panel` |
|
||||||
|
|
||||||
|
### Remaining for Stage 10
|
||||||
|
|
||||||
|
**The editor-UX batch below is ✅ DONE (2026-07-10) — all four items are on
|
||||||
|
`dev` as separate commits awaiting one eye-check pass** (details in each
|
||||||
|
commit message + PLAN.md "Editor-UX follow-ups" + `docs/assets.md` /
|
||||||
|
`docs/scripting.md` / `docs/projects.md`):
|
||||||
|
|
||||||
|
| Piece | Commit | What to eye-check |
|
||||||
|
|-------|--------|-------------------|
|
||||||
|
| New Script button | `4f1e9a4` | Script inspector: name + ➕ creates/assigns a `.rhai`; Enter submits; undo detaches |
|
||||||
|
| Open script in editor | `d08bee1` | ✏ Edit + Project-panel double-click; External Editor pref in Preferences; $EDITOR opens in a Terminal tab |
|
||||||
|
| Native folder picker | `48003ff` | Browse… on New/Open Project — test on **both Wayland and Xorg**; UI keeps rendering while the dialog is up |
|
||||||
|
| File explorer | `c47efa8` | Breadcrumbs, New Folder, rename/delete menus, drag-to-move, OS drag-in import, double-click open |
|
||||||
|
|
||||||
|
Engine-side groundwork already on `main`: `AssetDatabase::move_asset`/
|
||||||
|
`move_folder`/`delete_asset` (uid-preserving, so renames/moves never break a
|
||||||
|
saved `AssetRef`), whole-tree `scan`, `editor/src/assets.rs` script
|
||||||
|
template/creation helpers, `editor/src/explorer.rs` (unit-tested behavior
|
||||||
|
layer). Note: the build machine's toolchain is Rust 1.97 (rustup, user-local
|
||||||
|
`~/.cargo/bin` — may need `export PATH="$HOME/.cargo/bin:$PATH"`).
|
||||||
|
|
||||||
|
**Historical notes (already on `main` before this batch):**
|
||||||
|
|
||||||
|
**1. Richer script API — ✅ DONE, on `main`.** Scripts now `spawn_entity()` /
|
||||||
|
`spawn_entity(name)`, `despawn(e)`, and `add_component`/`set_component`/
|
||||||
|
`remove_component` on any entity, plus `entity()` for their own. Implemented as a
|
||||||
|
**deferred command buffer** in `bridge.rs` (`EntityHandle` + `ScriptCommand`):
|
||||||
|
the `rhai` functions can't borrow the ECS (must be `Send + Sync`), so each call
|
||||||
|
buffers a command the host drains in `ScriptHost::apply_commands` and applies via
|
||||||
|
the reflection registry (`app.types`, `set_ron`/`add_default`/`remove`). `spawn_entity`
|
||||||
|
returns a **provisional** handle resolvable in the same frame (`spawn` is a rhai
|
||||||
|
reserved word, hence the longer name). Component edits go through RON, closing the
|
||||||
|
last PLAN round-trip criterion. Headless tests in `host.rs`/`bridge.rs`; docs in
|
||||||
|
`docs/scripting.md` ("Spawning entities and editing components").
|
||||||
|
|
||||||
|
**2. Maintainer's editor-UX batch — ✅ DONE 2026-07-10, on `dev` (see the
|
||||||
|
table above; awaiting eye-check).** Original ask, for context — details in
|
||||||
|
PLAN.md "Editor-UX follow-ups". The maintainer asked for these after the
|
||||||
|
terminal work:
|
||||||
|
- **"New Script" button** on the `Script` inspector — write a `.rhai` template
|
||||||
|
into `assets/scripts/`, register it in the db, auto-assign it to the component.
|
||||||
|
There is **no in-editor script creation today** (scripts are authored as
|
||||||
|
files). Good small first one to pull forward.
|
||||||
|
- **Open a script in an editor** — double-click / button to open a `.rhai` in an
|
||||||
|
in-editor text view or launch `$EDITOR` / a configured external editor; edits
|
||||||
|
flow back through live reload (you can also just run `$EDITOR file` in the new
|
||||||
|
PTY terminal).
|
||||||
|
- **Unity-style file explorer** in the Project panel (create/rename/move folders,
|
||||||
|
drag-in OS import, context menus) over the existing `AssetDatabase`.
|
||||||
|
- **Native New/Open-Project dialog** (`rfd`, must work on Wayland + X11) —
|
||||||
|
typing the project path by hand "is very hard to use".
|
||||||
|
|
||||||
|
**Reuse, don't reinvent:** `oxide-physics`/`oxide-script` are the template for
|
||||||
|
"new crate → `Module` + schedule → reflected/addable components → editor
|
||||||
|
registration → play-loop integration". The play loop
|
||||||
|
(`oxide_editor::main::drive_play`) runs scripts alongside physics and shares the
|
||||||
|
editor's asset server, so live reload reaches a playing scene.
|
||||||
|
|
||||||
|
**Terminal design note:** the embedded terminal is a general PTY widget
|
||||||
|
(`portable-pty` + `vt100`); run any program (incl. `claude`) from a *Shell* tab.
|
||||||
|
A "one-app launcher" was considered and rejected — it needs the same
|
||||||
|
PTY+VT+render+input stack, so the general widget is strictly better.
|
||||||
|
|
||||||
|
**Don't do these unless asked (parked in PLAN.md):** editor authoring of joint
|
||||||
|
**components** (needs a serializable entity-reference type); standalone game
|
||||||
|
"Launch" (deferred to Stage 16).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Quick orientation commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test # full suite (all green at handoff)
|
||||||
|
cargo test -p oxide-script # the scripting crate (lifecycle, live reload)
|
||||||
|
cargo test -p oxide-editor --lib pty # PTY terminal pure helpers
|
||||||
|
cargo run -p oxide-examples --bin script_spin # headless live-reload demo
|
||||||
|
cargo run -p oxide-editor # the editor (Console + Terminal panels)
|
||||||
|
git log --oneline -15 # recent per-piece commits
|
||||||
|
sed -n '/## Stage 10/,/## Stage 11/p' PLAN.md # Stage 10 spec + status
|
||||||
|
```
|
||||||
|
|
||||||
|
Memory files (auto-loaded each session) track decisions: `MEMORY.md` index →
|
||||||
|
`layer-group-model-decision.md` → `component-multiplicity-decision.md` →
|
||||||
|
`run-on-target-pc.md` (headless-vs-eye-check rule) →
|
||||||
|
`workflow-auto-continue.md` (don't pause between pure-logic pieces).
|
||||||
@@ -1,3 +1,252 @@
|
|||||||
# Oxide
|
# Oxide Engine
|
||||||
|
|
||||||
Oxide general purpose 3D game engine
|

|
||||||
|
|
||||||
|
> **Notice:** This project was developed with the assistance of [Claude Code](https://claude.ai/code) (Anthropic's AI coding assistant).
|
||||||
|
> All generated code, configuration, and documentation has been reviewed and tested by the author.
|
||||||
|
> Claude Code was used as a development tool; all design decisions, requirements, and sign-offs are the author's own.
|
||||||
|
|
||||||
|
A general-purpose 3D game engine written in Rust — built to make **any** 3D game, scaling from
|
||||||
|
stylized low-poly to realistic graphics, and shipping only what each game uses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Oxide is built in two phases (see [PLAN.md](PLAN.md)): **Phase 1 (Stages 0–16)**
|
||||||
|
is the general-purpose engine — everything needed to build and export any game;
|
||||||
|
**Phase 2 (Stages 17+)** adds optional, feature-gated built-in modules.
|
||||||
|
|
||||||
|
**Stages 0–9 are complete on `main`.** Stage 8 shipped the engine's full
|
||||||
|
in-game UI stack — widget tree, layout, themed styling, `ab_glyph`-backed text
|
||||||
|
shaping + R8 glyph atlas, screen-space *and* world-space render passes,
|
||||||
|
hit-test + hover/focus/press router, immediate-mode event queries with typed
|
||||||
|
`WidgetValue` data binding, and the editor's visual UI canvas. **Stage 8.5**
|
||||||
|
added reflection v2 (public fields auto-appear in the inspector, no per-type
|
||||||
|
editor code), prefabs, the asset database, and layers/groups. **Stage 8.7**
|
||||||
|
added **editor play mode** — Play/Pause/Step/Stop the open scene in the
|
||||||
|
viewport (Ctrl+P / Ctrl+.), with snapshot-on-Play / bit-for-bit restore-on-Stop.
|
||||||
|
**Stage 9** added **comprehensive physics** (`oxide-physics` on `rapier3d`) —
|
||||||
|
rigid bodies, colliders, collision/trigger events, scene queries (raycast/
|
||||||
|
shape-cast/overlap), joints, a kinematic character controller, and editor
|
||||||
|
integration (addable components, collider wireframe gizmos, a freeze-on-click
|
||||||
|
raycast debug probe). **Stage 10 — Scripting, Live Reload & Editor Terminal**
|
||||||
|
(`rhai`, the `oxide-script` crate) is now **in progress**: the `Script`
|
||||||
|
component, the `.rhai` asset loader, and the sandboxed script engine have
|
||||||
|
landed; lifecycle execution, live reload, and the editor terminal follow.
|
||||||
|
|
||||||
|
Available now:
|
||||||
|
|
||||||
|
- `oxide_engine::math` — `Transform`, `Aabb`, `Ray`, `Plane`, `Frustum`, `Color`, `Rect`, `Range3`
|
||||||
|
- `oxide_engine::window` — window creation, `WindowApp` trait + event loop, raw input events (`winit`)
|
||||||
|
- `oxide_engine::render` — GPU setup (`wgpu`), surface management, clear loop, and a forward
|
||||||
|
renderer: `Mesh`/`Vertex` (+ cube/plane/sphere primitives), `Material`, `Camera`, `ForwardRenderer`;
|
||||||
|
data-driven `RenderPipeline` (`RenderPass`/`ClearPass`/`ForwardPass`)
|
||||||
|
- `oxide_engine::scene` — `Scene`, `Node`, entity hierarchy with world-transform resolution, RON serialization (`hecs`)
|
||||||
|
- `oxide_engine::app` — the `App` core, `Module`/`DefaultModules`, and the `Schedule` (system phases + fixed timestep)
|
||||||
|
- `oxide_engine::layer` — `LayerMask`, `LayerRegistry`, and `Layers`/`Tags` components (shared filtering primitive)
|
||||||
|
- `oxide_engine::reflect` — `TypeRegistry` for generic, name-keyed component access (dual-editability)
|
||||||
|
- `oxide_engine::asset` — `AssetServer` + ref-counted `Handle<T>` (dedup, background load, reload by path)
|
||||||
|
- `oxide_engine::project` — `Project` (create/open/save, folder layout, enabled modules, per-project
|
||||||
|
settings) + `RecentProjects` MRU list
|
||||||
|
- `oxide_engine::settings` — typed `Settings` sections (engine/editor/per-module), export/import (RON)
|
||||||
|
- `oxide_engine::watch` — `FileWatcher` with a debounced/deduplicated change-event stream and
|
||||||
|
`reload_changed_assets` helper that drives `AssetServer::reload_path`
|
||||||
|
- `oxide_editor::shell::Shell` — docking shell (menu bar, dock area, status bar, Preferences window);
|
||||||
|
`oxide_editor::command` / `commands` — `CommandStack` + `SetTransformCmd` (drag-coalesce) /
|
||||||
|
`RenameCmd`; `oxide_editor::extension` — module → editor `EditorModule` extension API
|
||||||
|
- `oxide_engine::input` — per-frame `InputState` (keyboard / mouse / cursor / scroll, edge
|
||||||
|
detection), remappable `ActionMap` with `Binding` / `AxisBinding` / `Axis2DBinding`, RON-persistable
|
||||||
|
`ActionOverrides` (Stage-7 piece 1–3)
|
||||||
|
- `oxide_editor::gizmo` — pure-logic transform-gizmo math (hit testing, drag projection, snap);
|
||||||
|
`oxide_editor::bindings` — default editor action set (camera + gizmo hotkeys);
|
||||||
|
`oxide_editor::preferences` — `~/.config/oxide/editor.ron` load/save. The viewport ships a
|
||||||
|
flythrough camera (F-toggle), translate / rotate / scale gizmos with Ctrl-snap and undo, and
|
||||||
|
an Input Bindings preferences page (Stage-7 pieces 4–6)
|
||||||
|
- `oxide_engine::ui` — in-game UI system (Stage-8 pieces 1–6): `Widget` tree
|
||||||
|
with stack / grid / anchor layouts, DPI-aware sizing, per-widget visual
|
||||||
|
styles with named-style `Theme` cascade, `ab_glyph`-backed text shaping +
|
||||||
|
shelf-packed R8 `GlyphAtlas`, `paint()` → `DrawCommand`s consumed by
|
||||||
|
screen-space and world-space (`UiPanel`) `UiOverlayPass` in
|
||||||
|
`oxide_engine::render`, hit-test + hover/focus/press `Router` with
|
||||||
|
immediate-mode `RouterFrame::clicked_left(...)` queries, typed
|
||||||
|
`WidgetValue` (Bool / Int / Float / Text) for game-data round-tripping.
|
||||||
|
Runnable example: `cargo run -p oxide-examples --bin ui_menu` (Stage-8
|
||||||
|
piece 7)
|
||||||
|
|
||||||
|
## Features (planned — see [PLAN.md](PLAN.md))
|
||||||
|
|
||||||
|
**Phase 1 — the general-purpose engine:**
|
||||||
|
|
||||||
|
- ✅ Math & core primitives (transforms, bounds, rays, frustum culling)
|
||||||
|
- ✅ Window, GPU context & clear-color render loop (`winit` + `wgpu`)
|
||||||
|
- ✅ Scene graph and entity management (ECS-based, `hecs`)
|
||||||
|
- ✅ Basic 3D rendering (meshes, PBR-lite materials, camera, GLTF import, editor viewport)
|
||||||
|
- ✅ Engine core framework: module/plugin system, layers & tags, asset server, reflection registry,
|
||||||
|
data-driven render pass pipeline
|
||||||
|
- ✅ Editor framework & project system: top menu, dockable panels, undo/redo, module extension API,
|
||||||
|
settings/preferences, create/open/save projects with live file watching
|
||||||
|
- ✅ Input system with remappable named actions (per-key edges + button/axis actions; RON-persisted
|
||||||
|
remap surfaced through the editor's Input Bindings preferences page) and editor transform gizmos
|
||||||
|
(translate / rotate / scale with Ctrl-snap and undo, W/E/R hotkeys, flythrough viewport camera)
|
||||||
|
- ✅ Comprehensive in-game UI system (widgets, layout, theming, text) authored in a visual editor canvas
|
||||||
|
- ✅ Reflection-driven inspector, prefabs, asset database, layers/groups (Stage 8.5)
|
||||||
|
- ✅ Editor play mode: Play/Pause/Step/Stop with snapshot-on-Play / restore-on-Stop (Stage 8.7)
|
||||||
|
- ✅ Comprehensive rigid-body physics (`rapier3d`): colliders, joints, scene queries, collision/
|
||||||
|
trigger events, kinematic character controller, editor integration + collider/raycast gizmos (Stage 9)
|
||||||
|
- Scripting with live reload + an in-editor terminal (host tools/AI agents that edit game code live)
|
||||||
|
- Skeletal animation · GPU-driven particles · shader hot-reload & scalable post-processing
|
||||||
|
- Standard audio (mixer/spatial)
|
||||||
|
- Built-in content kit: prototyping primitives, shaders, and a character controller
|
||||||
|
- Game export to standalone **Linux and Windows** binaries
|
||||||
|
- **In-engine editor** (`oxide-editor`) built alongside the engine
|
||||||
|
|
||||||
|
**Phase 2 — optional built-in modules (feature-gated):**
|
||||||
|
|
||||||
|
- Ray-traced spatial audio (wave propagation, occlusion, reverb)
|
||||||
|
- Developer console & cheats
|
||||||
|
- Procedural toolkit (noise + composable modifier stack)
|
||||||
|
- Terrain: generate, sculpt & paint with brushes, scatter grass/trees/objects
|
||||||
|
- Open world streaming (chunking, async asset loading, LOD)
|
||||||
|
- Pathfinding & NPC AI (navmesh, agents, behavior trees, perception)
|
||||||
|
- Water (rendering + buoyancy/swim/flow mechanics)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Rust stable toolchain (`rustup` recommended)
|
||||||
|
- Linux — primary target, on **both Wayland and Xorg (X11)**. Windows support and cross-platform
|
||||||
|
game export are planned (see [PLAN.md](PLAN.md), Stage 16); other platforms are not yet tested.
|
||||||
|
- A GPU with Vulkan or Metal support (for `wgpu`)
|
||||||
|
|
||||||
|
Install Rust if you don't have it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
To build and run the editor directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-editor --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
Each stage ships at least one runnable example. List and run them with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-examples --bin math_demo # Stage 1: math primitives tour
|
||||||
|
cargo run -p oxide-examples --bin hello_window # Stage 2: window + clear color (1–5/Space to recolor, Esc quits)
|
||||||
|
cargo run -p oxide-examples --bin scene_basic # Stage 3: build a hierarchy, print world transforms, round-trip RON
|
||||||
|
cargo run -p oxide-examples --bin hello_mesh # Stage 4: lit 3D meshes (spinning cube + sphere + ground), Esc quits
|
||||||
|
cargo run -p oxide-examples --bin ui_menu # Stage 8: themed main menu + settings (draggable slider, checkbox), Esc quits
|
||||||
|
cargo run -p oxide-examples --bin ui_hud # Stage 8: HUD (HP/ammo/minimap/crosshair) over a 3D scene, Esc quits
|
||||||
|
cargo run -p oxide-examples --bin physics_stack # Stage 9: a stack of boxes settles + a ball lands (headless console)
|
||||||
|
cargo run -p oxide-examples --bin character_capsule # Stage 9: a capsule walks, climbs a step, jumps, hits a wall (headless console)
|
||||||
|
cargo run -p oxide-examples --bin script_spin # Stage 10: a rhai script spins an entity; the script is edited live and the spin rate jumps (headless console)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benchmarks
|
||||||
|
|
||||||
|
Performance-sensitive systems have `criterion` benchmarks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo bench -p oxide-engine
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installing (Linux)
|
||||||
|
|
||||||
|
The `install.sh` script compiles the project and installs it to your system (`/usr/local`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
chmod +x install.sh
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
After installation the editor is available as:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
oxide-editor
|
||||||
|
```
|
||||||
|
|
||||||
|
To uninstall:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo rm /usr/local/bin/oxide-editor
|
||||||
|
sudo rm -rf /usr/local/share/oxide
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
Lint and format checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo clippy -- -D warnings
|
||||||
|
cargo fmt --check
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
The full documentation — usage guides, per-system API references, and
|
||||||
|
explanations of the engine's inner workings — lives in [`docs/`](docs/README.md).
|
||||||
|
Start there for anything beyond this overview:
|
||||||
|
|
||||||
|
- [Getting Started](docs/getting-started.md) — build, run, test, install
|
||||||
|
- [Architecture](docs/architecture.md) — workspace and design overview
|
||||||
|
- [Conventions](docs/conventions.md) — coordinate system, units, color space
|
||||||
|
- [Development Workflow](docs/development.md) — branches, testing, contributing
|
||||||
|
- [Math & Core Primitives](docs/math.md) — Stage 1 API reference
|
||||||
|
- [Windowing & App Loop](docs/windowing.md) — Stage 2 window/event-loop reference
|
||||||
|
- [Render Context](docs/render-context.md) — Stage 2 GPU/surface reference
|
||||||
|
- [Scene Graph & Entities](docs/scene.md) — Stage 3 scene/hierarchy/serialization reference
|
||||||
|
- [Rendering](docs/rendering.md) — Stage 4 mesh/material/camera/forward-renderer reference
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
Oxide/
|
||||||
|
├── assets/ # Project logos and shared assets
|
||||||
|
├── docs/ # Full engine documentation
|
||||||
|
├── engine/ # Core engine library (oxide-engine)
|
||||||
|
├── editor/ # In-engine editor binary (oxide-editor)
|
||||||
|
├── examples/ # Runnable stage examples (oxide-examples)
|
||||||
|
├── tests/ # Integration test harness (oxide-tests)
|
||||||
|
├── install.sh # Build + system install script
|
||||||
|
├── PLAN.md # Staged development roadmap
|
||||||
|
└── CLAUDE.md # Context and rules for Claude Code
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development roadmap
|
||||||
|
|
||||||
|
Development follows a staged plan — each stage is fully tested before the next begins.
|
||||||
|
See [PLAN.md](PLAN.md) for the complete roadmap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"asset": {
|
||||||
|
"version": "2.0",
|
||||||
|
"generator": "oxide cube generator"
|
||||||
|
},
|
||||||
|
"scene": 0,
|
||||||
|
"scenes": [
|
||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"mesh": 0,
|
||||||
|
"name": "Cube"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meshes": [
|
||||||
|
{
|
||||||
|
"name": "Cube",
|
||||||
|
"primitives": [
|
||||||
|
{
|
||||||
|
"attributes": {
|
||||||
|
"POSITION": 0
|
||||||
|
},
|
||||||
|
"indices": 1,
|
||||||
|
"material": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"materials": [
|
||||||
|
{
|
||||||
|
"name": "CubeMat",
|
||||||
|
"pbrMetallicRoughness": {
|
||||||
|
"baseColorFactor": [
|
||||||
|
0.9,
|
||||||
|
0.45,
|
||||||
|
0.12,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
"metallicFactor": 0.0,
|
||||||
|
"roughnessFactor": 0.7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"buffers": [
|
||||||
|
{
|
||||||
|
"byteLength": 168,
|
||||||
|
"uri": "data:application/octet-stream;base64,AAAAvwAAAL8AAAC/AAAAPwAAAL8AAAC/AAAAPwAAAD8AAAC/AAAAvwAAAD8AAAC/AAAAvwAAAL8AAAA/AAAAPwAAAL8AAAA/AAAAPwAAAD8AAAA/AAAAvwAAAD8AAAA/BAAFAAYABAAGAAcAAQAAAAMAAQADAAIABQABAAIABQACAAYAAAAEAAcAAAAHAAMAAwACAAYAAwAGAAcAAAABAAUAAAAFAAQA"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bufferViews": [
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteOffset": 0,
|
||||||
|
"byteLength": 96,
|
||||||
|
"target": 34962
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteOffset": 96,
|
||||||
|
"byteLength": 72,
|
||||||
|
"target": 34963
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"accessors": [
|
||||||
|
{
|
||||||
|
"bufferView": 0,
|
||||||
|
"componentType": 5126,
|
||||||
|
"count": 8,
|
||||||
|
"type": "VEC3",
|
||||||
|
"min": [
|
||||||
|
-0.5,
|
||||||
|
-0.5,
|
||||||
|
-0.5
|
||||||
|
],
|
||||||
|
"max": [
|
||||||
|
0.5,
|
||||||
|
0.5,
|
||||||
|
0.5
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 1,
|
||||||
|
"componentType": 5123,
|
||||||
|
"count": 36,
|
||||||
|
"type": "SCALAR"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
@@ -0,0 +1,68 @@
|
|||||||
|
# Oxide Engine Documentation
|
||||||
|
|
||||||
|
This directory is the canonical, in-depth documentation for the Oxide engine. The
|
||||||
|
top-level [`README.md`](../README.md) is a short overview; everything detailed —
|
||||||
|
usage guides, API references, and explanations of how the engine works
|
||||||
|
internally — lives here and grows with the engine at every stage.
|
||||||
|
|
||||||
|
## How this documentation is organized
|
||||||
|
|
||||||
|
| Document | What it covers |
|
||||||
|
|----------|----------------|
|
||||||
|
| [getting-started.md](getting-started.md) | Installing the toolchain, building, running examples, running tests and benchmarks |
|
||||||
|
| [architecture.md](architecture.md) | Workspace layout, crate responsibilities, design philosophy, staged development model |
|
||||||
|
| [conventions.md](conventions.md) | Coordinate system, handedness, units, color space, and other cross-cutting conventions |
|
||||||
|
| [development.md](development.md) | Branch workflow (`dev`/`main`), testing protocol, documentation policy, how to add a stage |
|
||||||
|
| [math.md](math.md) | Full reference for the `oxide_engine::math` module (Stage 1) |
|
||||||
|
| [windowing.md](windowing.md) | Window creation, the `App` trait and event loop, raw input events (Stage 2) |
|
||||||
|
| [render-context.md](render-context.md) | GPU acquisition, surface configuration, the clear-color frame loop (Stage 2) |
|
||||||
|
| [scene.md](scene.md) | Scene graph, entities, the transform hierarchy, and serialization (Stage 3) |
|
||||||
|
| [rendering.md](rendering.md) | Meshes, materials, camera, and the forward renderer (Stage 4) |
|
||||||
|
| [layers.md](layers.md) | Layers, groups & tags: single-valued `Layer` + multi-valued `Tags`/`GroupRegistry`, the shared `LayerMask` filter primitive (Stage 5) |
|
||||||
|
| [reflection.md](reflection.md) | Reflection / type registry: generic name-keyed component access for dual-editability (Stage 5) |
|
||||||
|
| [prefabs.md](prefabs.md) | Prefabs: data-driven named spawn templates (component specs applied via the registry) (Stage 8.5) |
|
||||||
|
| [assets.md](assets.md) | Asset server & handles: ref-counted loading, dedup, loaders, background load, reload (Stage 5) |
|
||||||
|
| [modules.md](modules.md) | App, modules & scheduling: composing the engine, system phases, fixed timestep, enable/disable/remove (Stage 5) |
|
||||||
|
| [render-pipeline.md](render-pipeline.md) | Data-driven render pass pipeline: composable passes, scalable fidelity, camera layer visibility (Stage 5) |
|
||||||
|
| [projects.md](projects.md) | Project system: project file, folder layout, create/open/save, recent projects (Stage 6) |
|
||||||
|
| [settings.md](settings.md) | Settings & preferences framework: typed sections, export/import, per-module/per-project settings (Stage 6) |
|
||||||
|
| [file-watching.md](file-watching.md) | File-watcher foundation: debounced/deduplicated change events; asset live-reload wiring (Stage 6) |
|
||||||
|
| [editor-extensions.md](editor-extensions.md) | Module → editor extension API: how a module contributes menus, panels, tools, inspectors, settings pages (Stage 6) |
|
||||||
|
| [editor-shell.md](editor-shell.md) | Editor docking shell: menu bar, dockable panels, status bar, Preferences window, command stack + file watcher wiring (Stage 6) |
|
||||||
|
| [input.md](input.md) | Input system: per-frame `InputState` with edge detection, named action mapping (`ActionMap`/`Binding`) with defaults + remapping + RON persistence, 1D/2D directional axes (Stage 7) |
|
||||||
|
| [ui.md](ui.md) | In-game UI: widget tree, layout (stack/grid/anchor), DPI-aware sizing, per-widget visual styles + named-style themes, RON dual-edit, ab_glyph-backed text shaping + R8 glyph atlas, screen-space + world-space `UiOverlayPass`, hit-test + hover/focus/press `Router`, immediate-mode event queries + typed `WidgetValue`s (Stage 8 — pieces 1–6, GUI tail coming) |
|
||||||
|
| [play-mode.md](play-mode.md) | Editor play mode: `PlayState`, registry-aware `SceneSnapshot` snapshot/restore, the `tick_for` decision + `App::step`, the scene-swap runner, toolbar/shortcuts/viewport tint (Stage 8.7) |
|
||||||
|
| [physics.md](physics.md) | Physics module (`oxide-physics`, rapier3d): `RigidBody`/`Collider` components, `LayerMask`-filtered collision, the ECS-as-source-of-truth model, module wiring (Stage 9) |
|
||||||
|
| [scripting.md](scripting.md) | Scripting module (`oxide-script`, rhai): the `Script` component, `ScriptAsset` + `.rhai` loader, the sandboxed `ScriptEngine` wrapper, module wiring; ECS-as-source-of-truth + live-reload model (Stage 10) |
|
||||||
|
|
||||||
|
## Documentation status by stage
|
||||||
|
|
||||||
|
Documentation is written alongside the code. A stage is not considered done until
|
||||||
|
its docs exist here.
|
||||||
|
|
||||||
|
| Stage | Subject | Docs |
|
||||||
|
|-------|---------|------|
|
||||||
|
| 0 | Project foundation | [architecture.md](architecture.md), [getting-started.md](getting-started.md) |
|
||||||
|
| 1 | Math & core primitives | [math.md](math.md) |
|
||||||
|
| 2 | Window & render context | [windowing.md](windowing.md), [render-context.md](render-context.md) |
|
||||||
|
| 3 | Scene graph & entity system | [scene.md](scene.md) |
|
||||||
|
| 4 | Basic 3D rendering | [rendering.md](rendering.md) |
|
||||||
|
| 5 | Engine core framework | [modules.md](modules.md), [layers.md](layers.md), [reflection.md](reflection.md), [assets.md](assets.md), [render-pipeline.md](render-pipeline.md) |
|
||||||
|
| 6 | Editor framework & project system | [projects.md](projects.md), [settings.md](settings.md), [file-watching.md](file-watching.md), [editor-extensions.md](editor-extensions.md), [editor-shell.md](editor-shell.md) |
|
||||||
|
| 7 | Input system | [input.md](input.md) |
|
||||||
|
| 8 | Comprehensive UI system (in progress) | [ui.md](ui.md) |
|
||||||
|
| 8.7 | Editor play mode | [play-mode.md](play-mode.md) |
|
||||||
|
| 9 | Physics integration (in progress) | [physics.md](physics.md) |
|
||||||
|
| 10 | Scripting, live reload & editor terminal (in progress) | [scripting.md](scripting.md) |
|
||||||
|
| 11+ | — | _added as each stage lands_ |
|
||||||
|
|
||||||
|
## Conventions for these files
|
||||||
|
|
||||||
|
- One topic per file; keep files focused and link between them rather than
|
||||||
|
duplicating content.
|
||||||
|
- Every public type or system gets: a one-line summary, when to use it, and at
|
||||||
|
least one runnable code snippet.
|
||||||
|
- Code snippets are written so they would compile against the current API. When
|
||||||
|
the API changes, update the snippet in the same change.
|
||||||
|
- Prefer explaining the *why* (design intent, trade-offs) over restating the
|
||||||
|
*what* (which the rustdoc comments already cover).
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
This document explains how Oxide is structured and the principles that govern how
|
||||||
|
it is built. For per-system detail, see the topic documents (e.g. [math.md](math.md)).
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
Oxide is a general-purpose 3D game engine written in Rust, built to make **any**
|
||||||
|
3D game — scaling from stylized low-poly to realistic graphics — and to **ship
|
||||||
|
only what each game uses**. It is built in two phases: a **general-purpose engine**
|
||||||
|
(Phase 1, scene graph, render pass pipeline, input, UI, physics, scripting,
|
||||||
|
animation, particles, shaders, audio, content kit, and game export) and a set of
|
||||||
|
optional, feature-gated **built-in modules** (Phase 2: ray-traced sound, developer
|
||||||
|
console, procedural toolkit, terrain, open world, pathfinding/AI, water). The
|
||||||
|
guiding idea is **build tools, not games**, all driven through a first-class
|
||||||
|
**in-engine editor**. See [`PLAN.md`](../PLAN.md) for the staged roadmap.
|
||||||
|
|
||||||
|
## Design philosophy
|
||||||
|
|
||||||
|
These principles are non-negotiable and shape every decision:
|
||||||
|
|
||||||
|
1. **Build in stages.** Each stage produces a usable, standalone artifact and
|
||||||
|
must be fully tested and stable before the next begins. See
|
||||||
|
[development.md](development.md) and [`PLAN.md`](../PLAN.md).
|
||||||
|
2. **Composability.** Every system should be usable independently of the others.
|
||||||
|
You should be able to pull in the math module, or the scene graph, without
|
||||||
|
dragging in the renderer.
|
||||||
|
3. **Correctness and clarity over premature optimization.** Optimize when a
|
||||||
|
benchmark says to, not before.
|
||||||
|
4. **Minimal public surface.** Internal complexity is fine; the external API
|
||||||
|
should be small and clean. Modules expose a curated set of types through
|
||||||
|
`pub use`, not their whole internal structure.
|
||||||
|
5. **No feature creep between stages.** New ideas go to the backlog, not into the
|
||||||
|
current stage.
|
||||||
|
6. **The editor grows with the engine.** `oxide-editor` is a first-class
|
||||||
|
deliverable, gaining panels and tools as each stage adds systems.
|
||||||
|
7. **Build tools, not games.** Ship composable building blocks; genre-specific
|
||||||
|
behavior belongs in game code or optional modules.
|
||||||
|
8. **Ship only what's used.** Subsystems are feature-gated **modules** (from
|
||||||
|
Stage 5); an exported game compiles in only the modules it registers.
|
||||||
|
9. **Scalable fidelity.** A data-driven render pass pipeline lets a project run
|
||||||
|
anything from a flat low-poly/stylized look to a full realistic stack, paying
|
||||||
|
only for the passes it enables.
|
||||||
|
10. **Modules are the primary extension point.** A module registers engine logic
|
||||||
|
*and* editor UI *and* its own settings through one documented API; anyone —
|
||||||
|
including AI agents — can write one.
|
||||||
|
|
||||||
|
## Workspace layout
|
||||||
|
|
||||||
|
Oxide is a single Cargo workspace. Crates share version, edition, license, and
|
||||||
|
dependency versions through `[workspace.package]` and `[workspace.dependencies]`
|
||||||
|
in the root `Cargo.toml`.
|
||||||
|
|
||||||
|
```
|
||||||
|
Oxide/
|
||||||
|
├── engine/ # oxide-engine — the core library (all engine systems)
|
||||||
|
├── editor/ # oxide-editor — the in-engine editor binary
|
||||||
|
├── examples/ # oxide-examples — runnable examples, one+ per stage
|
||||||
|
├── tests/ # oxide-tests — integration / end-to-end test harness
|
||||||
|
├── docs/ # this documentation
|
||||||
|
├── assets/ # logos and shared assets
|
||||||
|
├── install.sh # release build + system install
|
||||||
|
├── PLAN.md # authoritative staged roadmap
|
||||||
|
├── README.md # short project overview
|
||||||
|
└── CLAUDE.md # rules and context for AI-assisted development
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crate responsibilities
|
||||||
|
|
||||||
|
- **`oxide-engine`** — the library that contains every engine system. It is
|
||||||
|
organized as one module per system (`math`, and later `scene`, `render`,
|
||||||
|
`physics`, …). Each module is independently usable and re-exports its public
|
||||||
|
types. A `prelude` module collects the most common imports.
|
||||||
|
- **`oxide-editor`** — the binary users run. It depends on `oxide-engine` and
|
||||||
|
builds a UI on top of engine systems (its own window with a placeholder
|
||||||
|
viewport since Stage 2; egui panels from Stage 3). It never contains engine
|
||||||
|
logic itself; it is a consumer of the engine.
|
||||||
|
- **`oxide-examples`** — small, focused programs that each demonstrate one
|
||||||
|
stage's capabilities. They double as manual-review artifacts and as living
|
||||||
|
documentation. `publish = false`; they are never installed.
|
||||||
|
- **`oxide-tests`** — integration tests that exercise the engine the way a real
|
||||||
|
consumer would, including cross-module scenarios and fuzz/property tests.
|
||||||
|
|
||||||
|
## Engine module structure
|
||||||
|
|
||||||
|
Inside `oxide-engine`, each system is a module under `engine/src/`. The pattern,
|
||||||
|
established by the math module, is:
|
||||||
|
|
||||||
|
```
|
||||||
|
engine/src/
|
||||||
|
├── lib.rs # declares modules, defines the prelude
|
||||||
|
├── math/
|
||||||
|
│ ├── mod.rs # module docs + curated `pub use` re-exports
|
||||||
|
│ ├── transform.rs # one type/concept per file, with its own tests
|
||||||
|
│ ├── aabb.rs
|
||||||
|
│ └── ...
|
||||||
|
├── render/ # Stage 2: GPU acquisition + surface clear loop
|
||||||
|
│ ├── mod.rs # RenderError, clear_view, re-exports
|
||||||
|
│ ├── gpu.rs # Gpu (instance/adapter/device/queue)
|
||||||
|
│ └── context.rs # RenderContext (surface, resize, render_frame)
|
||||||
|
└── window/ # Stage 2: window + event loop + App trait
|
||||||
|
├── mod.rs # WindowConfig, `event` re-export module
|
||||||
|
├── app.rs # App trait, AppCtx
|
||||||
|
└── runner.rs # winit ApplicationHandler internals
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules of thumb:
|
||||||
|
|
||||||
|
- **One concept per file.** Prefer many small focused files over a few large
|
||||||
|
ones.
|
||||||
|
- **Tests live with the code.** Each file has a `#[cfg(test)] mod tests` block
|
||||||
|
covering core behavior and edge cases.
|
||||||
|
- **The module root curates the API.** `mod.rs` decides what is public via
|
||||||
|
`pub use`; submodules are private (`mod foo;`, not `pub mod foo;`) unless there
|
||||||
|
is a reason to expose the path.
|
||||||
|
- **The prelude is the front door.** `oxide_engine::prelude::*` brings in the
|
||||||
|
types a typical consumer needs, including re-exported third-party math types so
|
||||||
|
downstream code needs only one dependency for everyday work.
|
||||||
|
|
||||||
|
## Key dependencies
|
||||||
|
|
||||||
|
Chosen for portability and a lightweight footprint:
|
||||||
|
|
||||||
|
| Concern | Crate | Notes |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| Math | [`glam`](https://docs.rs/glam) | SIMD-friendly vectors/quats/matrices; `serde` feature enabled |
|
||||||
|
| Graphics | [`wgpu`](https://docs.rs/wgpu) | Portable across Vulkan/Metal/DX12 (since Stage 2; re-exported as `oxide_engine::wgpu`) |
|
||||||
|
| Windowing | [`winit`](https://docs.rs/winit) | Cross-platform windows and events (since Stage 2; re-exported as `oxide_engine::winit`) |
|
||||||
|
| ECS | [`hecs`](https://docs.rs/hecs) | Lightweight archetypal ECS (Stage 3+) |
|
||||||
|
| Physics | [`rapier3d`](https://docs.rs/rapier3d) | Rigid bodies and collision (Stage 6+) |
|
||||||
|
| Editor UI | [`egui`](https://docs.rs/egui) | Immediate-mode UI (Stage 3+) |
|
||||||
|
| Logging | `log` + `env_logger` | Facade + env-driven backend |
|
||||||
|
| Errors | `anyhow` + `thiserror` | Application vs. library error handling |
|
||||||
|
| Serialization | `serde` + `ron` | Scene/asset (de)serialization (Stage 3+) |
|
||||||
|
|
||||||
|
## Error handling and logging
|
||||||
|
|
||||||
|
- **Libraries (`oxide-engine`)** define their own error types with `thiserror`
|
||||||
|
so callers can match on failure modes.
|
||||||
|
- **Binaries (`oxide-editor`, examples)** use `anyhow` for ergonomic error
|
||||||
|
propagation at the top level.
|
||||||
|
- **Logging** uses the `log` facade throughout the engine; binaries initialize a
|
||||||
|
backend (`env_logger`). Control verbosity with `RUST_LOG`, e.g.
|
||||||
|
`RUST_LOG=oxide_engine=debug cargo run -p oxide-examples --bin math_demo`.
|
||||||
|
|
||||||
|
## Installability
|
||||||
|
|
||||||
|
Oxide must remain installable as a Linux package at all times. `install.sh`
|
||||||
|
builds in release mode and installs the `oxide-editor` binary and assets under a
|
||||||
|
prefix (`/usr/local` by default, overridable with `PREFIX`). Any new installed
|
||||||
|
binary or asset must be reflected in `install.sh` in the same change.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [conventions.md](conventions.md) — coordinate system, units, color space
|
||||||
|
- [development.md](development.md) — workflow, testing, and how stages progress
|
||||||
|
- [`PLAN.md`](../PLAN.md) — the full staged roadmap
|
||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
# Asset Server & Handles
|
||||||
|
|
||||||
|
`oxide_engine::asset` is the engine's central way to load and own external data
|
||||||
|
(meshes, and later textures, audio, fonts, …). It lands in Stage 5 and underpins
|
||||||
|
three later stages: live reload (Stage 10), open-world streaming (Stage 21), and
|
||||||
|
export packing (Stage 16). Putting it in early means later loaders plug into one
|
||||||
|
system instead of each inventing its own.
|
||||||
|
|
||||||
|
## Handles own assets
|
||||||
|
|
||||||
|
The unit of ownership is a [`Handle<T>`]: a typed, reference-counted reference to
|
||||||
|
a loaded asset. It is cheap to clone (an `Arc` bump), and the asset behind it
|
||||||
|
lives exactly as long as at least one handle does. The [`AssetServer`] keeps only
|
||||||
|
a `Weak` reference in its dedup cache, so it never keeps an otherwise-unused
|
||||||
|
asset alive — drop the last handle and the asset is freed.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::asset::{AssetServer, GltfModel, Handle};
|
||||||
|
|
||||||
|
let assets = AssetServer::new(); // built-in loaders (glTF) registered
|
||||||
|
let model: Handle<GltfModel> = assets.load("assets/models/cube.gltf");
|
||||||
|
|
||||||
|
if let Some(model) = model.get() { // Option<Arc<GltfModel>>, None until loaded
|
||||||
|
println!("{} meshes", model.meshes.len());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key handle methods:
|
||||||
|
|
||||||
|
| Method | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `get()` | `Some(Arc<T>)` once loaded, else `None` (cheap clone of the value `Arc`) |
|
||||||
|
| `state()` / `is_loaded()` | lifecycle: `Loading` / `Loaded` / `Failed` |
|
||||||
|
| `wait()` | block until ready; `Some(value)` or `None` on failure |
|
||||||
|
| `error()` | the failure message, if any |
|
||||||
|
| `source()` | the path it was loaded from (in-memory assets have none) |
|
||||||
|
| `ref_count()` | number of live handles (the server holds none) |
|
||||||
|
|
||||||
|
## Deduplication and freeing
|
||||||
|
|
||||||
|
Loading the same path+type twice returns handles to **one** shared asset — the
|
||||||
|
loader runs once:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::asset::{AssetServer, GltfModel, Handle};
|
||||||
|
# let assets = AssetServer::new();
|
||||||
|
let a: Handle<GltfModel> = assets.load("model.gltf");
|
||||||
|
let b: Handle<GltfModel> = assets.load("model.gltf");
|
||||||
|
assert_eq!(a.id(), b.id()); // same underlying asset
|
||||||
|
assert_eq!(assets.live_asset_count(), 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
When the last handle drops, the weak cache entry dies and the asset is collected;
|
||||||
|
`live_asset_count()` prunes such entries as it counts.
|
||||||
|
|
||||||
|
## Synchronous vs background loading
|
||||||
|
|
||||||
|
`load` blocks until the asset is ready. `load_async` returns immediately with a
|
||||||
|
handle in the `Loading` state and fills it on a background thread:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::asset::{AssetServer, GltfModel, Handle};
|
||||||
|
# let assets = AssetServer::new();
|
||||||
|
let handle: Handle<GltfModel> = assets.load_async("big.gltf");
|
||||||
|
// ... do other work while it loads ...
|
||||||
|
let model = handle.wait(); // or poll handle.state()
|
||||||
|
```
|
||||||
|
|
||||||
|
`add(value)` stores an already-constructed, in-memory asset (no path, not
|
||||||
|
deduplicated) and returns a handle to it — useful for procedurally generated or
|
||||||
|
test data.
|
||||||
|
|
||||||
|
## Writing a loader
|
||||||
|
|
||||||
|
Formats are pluggable via the [`AssetLoader`] trait: declare the output type and
|
||||||
|
the extensions, and implement `load`. Register it with
|
||||||
|
`server.register_loader(...)`; the server dispatches by extension and verifies
|
||||||
|
the loader's output type matches the requested `T`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::path::Path;
|
||||||
|
use oxide_engine::asset::{AssetError, AssetLoader};
|
||||||
|
|
||||||
|
struct TextLoader;
|
||||||
|
impl AssetLoader for TextLoader {
|
||||||
|
type Asset = String;
|
||||||
|
fn extensions(&self) -> &'static [&'static str] { &["txt"] }
|
||||||
|
fn load(&self, path: &Path) -> Result<String, AssetError> {
|
||||||
|
std::fs::read_to_string(path).map_err(|e| AssetError::Load {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: e.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let assets = oxide_engine::asset::AssetServer::empty(); // no built-ins
|
||||||
|
assets.register_loader(TextLoader);
|
||||||
|
```
|
||||||
|
|
||||||
|
The built-in [`GltfLoader`] is exactly this pattern wrapping the Stage-4
|
||||||
|
[`load_gltf`] importer; `AssetServer::new()` registers it for `.gltf`/`.glb`.
|
||||||
|
Errors are specific — `NoExtension`, `NoLoader`, `TypeMismatch`, and `Load` — so
|
||||||
|
callers can tell "no loader" from "the file is broken".
|
||||||
|
|
||||||
|
## Live reload foundation
|
||||||
|
|
||||||
|
`reload(path)` re-runs the loader and updates the existing asset **in place**, so
|
||||||
|
every live handle observes the new contents on its next `get()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::asset::{AssetServer, GltfModel, Handle};
|
||||||
|
# let assets = AssetServer::new();
|
||||||
|
let model: Handle<GltfModel> = assets.load("model.gltf");
|
||||||
|
// ... the file changes on disk ...
|
||||||
|
let same = assets.reload::<GltfModel>("model.gltf");
|
||||||
|
assert_eq!(same.id(), model.id()); // same asset, new data
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage 10 wires this to the file watcher to hot-reload changed assets while the
|
||||||
|
editor runs.
|
||||||
|
|
||||||
|
## Asset database — stable, project-relative references
|
||||||
|
|
||||||
|
The [`AssetServer`] loads by *path*, but a scene or UI document must not bake an
|
||||||
|
**absolute** system path into its saved data — that breaks the moment the
|
||||||
|
project moves to another directory or machine, and it is the chief obstacle to a
|
||||||
|
clean game export (Stage 16). The [`AssetDatabase`] is the bridge.
|
||||||
|
|
||||||
|
Every imported asset lives under a **typed subfolder** of the project's
|
||||||
|
`assets/` directory and gets a stable [`AssetUid`]. The database records, per
|
||||||
|
project, the mapping **`AssetUid` ↔ assets-relative path** (e.g.
|
||||||
|
`"fonts/Inter-Regular.ttf"`) in a manifest at the project root
|
||||||
|
(`assets.manifest`). Saved documents reference assets by `AssetUid`; resolving a
|
||||||
|
uid yields the relative path, which combined with the *current* project root
|
||||||
|
gives an absolute path the server loads and deduplicates.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::asset::{AssetDatabase, AssetServer, GltfModel};
|
||||||
|
|
||||||
|
let mut db = AssetDatabase::open(project_root); // reads assets.manifest if present
|
||||||
|
db.scan(); // walk assets/ — register new files, prune missing
|
||||||
|
db.save().unwrap(); // persist any newly-assigned uids
|
||||||
|
|
||||||
|
let uid = db.uid_of("models/cube.glb").unwrap();
|
||||||
|
let server = AssetServer::new();
|
||||||
|
let model = db.load::<GltfModel>(&server, uid); // Option<Handle<GltfModel>>
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the stored mapping is purely relative, a reference resolves to the **same
|
||||||
|
handle** across save/load *and* after the whole project directory moves — open
|
||||||
|
the database from the new location (or call `set_root`) and every uid keeps
|
||||||
|
resolving. The uid layer (rather than referencing by relative path directly)
|
||||||
|
also lets an asset be renamed or moved *within* the project later without
|
||||||
|
breaking references, since the uid travels with the file in the manifest.
|
||||||
|
|
||||||
|
### Typed folders
|
||||||
|
|
||||||
|
[`AssetKind`] fixes each asset's subfolder and the extensions that belong to it:
|
||||||
|
|
||||||
|
| Kind | Folder | Extensions |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| `Font` | `fonts/` | `ttf`, `otf` |
|
||||||
|
| `Texture` | `textures/` | `png`, `jpg`, `jpeg`, `tga`, `bmp`, `dds`, `ktx2` |
|
||||||
|
| `Model` | `models/` | `gltf`, `glb`, `obj` |
|
||||||
|
| `Audio` | `audio/` | `wav`, `ogg`, `mp3`, `flac` |
|
||||||
|
| `Ui` | `ui/` | (recognised by folder — shares `.ron` with scenes) |
|
||||||
|
| `Script` | `scripts/` | `rhai` |
|
||||||
|
|
||||||
|
`AssetKind::classify(path)` infers the kind: the leading folder wins, with the
|
||||||
|
file extension as a fallback for files dropped directly in `assets/`.
|
||||||
|
|
||||||
|
### File operations — rename, move, delete
|
||||||
|
|
||||||
|
The database also *performs* file reorganisation, so the editor's file explorer
|
||||||
|
(and any tool) can rename or move assets **without breaking saved references**
|
||||||
|
— the disk operation and the uid map are updated together:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::asset::{AssetDatabase, AssetUid};
|
||||||
|
# fn demo(db: &mut AssetDatabase, uid: AssetUid) -> Result<(), oxide_engine::asset::AssetDbError> {
|
||||||
|
db.move_asset(uid, "textures/env/brick.png")?; // rename/move; uid unchanged
|
||||||
|
db.move_folder("textures/env", "textures/world")?; // every entry under it follows
|
||||||
|
db.delete_asset(uid)?; // file + entry; uid never reused
|
||||||
|
db.save()?; // persist the new paths
|
||||||
|
# Ok(())
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
All three refuse to touch anything outside `assets/` (`..` is rejected) and
|
||||||
|
refuse to overwrite an existing destination ([`AssetDbError`] has a variant per
|
||||||
|
failure). Kinds are re-classified from the new path, since a move can change
|
||||||
|
the typed folder. Deleting drops the entry but never reuses its uid — a
|
||||||
|
dangling reference stays dangling instead of silently pointing at a new file.
|
||||||
|
|
||||||
|
### Asset-reference fields in the inspector
|
||||||
|
|
||||||
|
A component points at an asset with an [`AssetRef<T>`] field — **not** a live
|
||||||
|
`Handle<T>`. A handle is process-local and not serializable, so persisting one
|
||||||
|
would be wrong; an `AssetRef<T>` is a thin, serializable wrapper over
|
||||||
|
`Option<AssetUid>` that resolves to a handle on demand:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::asset::{AssetRef, AssetServer, AssetDatabase};
|
||||||
|
use oxide_engine::ui::Font;
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
struct Label { font: AssetRef<Font> } // serializes as just the uid
|
||||||
|
|
||||||
|
# fn demo(label: &Label, db: &AssetDatabase, server: &AssetServer) {
|
||||||
|
let handle = label.font.resolve(db, server); // Option<Handle<Font>>
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
Because `AssetRef<T>` round-trips through reflection's RON path, the field is
|
||||||
|
editable in the inspector with **no per-type code**. Its
|
||||||
|
[`type_name`](../engine/src/reflect.rs) is the syntactic spelling
|
||||||
|
`"AssetRef < Font >"`; [`asset_ref_target`] unwraps that to the target type name
|
||||||
|
(`"Font"`), and [`AssetKind::for_handle_target`] maps it to the kind the editor's
|
||||||
|
asset picker filters by — so selecting a UI element and picking a font lists only
|
||||||
|
the assets under `fonts/`. A bare `Handle<T>` field (e.g. on a non-persisted
|
||||||
|
type) is recognised the same way.
|
||||||
|
|
||||||
|
### In the editor
|
||||||
|
|
||||||
|
`EditorState` holds an `asset_db` whenever a project is open: the editor opens
|
||||||
|
and scans it on project open/create and rescans when the file watcher reports
|
||||||
|
changes under `assets/`, so importing an asset is as simple as **dropping the
|
||||||
|
file anywhere under `assets/`** — no separate import step. An asset-reference
|
||||||
|
field in the inspector renders as a **picker** populated from the database,
|
||||||
|
filtered to the field's target kind. New projects are seeded with a bundled
|
||||||
|
default UI font (Inter, SIL OFL) under `fonts/`, referenced by its
|
||||||
|
project-relative path like any other asset.
|
||||||
|
|
||||||
|
The **Project panel** hosts a Unity-style **file explorer** over `assets/`
|
||||||
|
(`oxide_editor::explorer` holds the egui-free behavior layer; the shell only
|
||||||
|
renders it):
|
||||||
|
|
||||||
|
- **breadcrumbs + folder navigation** (double-click a folder, click a crumb);
|
||||||
|
- **➕ New Folder**, and per-row context menus with **Rename** and **Delete**
|
||||||
|
(folders delete only when empty — recursive asset deletion is deliberately
|
||||||
|
not offered);
|
||||||
|
- **drag a row onto a folder** (or the `..` row) to move it;
|
||||||
|
- **drag files in from the OS** to import them into the current folder
|
||||||
|
(copied in under a collision-free name, registered, manifest saved);
|
||||||
|
- **double-click a file** to open it — scripts via the external-editor flow
|
||||||
|
(see [scripting.md](scripting.md)), everything else via `xdg-open`.
|
||||||
|
|
||||||
|
Renames and moves go through the database's uid-preserving file ops, so saved
|
||||||
|
`AssetRef`s keep resolving after any reorganisation; unregistered files
|
||||||
|
(licenses, notes) fall back to plain filesystem operations. File operations
|
||||||
|
act immediately and bypass the undo stack, like the hierarchy's structural
|
||||||
|
edits.
|
||||||
|
|
||||||
|
[`AssetDatabase`]: ../engine/src/asset/database.rs
|
||||||
|
[`AssetDbError`]: ../engine/src/asset/database.rs
|
||||||
|
[`AssetKind`]: ../engine/src/asset/database.rs
|
||||||
|
[`AssetUid`]: ../engine/src/asset/database.rs
|
||||||
|
[`AssetRef<T>`]: ../engine/src/asset/database.rs
|
||||||
|
[`asset_ref_target`]: ../engine/src/asset/database.rs
|
||||||
|
[`AssetKind::for_handle_target`]: ../engine/src/asset/database.rs
|
||||||
|
[`Handle<T>`]: ../engine/src/asset/handle.rs
|
||||||
|
[`AssetServer`]: ../engine/src/asset/server.rs
|
||||||
|
[`AssetLoader`]: ../engine/src/asset/server.rs
|
||||||
|
[`GltfLoader`]: ../engine/src/asset/gltf.rs
|
||||||
|
[`load_gltf`]: ../engine/src/asset/gltf.rs
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Conventions
|
||||||
|
|
||||||
|
Cross-cutting conventions every Oxide system follows. These are decided once,
|
||||||
|
here, so individual systems don't each invent their own.
|
||||||
|
|
||||||
|
## Coordinate system
|
||||||
|
|
||||||
|
Oxide uses a **right-handed** coordinate system, consistent with `glam`'s
|
||||||
|
`*_rh` matrix constructors and the glTF asset format the engine will load.
|
||||||
|
|
||||||
|
In a default (identity) orientation:
|
||||||
|
|
||||||
|
| Axis | Direction | Local accessor on `Transform` |
|
||||||
|
|------|-----------|-------------------------------|
|
||||||
|
| `+X` | right | `Transform::right()` |
|
||||||
|
| `+Y` | up | `Transform::up()` |
|
||||||
|
| `-Z` | forward (the direction a camera/object looks) | `Transform::forward()` |
|
||||||
|
|
||||||
|
So **forward is `-Z`**. This matches the convention used by glTF, OpenGL, and
|
||||||
|
`glam`'s view-matrix helpers, which keeps asset import and camera math
|
||||||
|
consistent.
|
||||||
|
|
||||||
|
`Transform::looking_at(eye, target, up)` produces an orientation whose
|
||||||
|
`forward()` points from `eye` toward `target`.
|
||||||
|
|
||||||
|
## Rotations
|
||||||
|
|
||||||
|
- Rotations are stored as **unit quaternions** (`glam::Quat`), not Euler angles
|
||||||
|
or matrices, to avoid gimbal lock and accumulate cleanly under composition.
|
||||||
|
- Euler-angle helpers (`Quat::from_euler`) are available for authoring, but the
|
||||||
|
canonical stored form is always a quaternion.
|
||||||
|
- Angles are in **radians**. Convert from degrees explicitly at the boundary
|
||||||
|
(`90_f32.to_radians()`).
|
||||||
|
|
||||||
|
## Transform composition
|
||||||
|
|
||||||
|
- Transforms compose **parent-first**: `parent.mul_transform(&child)` yields the
|
||||||
|
child resolved in the parent's space, matching `parent_matrix * child_matrix`.
|
||||||
|
- The effective matrix order is `T * R * S` — scale is applied first, then
|
||||||
|
rotation, then translation.
|
||||||
|
- Composition is **exact** for uniform scale. With non-uniform scale plus
|
||||||
|
rotation the true product is not representable as a single translation /
|
||||||
|
rotation / scale triple, so the result is the closest TRS approximation
|
||||||
|
(re-decomposed from the matrix). Prefer uniform scale in deep hierarchies.
|
||||||
|
|
||||||
|
See [math.md](math.md#transform) for details.
|
||||||
|
|
||||||
|
## Units
|
||||||
|
|
||||||
|
- **Length:** meters. Physics (`rapier3d`, Stage 6) tunes its solver for
|
||||||
|
meter-scale geometry, so the whole engine adopts meters to avoid conversions.
|
||||||
|
- **Time:** seconds (`f32` for per-frame deltas; a fixed timestep drives physics
|
||||||
|
from Stage 6).
|
||||||
|
- **Angles:** radians (see above).
|
||||||
|
- **Mass:** kilograms (Stage 6+).
|
||||||
|
|
||||||
|
## Numeric type
|
||||||
|
|
||||||
|
- The engine is **`f32`-first**. `glam`'s `f32` types are the default throughout;
|
||||||
|
`f64` is used only where a specific algorithm demands it.
|
||||||
|
- Comparisons use explicit epsilons rather than `==` on floats. Helpers and tests
|
||||||
|
use a small tolerance (commonly `1e-4`–`1e-6`) appropriate to the operation.
|
||||||
|
|
||||||
|
## Color and color space
|
||||||
|
|
||||||
|
- `Color` stores **linear** RGBA as `f32`. Lighting and blending math is correct
|
||||||
|
only in linear space, so that is the engine's working space.
|
||||||
|
- Values are nominally in `[0, 1]` but are **not clamped** — values above `1.0`
|
||||||
|
represent HDR / emissive intensity.
|
||||||
|
- Conversions to and from 8-bit **sRGB** (the space of color pickers, image
|
||||||
|
files, and `#RRGGBB` hex) are explicit: `Color::from_srgb_u8`,
|
||||||
|
`Color::from_hex`, `Color::to_srgb_u8`. Never treat raw 8-bit values as linear.
|
||||||
|
|
||||||
|
## Geometry primitives
|
||||||
|
|
||||||
|
- An [`Aabb`](math.md#aabb) is *empty* when any `min` component exceeds the
|
||||||
|
corresponding `max`; `Aabb::EMPTY` is the identity for `union`.
|
||||||
|
- A [`Ray`](math.md#ray) always stores a **normalized** direction, so its
|
||||||
|
parameter `t` is a true distance.
|
||||||
|
- A [`Plane`](math.md#plane) is stored in **Hessian normal form** (`normal·p + d
|
||||||
|
= 0`) with a unit normal; the positive half-space is the side the normal points
|
||||||
|
toward.
|
||||||
|
- A [`Frustum`](math.md#frustum) stores six planes with **inward-facing**
|
||||||
|
normals; a point is inside when it is in the positive half-space of all six.
|
||||||
|
|
||||||
|
## Determinism
|
||||||
|
|
||||||
|
Procedural systems (Stage 11+) must be **deterministic**: the same seed always
|
||||||
|
produces the same output. Tests that need randomness use a small, explicit,
|
||||||
|
seeded PRNG rather than a system RNG, so failures are reproducible.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Development Workflow
|
||||||
|
|
||||||
|
How work flows through the Oxide project: branches, testing gates, documentation,
|
||||||
|
and how a stage progresses from start to sign-off.
|
||||||
|
|
||||||
|
## Branch model
|
||||||
|
|
||||||
|
Oxide uses two long-lived branches:
|
||||||
|
|
||||||
|
| Branch | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `dev` | Integration branch. Everything that builds and passes automated tests lands here first. |
|
||||||
|
| `main` | Stable branch. Only contains work that has been verified — automatically *and*, where relevant, manually approved. |
|
||||||
|
|
||||||
|
### The flow
|
||||||
|
|
||||||
|
1. **Work lands on `dev`.** Any change that can be **fully verified by automated
|
||||||
|
means** (it builds, and its unit / integration / fuzz tests and benchmarks
|
||||||
|
pass) is committed and pushed to `dev` as soon as it is complete and green.
|
||||||
|
|
||||||
|
2. **Manual-test gate before `main`.** If a change *cannot* be fully verified
|
||||||
|
automatically — anything involving the GUI, rendering output, audio, input
|
||||||
|
feel, or otherwise "you have to actually run the engine and look at it" — it
|
||||||
|
stops at `dev`. The maintainer runs it, reviews the behavior, and reports
|
||||||
|
back. Only after explicit approval is that version promoted to `main`.
|
||||||
|
|
||||||
|
3. **Fully-automated changes can go straight to both.** When a change is
|
||||||
|
completely covered by automated tests (e.g. the math module — pure CPU logic
|
||||||
|
with full unit/fuzz/benchmark coverage), it may be pushed to `dev` and `main`
|
||||||
|
together, because the automated suite *is* the sign-off. No manual gate is
|
||||||
|
needed.
|
||||||
|
|
||||||
|
### Deciding which path a change takes
|
||||||
|
|
||||||
|
Ask: **"Can a test prove this works without a human looking at it?"**
|
||||||
|
|
||||||
|
- **Yes** → it can go to `main` as soon as tests pass (via `dev`).
|
||||||
|
- **No** (needs eyes/ears on a running engine) → push to `dev`, request manual
|
||||||
|
testing, wait for approval, then promote to `main`.
|
||||||
|
|
||||||
|
When in doubt, treat it as needing manual testing and leave it on `dev`.
|
||||||
|
|
||||||
|
### Promoting `dev` to `main`
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git checkout main
|
||||||
|
git merge --ff-only dev # or a regular merge if histories diverged
|
||||||
|
git push origin main
|
||||||
|
git checkout dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing protocol
|
||||||
|
|
||||||
|
Every stage must pass all of the following before it is considered done (see also
|
||||||
|
[`PLAN.md`](../PLAN.md)):
|
||||||
|
|
||||||
|
1. **Unit tests** — every module has tests for core behavior and edge cases,
|
||||||
|
living in a `#[cfg(test)] mod tests` block beside the code.
|
||||||
|
2. **Integration tests** — the `oxide-tests` crate runs end-to-end and
|
||||||
|
cross-module scenarios, including fuzz/property tests where appropriate.
|
||||||
|
3. **Example review** — each stage ships at least one runnable example in
|
||||||
|
`oxide-examples`; both the maintainer and the implementer review it.
|
||||||
|
4. **Benchmarks** — performance-sensitive systems have `criterion` benchmarks;
|
||||||
|
regressions against a stage's stated budget block sign-off.
|
||||||
|
5. **Clippy + fmt** — `cargo clippy --all-targets -- -D warnings` and
|
||||||
|
`cargo fmt --check` must be clean. Engine/editor crates use
|
||||||
|
`#![deny(warnings)]`.
|
||||||
|
|
||||||
|
Quick local gate before any commit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt --check && \
|
||||||
|
cargo clippy --all-targets -- -D warnings && \
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation policy
|
||||||
|
|
||||||
|
Documentation is written **as the work is done**, not after:
|
||||||
|
|
||||||
|
- Detailed docs live in [`docs/`](README.md), one topic per file. The top-level
|
||||||
|
`README.md` stays a short overview.
|
||||||
|
- A stage is not complete until its systems are documented in `docs/` — usage
|
||||||
|
(how to call it) **and** inner workings (how/why it works).
|
||||||
|
- When an API changes, update the affected doc and its code snippets in the
|
||||||
|
**same change**, so docs never drift from the code.
|
||||||
|
- Keep the maintained project files current whenever structure, goals, or
|
||||||
|
process change: `CLAUDE.md`, `PLAN.md`, `README.md`, `.gitignore`, and the
|
||||||
|
relevant files in `docs/`.
|
||||||
|
|
||||||
|
## Adding a new stage
|
||||||
|
|
||||||
|
1. Read [`PLAN.md`](../PLAN.md) for the stage's deliverables and test criteria.
|
||||||
|
2. Implement the system as one or more focused modules under `engine/src/`
|
||||||
|
(one concept per file, tests beside the code). Add editor support if the
|
||||||
|
stage calls for it.
|
||||||
|
3. Add at least one example under `examples/src/bin/`.
|
||||||
|
4. Write the stage's documentation under `docs/` and link it from
|
||||||
|
[`docs/README.md`](README.md).
|
||||||
|
5. Ensure the full testing protocol passes.
|
||||||
|
6. Update `PLAN.md` (mark the stage complete), `README.md` (status/features),
|
||||||
|
and — if installed binaries or assets changed — `install.sh`.
|
||||||
|
7. Land on `dev`; promote to `main` per the branch model above.
|
||||||
|
|
||||||
|
## Commit conventions
|
||||||
|
|
||||||
|
- Commit messages are written in the imperative mood and describe *what* and
|
||||||
|
*why*.
|
||||||
|
- Group related changes; keep a commit focused on one logical change where
|
||||||
|
practical.
|
||||||
|
- Co-authorship trailers are added when a commit is produced with AI assistance.
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Editor Extension API
|
||||||
|
|
||||||
|
`oxide_editor::extension` is the editor-side companion to the engine's
|
||||||
|
[`Module`](modules.md) trait. It is the mechanism through which a module
|
||||||
|
contributes the UI it needs the editor to host on its behalf — menu items,
|
||||||
|
dockable panels, viewport tools, component inspectors, and Preferences
|
||||||
|
pages — **without editing the editor's source**.
|
||||||
|
|
||||||
|
This is *the* extension surface for both first-party modules (Stage-7 input
|
||||||
|
binding pages, Stage-9 physics inspectors, Stage-17 ray-traced-audio panels…)
|
||||||
|
and any third-party or AI-authored module.
|
||||||
|
|
||||||
|
## Why a separate trait
|
||||||
|
|
||||||
|
The engine doesn't depend on egui — putting the editor hook on
|
||||||
|
`oxide_engine::app::Module` would pull egui into the engine. Instead a module
|
||||||
|
that wants to participate in the editor implements **two** traits on the same
|
||||||
|
struct:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct AudioPreviewModule;
|
||||||
|
|
||||||
|
impl oxide_engine::app::Module for AudioPreviewModule {
|
||||||
|
fn name(&self) -> &'static str { "audio_preview" }
|
||||||
|
fn build(&self, app: &mut oxide_engine::app::App) {
|
||||||
|
// … register systems, types, asset loaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl oxide_editor::extension::EditorModule for AudioPreviewModule {
|
||||||
|
fn name(&self) -> &'static str { "audio_preview" }
|
||||||
|
fn build_editor(&self, ext: &mut oxide_editor::extension::EditorExtensions) {
|
||||||
|
// … register menu items, panels, inspectors, settings pages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The shared name is how enable/disable in Preferences stays consistent across
|
||||||
|
the two halves: toggling `"audio_preview"` hides the editor contributions and
|
||||||
|
disables the engine systems together.
|
||||||
|
|
||||||
|
## What a module can contribute
|
||||||
|
|
||||||
|
| Kind | Helper | Stage-6 criterion |
|
||||||
|
|------|--------|-------------------|
|
||||||
|
| **Menu items** (`File/New`, `Help/About`, …) | `add_menu_item` | ✔ required |
|
||||||
|
| **Dockable panels** | `add_panel` | ✔ required |
|
||||||
|
| **Viewport tools** (gizmos, brushes) | `add_viewport_tool` | future stages |
|
||||||
|
| **Component inspectors** (by reflection name) | `add_inspector` | future stages |
|
||||||
|
| **Settings pages** (by section name) | `add_settings_page` | ✔ required |
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use oxide_editor::extension::{DockLocation, EditorExtensions, EditorModule};
|
||||||
|
|
||||||
|
struct DemoModule;
|
||||||
|
impl EditorModule for DemoModule {
|
||||||
|
fn name(&self) -> &'static str { "demo" }
|
||||||
|
fn build_editor(&self, ext: &mut EditorExtensions) {
|
||||||
|
ext.add_menu_item("File/Demo…", || { /* open the demo dialog */ });
|
||||||
|
ext.add_panel("Demo Panel", DockLocation::Right, |ui| {
|
||||||
|
ui.label("hello from a module-owned panel");
|
||||||
|
});
|
||||||
|
ext.add_inspector("DemoComponent", |ui| {
|
||||||
|
ui.label("custom editor for DemoComponent");
|
||||||
|
});
|
||||||
|
ext.add_settings_page("demo", "Demo", |ui| {
|
||||||
|
ui.label("module preferences here");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The render closures take only `&mut egui::Ui` in Piece 5 (registration). Piece
|
||||||
|
6 — the docking shell — refines the signatures to pass through the editor's
|
||||||
|
runtime context (scene, selection, asset server, settings). Modules that need
|
||||||
|
shared state today can capture it through interior mutability
|
||||||
|
(`Rc<RefCell<...>>`).
|
||||||
|
|
||||||
|
## How the shell consumes the registry
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_editor::extension::{EditorExtensions, EditorModule, DockLocation};
|
||||||
|
# struct M; impl EditorModule for M {
|
||||||
|
# fn name(&self) -> &'static str { "m" }
|
||||||
|
# fn build_editor(&self, ext: &mut EditorExtensions) {
|
||||||
|
# ext.add_menu_item("File/Open", || {});
|
||||||
|
# ext.add_panel("Inspector", DockLocation::Right, |_| {});
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
let mut ext = EditorExtensions::new();
|
||||||
|
ext.add_module(M);
|
||||||
|
|
||||||
|
// What the shell will do in piece 6:
|
||||||
|
for item in ext.iter_menu_items() {
|
||||||
|
let _ = &item.path; // build the menu tree
|
||||||
|
}
|
||||||
|
for panel in ext.iter_panels() {
|
||||||
|
let _ = (&panel.name, panel.default_dock); // place in dock layout
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Lookups by name are also provided (`has_inspector_for("Transform")`,
|
||||||
|
`has_settings_page_for("audio")`) so the Inspector and Preferences windows can
|
||||||
|
ask "is there a custom editor for this thing?" before rendering a fallback.
|
||||||
|
|
||||||
|
## Attribution and lifecycle
|
||||||
|
|
||||||
|
Every contribution remembers its source module. That gives three lifecycle
|
||||||
|
operations the engine `App` already has and the editor needs to mirror:
|
||||||
|
|
||||||
|
| Operation | Effect |
|
||||||
|
|-----------|--------|
|
||||||
|
| `add_module` | Runs `build_editor`, attributes every contribution to the module, marks enabled. Re-adding replaces the old registration cleanly. |
|
||||||
|
| `set_module_enabled(name, false)` | Contributions stay registered but vanish from every `iter_*` / `has_*` lookup — toggling Preferences is reversible without rebuilding state. |
|
||||||
|
| `remove_module(name)` | Drops every contribution attributed to the module in one shot. |
|
||||||
|
|
||||||
|
Adding contributions outside a module's `build_editor` panics: every entry
|
||||||
|
must be attributable to *some* module, otherwise removal would leave orphans.
|
||||||
|
|
||||||
|
## Inspector / settings-page resolution
|
||||||
|
|
||||||
|
The Inspector panel renders custom editors for components whose type has a
|
||||||
|
registered inspector — keyed by the same name the component is registered
|
||||||
|
under in the [reflection registry](reflection.md). Settings pages plug into the
|
||||||
|
[Preferences framework](settings.md) by matching their `section_name` to the
|
||||||
|
section the module registered. Both lookups respect the enabled flag, so a
|
||||||
|
disabled module's inspector / page disappears even if the underlying section
|
||||||
|
or type is still registered.
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
The whole API is purely about *registration*, so it's directly unit-testable
|
||||||
|
without bringing up egui — tests construct an `EditorExtensions`, add a
|
||||||
|
demo module, and assert via the lookup helpers. The actual rendering of the
|
||||||
|
contributed UI is exercised by the Piece-6 docking shell with a maintainer
|
||||||
|
manual pass; the Stage-6 criterion ("a trivial test module adds a menu item,
|
||||||
|
a panel, and a settings page through the API with no editor-core edits") is
|
||||||
|
covered by the integration test in `tests/src/lib.rs::stage6`.
|
||||||
|
|
||||||
|
[`extension`]: ../editor/src/extension.rs
|
||||||
|
[`EditorExtensions`]: ../editor/src/extension.rs
|
||||||
|
[`EditorModule`]: ../editor/src/extension.rs
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# Editor Shell
|
||||||
|
|
||||||
|
The Stage-6 docking **shell** is the editor's host frame: the top menu bar,
|
||||||
|
the bottom status bar, the dockable panel area, the Preferences window, and
|
||||||
|
the wiring between every Stage-6 framework piece — command stack, project
|
||||||
|
system, settings, file watcher, and the
|
||||||
|
[module → editor extension API](editor-extensions.md).
|
||||||
|
|
||||||
|
Lives in [`oxide_editor::shell`](../editor/src/shell.rs) (the library) with
|
||||||
|
[`oxide-editor`](../editor/src/main.rs) (the binary) acting as glue: open a
|
||||||
|
window, run the egui paint pump, run the 3D viewport, hand events to the
|
||||||
|
shell. Splitting the shell into the library lets it be unit-tested without
|
||||||
|
spinning up a window.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ File Edit View Project Modules Help ⚙ Prefs │ ← menu bar
|
||||||
|
├────────────┬───────────────────────────┬────────────────────┤
|
||||||
|
│ │ │ │
|
||||||
|
│ Hierarchy │ Viewport │ Inspector │
|
||||||
|
│ │ │ │
|
||||||
|
│ ├───────────────────────────┤ │
|
||||||
|
│ │ Project │ Console │ │
|
||||||
|
│ │ │ │
|
||||||
|
├────────────┴───────────────────────────┴────────────────────┤
|
||||||
|
│ Reloaded 3 asset(s) modules: 0 undo: 2 │ ← status bar
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Panels are dockable: drag a tab to re-dock, resize splits, or pop it out as a
|
||||||
|
floating window — provided by [`egui_dock`](https://docs.rs/egui_dock). The
|
||||||
|
default layout is built once in [`Shell::default_dock`]; persisting the user's
|
||||||
|
layout across restarts is a later refinement.
|
||||||
|
|
||||||
|
## What's wired to what
|
||||||
|
|
||||||
|
| Shell surface | Backing system |
|
||||||
|
|---------------|----------------|
|
||||||
|
| File menu → New / Open / Save / Recent / Quit | [`Project`](projects.md) + `RecentProjects` + `Shell::take_quit_request` |
|
||||||
|
| Edit menu → Undo / Redo (Ctrl+Z / Ctrl+Y) | [`CommandStack`](../editor/src/command.rs) |
|
||||||
|
| View menu → Show/Hide panel toggles | dock state |
|
||||||
|
| Project menu → Open project info | `EditorState::project` |
|
||||||
|
| Modules menu → module-contributed items | [`EditorExtensions`](editor-extensions.md) |
|
||||||
|
| Help → About | static info |
|
||||||
|
| Status bar → live hint (last action, errors) | `StatusLine` (timed TTL) |
|
||||||
|
| Status bar → module / undo counters | `EditorExtensions` + `CommandStack` |
|
||||||
|
| Preferences window → settings sections + module on/off | [`Settings`](settings.md) + `EditorExtensions` |
|
||||||
|
| File watcher (on project open) → `AssetServer::reload_path` | [`FileWatcher`](file-watching.md) |
|
||||||
|
|
||||||
|
## Commands and undo
|
||||||
|
|
||||||
|
`Edit` menu shows the labels of the next undo / redo entry; `Ctrl+Z` /
|
||||||
|
`Ctrl+Y` (also `Ctrl+Shift+Z`) drive them. The shortcut router is
|
||||||
|
[`Shell::try_consume_shortcut`] — same path the menu uses, so the unit tests
|
||||||
|
exercise the real flow.
|
||||||
|
|
||||||
|
The first wired commands ([`SetTransformCmd`], [`RenameCmd`]) live in
|
||||||
|
[`oxide_editor::commands`](../editor/src/commands.rs).
|
||||||
|
[`SetTransformCmd::merge`] coalesces consecutive edits to the same entity, so
|
||||||
|
a slider drag (or a future gizmo drag) is **one** undo entry instead of one
|
||||||
|
per frame.
|
||||||
|
|
||||||
|
Structural edits (spawn / despawn / reparent / change mesh) still bypass the
|
||||||
|
stack today — round-tripping a despawn through undo needs stable entity ids,
|
||||||
|
a Stage-7 design step alongside the gizmos.
|
||||||
|
|
||||||
|
## File watcher
|
||||||
|
|
||||||
|
[`Shell::open_project`] (or `create_project`) attaches a
|
||||||
|
[`FileWatcher`](file-watching.md) over the project's `assets/`, `scenes/`, and
|
||||||
|
`scripts/` directories (~150 ms debounce window). The shell's
|
||||||
|
[`frame_tick`](../editor/src/shell.rs) pumps events through
|
||||||
|
[`reload_changed_assets`](file-watching.md) each frame, so editing a file
|
||||||
|
externally hot-reloads any handle that was already loaded. Closing the
|
||||||
|
project tears the watcher down.
|
||||||
|
|
||||||
|
Backends that don't deliver events (some sandboxed CI environments) log a
|
||||||
|
warning and let the editor keep running — the watcher is best-effort.
|
||||||
|
|
||||||
|
## Module integration
|
||||||
|
|
||||||
|
Anything a module registers via the [extension API](editor-extensions.md) is
|
||||||
|
hosted by the shell with no editor-source edits:
|
||||||
|
|
||||||
|
- **Menu items** appear under the `Modules` top menu (shown only when at
|
||||||
|
least one item is registered).
|
||||||
|
- **Panels** appear in the dock as `PanelKind::Custom(name)` tabs, rendered
|
||||||
|
through the module's `FnMut(&mut egui::Ui)` closure.
|
||||||
|
- **Settings pages** + module on/off checkboxes are surfaced in the
|
||||||
|
Preferences window's sidebar.
|
||||||
|
- **Component inspectors** (Stage 7+) will be looked up by reflection name
|
||||||
|
when the Inspector encounters a selection holding that component.
|
||||||
|
|
||||||
|
Disabling a module in Preferences hides every contribution at once but keeps
|
||||||
|
it registered — re-enabling restores it instantly, no shell rebuild.
|
||||||
|
|
||||||
|
## Why a `Shell` library
|
||||||
|
|
||||||
|
A few reasons it lives in `editor/src/shell.rs` rather than `main.rs`:
|
||||||
|
|
||||||
|
- **Unit-testable behavior.** Shortcut routing, project open/close, recent
|
||||||
|
list updates, command stack lifecycle — all exercised without a window.
|
||||||
|
The maintainer's manual pass focuses on what tests *can't* prove: how the
|
||||||
|
UI looks and feels.
|
||||||
|
- **Reusable in tests and future hosts.** A headless reproducer for a UI bug
|
||||||
|
can drive the shell directly; an alternate front-end (web, embedded) could
|
||||||
|
reuse it.
|
||||||
|
- **Separation of concerns.** The binary stays a thin runner — window event
|
||||||
|
loop, 3D viewport, egui paint pump — while the shell owns editor state,
|
||||||
|
layout, and the framework wiring.
|
||||||
|
|
||||||
|
## Viewport camera modes (Stage 7)
|
||||||
|
|
||||||
|
The viewport has two camera schemes; the active one is toggled with
|
||||||
|
**F** (the default binding for the `editor.camera.toggle_flythrough`
|
||||||
|
action) while the cursor is over the Viewport tab. Bindings live in
|
||||||
|
`EditorState::actions` ([`ActionMap`](input.md)) registered with editor
|
||||||
|
defaults at startup; the [Input Bindings](#input-bindings-preferences-page)
|
||||||
|
preferences page exposes them for remapping.
|
||||||
|
|
||||||
|
| Mode | Controls |
|
||||||
|
|------|----------|
|
||||||
|
| **Orbit** (default) | L-drag = orbit · R-drag = pan · scroll = zoom · click = pick |
|
||||||
|
| **Flythrough** | WASD = forward/back + strafe · QE = down/up · Shift = sprint · R-drag = look · scroll = adjust move speed · click = pick |
|
||||||
|
|
||||||
|
Toggling preserves pose: the new camera lands looking at the same view
|
||||||
|
the previous one was showing, so the scene doesn't snap.
|
||||||
|
|
||||||
|
## Input Bindings preferences page (Stage 7 piece 5)
|
||||||
|
|
||||||
|
The Preferences window's `input.bindings` section renders a rich page
|
||||||
|
listing every registered editor action — buttons, 1D axes, 2D axes — with
|
||||||
|
its current bindings. Each binding cell:
|
||||||
|
|
||||||
|
- Clicking it arms a **capture** for that slot. The page shows
|
||||||
|
"Press a key…"; the next non-`Escape` key or mouse button press
|
||||||
|
becomes the binding. `Escape` cancels.
|
||||||
|
- `✕` removes that binding.
|
||||||
|
- `+` (per direction or per action) starts an append capture so the user
|
||||||
|
can add a binding without replacing one.
|
||||||
|
- `↺` (per action) restores that action to its code-defined defaults.
|
||||||
|
- A global **Restore all defaults** button at the top resets every
|
||||||
|
action.
|
||||||
|
|
||||||
|
Edits flow through [`Shell`](../editor/src/shell.rs)'s
|
||||||
|
`try_complete_capture`, which mutates `EditorState::actions`, syncs the
|
||||||
|
new bindings into the `input.bindings` settings section via
|
||||||
|
`sync_action_overrides_to_settings`, and flips a `bindings_dirty` flag.
|
||||||
|
The host runner reads-and-clears the flag each frame and writes the
|
||||||
|
preferences file to `$XDG_CONFIG_HOME/oxide/editor.ron` (or
|
||||||
|
`$HOME/.config/oxide/editor.ron`). On startup the editor reads that
|
||||||
|
file, calls `Settings::import`, and `apply_action_overrides_from_settings`
|
||||||
|
layers the user's remap on top of the defaults — so a remap survives a
|
||||||
|
restart, and removing an action from code never breaks an old file
|
||||||
|
(unknown sections are silently skipped).
|
||||||
|
|
||||||
|
The page is intentionally a built-in Shell feature rather than going
|
||||||
|
through `EditorExtensions::add_settings_page`: it needs to mutate
|
||||||
|
`EditorState::actions` while a capture is in flight, which is more
|
||||||
|
direct from the Shell than through the extension API's `FnMut(&mut Ui)`
|
||||||
|
contract.
|
||||||
|
|
||||||
|
## What's not yet here
|
||||||
|
|
||||||
|
| Feature | Where it lands |
|
||||||
|
|---------|---------------|
|
||||||
|
| Native New/Open dialogs (`rfd` or similar) | Polish; the in-app text-path modals fill the gap today |
|
||||||
|
| ~~3D viewport with its own projection sized to the Viewport tab~~ | ✅ Landed in Stage 7 piece 6b: `FrameContext::viewport_rect` restricts the wgpu viewport and drives the projection aspect; `Viewport::pick` rebases the cursor to tab-local NDC. |
|
||||||
|
| Layout persistence across restarts | After settings sections are richer (Preferences-driven) |
|
||||||
|
| Inspector via reflection-keyed component editors | Stage 7 alongside the gizmos |
|
||||||
|
| Undo/redo for spawn/despawn/reparent | Stage 7 — needs stable entity ids |
|
||||||
|
| Console wired to a real log feed / Stage-10 terminal | Stage 10 |
|
||||||
|
|
||||||
|
[`Shell::default_dock`]: ../editor/src/shell.rs
|
||||||
|
[`Shell::try_consume_shortcut`]: ../editor/src/shell.rs
|
||||||
|
[`Shell::open_project`]: ../editor/src/shell.rs
|
||||||
|
[`SetTransformCmd`]: ../editor/src/commands.rs
|
||||||
|
[`SetTransformCmd::merge`]: ../editor/src/commands.rs
|
||||||
|
[`RenameCmd`]: ../editor/src/commands.rs
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# File Watching
|
||||||
|
|
||||||
|
`oxide_engine::watch` watches directories on disk and emits **debounced**,
|
||||||
|
**deduplicated** change events. It is the Stage-6 foundation for the engine's
|
||||||
|
live-reload story:
|
||||||
|
|
||||||
|
- **Stage 6** — reload changed assets (via [`AssetServer`](assets.md)) so a
|
||||||
|
texture or model edited in an external tool reappears in the running editor
|
||||||
|
without restarting.
|
||||||
|
- **Stage 10** — recompile and hot-swap game scripts using the same event
|
||||||
|
stream and the same debounce logic.
|
||||||
|
- **Editor** — drives the Project panel's "files appeared / disappeared"
|
||||||
|
refresh.
|
||||||
|
|
||||||
|
The same module covers all of these because the hard part — "wait until the
|
||||||
|
filesystem stops twitching, then emit one event per path" — is identical in
|
||||||
|
every case.
|
||||||
|
|
||||||
|
## Why debounce
|
||||||
|
|
||||||
|
Filesystem events are noisy:
|
||||||
|
|
||||||
|
- Most editors save in several syscalls (write the file, rename a temp file
|
||||||
|
into place, chmod) — that is one logical change but several events.
|
||||||
|
- Recursive watches re-fire while a directory's children are being created.
|
||||||
|
- Backends collapse or split events differently across Linux, macOS, and
|
||||||
|
Windows.
|
||||||
|
|
||||||
|
If the engine reloaded on every raw event, one save could re-parse a model many
|
||||||
|
times over. The watcher gathers raw events into a **pending set** keyed by
|
||||||
|
path, then emits one event per path once that path has been **quiet** for a
|
||||||
|
configurable window.
|
||||||
|
|
||||||
|
## Architecture (two layers)
|
||||||
|
|
||||||
|
The module is intentionally split so most behavior is unit-testable without
|
||||||
|
touching real files.
|
||||||
|
|
||||||
|
### `Debouncer` — the pure core
|
||||||
|
|
||||||
|
A plain struct that takes `Instant`s from the caller. Tests drive it through a
|
||||||
|
deterministic timeline; no `sleep`, no flaky timing dependence on the OS event
|
||||||
|
queue.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use oxide_engine::watch::{ChangeKind, Debouncer};
|
||||||
|
|
||||||
|
let mut d = Debouncer::new(Duration::from_millis(100));
|
||||||
|
let t0 = Instant::now();
|
||||||
|
|
||||||
|
d.record("assets/cube.gltf".into(), ChangeKind::Modified, t0);
|
||||||
|
d.record("assets/cube.gltf".into(), ChangeKind::Modified,
|
||||||
|
t0 + Duration::from_millis(20));
|
||||||
|
|
||||||
|
// Still hot — nothing fires.
|
||||||
|
assert!(d.drain_ready(t0 + Duration::from_millis(80)).is_empty());
|
||||||
|
|
||||||
|
// After 100 ms of quiet, one event fires for the path.
|
||||||
|
let ready = d.drain_ready(t0 + Duration::from_millis(130));
|
||||||
|
assert_eq!(ready.len(), 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
Coalescing rules (chosen to match what a reloader downstream cares about):
|
||||||
|
|
||||||
|
| Earlier kind | Newer kind | Emitted kind |
|
||||||
|
|--------------|-----------|--------------|
|
||||||
|
| `Created` | `Modified` | `Created` |
|
||||||
|
| `Removed` | `Modified` | `Created` (file came back) |
|
||||||
|
| anything | `Removed` | `Removed` |
|
||||||
|
| anything else | newer | newer |
|
||||||
|
|
||||||
|
### `FileWatcher` — the real-world wrapper
|
||||||
|
|
||||||
|
Wraps a `notify::RecommendedWatcher` plus a worker thread that drives the
|
||||||
|
debouncer with real time and forwards settled events through an `mpsc` channel.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use std::time::Duration;
|
||||||
|
use oxide_engine::watch::FileWatcher;
|
||||||
|
|
||||||
|
let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
|
||||||
|
watcher.watch("path/to/project/assets")?;
|
||||||
|
|
||||||
|
// In the editor's per-frame tick, drain whatever has settled:
|
||||||
|
while let Ok(event) = events.try_recv() {
|
||||||
|
println!("{:?} at {}", event.kind, event.path.display());
|
||||||
|
}
|
||||||
|
# Ok::<(), oxide_engine::watch::WatchError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
`FileWatcher` watches recursively. Dropping it stops the worker thread and
|
||||||
|
disconnects the receiver — no manual cleanup.
|
||||||
|
|
||||||
|
The quiet window is a knob: too short and you get repeated events from one
|
||||||
|
save; too long and the editor feels laggy. The default Stage-6 wiring uses
|
||||||
|
~150 ms.
|
||||||
|
|
||||||
|
## Asset reload
|
||||||
|
|
||||||
|
`reload_changed_assets` is the wiring between the watcher and the
|
||||||
|
[asset server](assets.md). For each `Created` or `Modified` event it calls
|
||||||
|
`AssetServer::reload_path`, which re-runs the loader for every cached asset at
|
||||||
|
that path and updates the existing handle **in place** — gameplay code holding
|
||||||
|
the handle sees the new contents on its next read.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use std::time::Duration;
|
||||||
|
use oxide_engine::asset::AssetServer;
|
||||||
|
use oxide_engine::watch::{reload_changed_assets, FileWatcher};
|
||||||
|
|
||||||
|
let assets = AssetServer::new();
|
||||||
|
let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
|
||||||
|
watcher.watch("path/to/project/assets")?;
|
||||||
|
|
||||||
|
// Per frame:
|
||||||
|
let batch: Vec<_> = std::iter::from_fn(|| events.try_recv().ok()).collect();
|
||||||
|
let reloaded = reload_changed_assets(&assets, batch);
|
||||||
|
if reloaded > 0 {
|
||||||
|
log::info!("hot-reloaded {} asset(s)", reloaded);
|
||||||
|
}
|
||||||
|
# Ok::<(), oxide_engine::watch::WatchError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
`AssetServer::reload_path` is type-erased on purpose. The cache records, per
|
||||||
|
entry, a function pointer that re-runs the loader for that entry's concrete
|
||||||
|
type, so the watcher can react to a disk change without knowing every asset
|
||||||
|
type at compile time. Paths that are not currently cached return zero work;
|
||||||
|
the next `load` picks up the fresh contents anyway. `Removed` events do **not**
|
||||||
|
invalidate cached handles — gameplay code may want the last-loaded copy to
|
||||||
|
keep working.
|
||||||
|
|
||||||
|
## What this groundwork enables
|
||||||
|
|
||||||
|
| Stage | Builds on |
|
||||||
|
|-------|-----------|
|
||||||
|
| 6 | Editor live-reload of assets; Project panel refresh |
|
||||||
|
| 7 | Watch input-binding config for changes during a session |
|
||||||
|
| 10 | Script hot-reload (same watcher; the reloader recompiles + swaps the module) |
|
||||||
|
| 11 | WGSL shader hot-reload |
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
- **Unit tests** drive `Debouncer` directly with fixed `Instant`s — fast,
|
||||||
|
deterministic, and they cover the coalescing rules exhaustively.
|
||||||
|
- **One tolerant smoke test** writes to a temp dir and polls for an event with
|
||||||
|
a generous deadline (seconds, not milliseconds). On containerized CI without
|
||||||
|
a usable event backend the test prints `SKIP:` and passes — the unit tests
|
||||||
|
already prove the logic is correct, this only checks the OS wiring is
|
||||||
|
connected.
|
||||||
|
|
||||||
|
[`watch`]: ../engine/src/watch.rs
|
||||||
|
[`Debouncer`]: ../engine/src/watch.rs
|
||||||
|
[`FileWatcher`]: ../engine/src/watch.rs
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Getting Started
|
||||||
|
|
||||||
|
This guide takes you from a clean machine to building Oxide, running an example,
|
||||||
|
and running the test and benchmark suites.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Rust stable toolchain.** Install via [rustup](https://rustup.rs):
|
||||||
|
```sh
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
```
|
||||||
|
Oxide tracks the stable channel and sets a minimum supported Rust version
|
||||||
|
(MSRV) in the workspace `Cargo.toml` (`rust-version`).
|
||||||
|
- **Linux** is the primary target. Other platforms are not yet tested.
|
||||||
|
- **A GPU with Vulkan support** (`wgpu` is integrated since Stage 2; on Linux
|
||||||
|
the Vulkan backend is the default). Backend selection can be overridden with
|
||||||
|
the `WGPU_BACKEND` environment variable.
|
||||||
|
|
||||||
|
## Cloning
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://git.houmeres.sk/Houmeres/Oxide.git
|
||||||
|
cd Oxide
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Build the whole workspace (engine, editor, examples, tests):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build # debug
|
||||||
|
cargo build --release # optimized
|
||||||
|
```
|
||||||
|
|
||||||
|
The workspace is a Cargo workspace with these members:
|
||||||
|
|
||||||
|
- `oxide-engine` — the core library
|
||||||
|
- `oxide-editor` — the in-engine editor binary
|
||||||
|
- `oxide-examples` — runnable examples (one or more per stage)
|
||||||
|
- `oxide-tests` — the integration test harness
|
||||||
|
|
||||||
|
## Running the editor
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-editor --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The editor opens its own window with a placeholder viewport (Stage 2); editor
|
||||||
|
panels arrive with the systems they edit in later stages. Quit with `Ctrl+Q`.
|
||||||
|
Raw input logging is visible with `RUST_LOG=debug`.
|
||||||
|
|
||||||
|
## Running examples
|
||||||
|
|
||||||
|
Examples live in `examples/src/bin/` and each is a standalone binary:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-examples --bin math_demo
|
||||||
|
```
|
||||||
|
|
||||||
|
| Example | Stage | Description |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| `math_demo` | 1 | Prints a tour of transforms, bounds, ray/plane queries, frustum culling, color, and value ranges |
|
||||||
|
| `hello_window` | 2 | Opens a window cleared to a configurable color — keys `1`–`5` pick presets, `Space` cycles, `Esc` quits; FPS logged once per second |
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test # the whole workspace
|
||||||
|
cargo test -p oxide-engine # engine unit tests only
|
||||||
|
cargo test -p oxide-tests # integration tests only
|
||||||
|
```
|
||||||
|
|
||||||
|
Unit tests live next to the code they test (a `#[cfg(test)] mod tests` block in
|
||||||
|
each module). End-to-end and cross-cutting tests live in the `oxide-tests` crate.
|
||||||
|
|
||||||
|
## Running benchmarks
|
||||||
|
|
||||||
|
Performance-sensitive systems use [criterion](https://github.com/bheisler/criterion.rs):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo bench -p oxide-engine
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage 1 ships the `transform` benchmark, which includes `compose_1m` (the Stage 1
|
||||||
|
budget is 1,000,000 transform compositions in under 10 ms).
|
||||||
|
|
||||||
|
## Lint and format
|
||||||
|
|
||||||
|
Both must be clean before any change is committed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo fmt --check
|
||||||
|
```
|
||||||
|
|
||||||
|
The engine and editor crates are compiled with `#![deny(warnings)]`, so warnings
|
||||||
|
are hard errors.
|
||||||
|
|
||||||
|
## Installing to the system (Linux)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
chmod +x install.sh
|
||||||
|
./install.sh # installs to /usr/local
|
||||||
|
PREFIX=$HOME/.local ./install.sh # custom prefix
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds in release mode and installs the `oxide-editor` binary plus assets.
|
||||||
|
To uninstall:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo rm /usr/local/bin/oxide-editor
|
||||||
|
sudo rm -rf /usr/local/share/oxide
|
||||||
|
```
|
||||||
+403
@@ -0,0 +1,403 @@
|
|||||||
|
# Input
|
||||||
|
|
||||||
|
Stage 7 reference for `oxide_engine::input` — the engine's input
|
||||||
|
abstraction. Three layers live here today:
|
||||||
|
|
||||||
|
- **Piece 1: [`InputState`](#raw-input-state)** — the raw per-frame
|
||||||
|
snapshot (keyboard / mouse / cursor / scroll, with edge detection).
|
||||||
|
- **Piece 2: [`Binding`] + [`ActionMap`](#named-actions-and-remapping)** —
|
||||||
|
named actions (e.g. `"Jump"`) bound to one or more physical inputs, with
|
||||||
|
defaults, runtime remapping, and RON-persistable user overrides.
|
||||||
|
- **Piece 3: [`AxisBinding`] + [`Axis2DBinding`](#directional-axes)** —
|
||||||
|
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.
|
||||||
|
|
||||||
|
The editor pieces (flythrough camera, bindings preferences page, transform
|
||||||
|
gizmos) layer on top of these.
|
||||||
|
|
||||||
|
For the raw [`WindowEvent`](windowing.md) vocabulary the runner pumps from,
|
||||||
|
see [windowing.md](windowing.md).
|
||||||
|
|
||||||
|
## Raw input state
|
||||||
|
|
||||||
|
### Why a separate layer
|
||||||
|
|
||||||
|
Game code wants three distinct things from a physical key:
|
||||||
|
|
||||||
|
- **The press edge** — fires *once* on the frame a key first goes down. A
|
||||||
|
jump fires here.
|
||||||
|
- **The release edge** — fires *once* on the frame a key comes back up. A
|
||||||
|
charged shot fires here.
|
||||||
|
- **The held state** — true every frame between press and release. A sprint
|
||||||
|
modifier reads this.
|
||||||
|
|
||||||
|
Reading these straight off `WindowEvent::KeyboardInput` is doable but error-
|
||||||
|
prone: OS key auto-repeat re-sends `Pressed` on every repeat, focus loss can
|
||||||
|
leave keys "held" with no matching release, and a `CursorMoved` carries no
|
||||||
|
delta unless the consumer remembers the previous position. `InputState`
|
||||||
|
solves all of that in one place, and its semantics are unit-tested.
|
||||||
|
|
||||||
|
## How the runner uses it
|
||||||
|
|
||||||
|
The windowing [`run`](windowing.md) loop owns one `InputState` and:
|
||||||
|
|
||||||
|
1. Pumps every incoming [`WindowEvent`](windowing.md) into it via
|
||||||
|
`InputState::handle_event` **before** any callback sees the event, so
|
||||||
|
`ctx.input()` in `WindowApp::event` already reflects the event being
|
||||||
|
delivered.
|
||||||
|
2. Calls `WindowApp::update` — game logic reads `ctx.input()` to query the
|
||||||
|
accumulated state for the frame.
|
||||||
|
3. After `update` returns, calls `InputState::end_frame` to roll edges and
|
||||||
|
per-frame deltas off. Held state and the cursor anchor persist.
|
||||||
|
|
||||||
|
The result: in `update`, edges describe what happened "since the previous
|
||||||
|
frame" and held state is "right now".
|
||||||
|
|
||||||
|
## Reading input from a `WindowApp`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::winit::event::MouseButton;
|
||||||
|
use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct MyApp;
|
||||||
|
|
||||||
|
impl WindowApp for MyApp {
|
||||||
|
fn update(&mut self, ctx: &mut AppCtx<'_>) {
|
||||||
|
let input = ctx.input();
|
||||||
|
|
||||||
|
if input.pressed(KeyCode::Space) {
|
||||||
|
// Fires once, on the frame Space went down.
|
||||||
|
}
|
||||||
|
if input.held(KeyCode::ShiftLeft) {
|
||||||
|
// True every frame Shift is down.
|
||||||
|
}
|
||||||
|
if input.released(KeyCode::Escape) {
|
||||||
|
ctx.request_exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-drag pans by the mouse delta accumulated this frame.
|
||||||
|
if input.mouse_held(MouseButton::Right) {
|
||||||
|
let _delta = input.mouse_delta(); // physical pixels
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll is in line-equivalent units (touchpad pixels are normalized
|
||||||
|
// so wheels and trackpads report on the same scale).
|
||||||
|
let _zoom_amount = input.scroll().y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Edge semantics, in detail
|
||||||
|
|
||||||
|
`InputState` keeps three sets per device (held / pressed / released) and
|
||||||
|
applies these rules:
|
||||||
|
|
||||||
|
- `press_key(k)` — if `k` was **not** already held, both `held` and
|
||||||
|
`pressed` add it. If it was already held (OS auto-repeat), `pressed` is
|
||||||
|
unchanged. The one-shot press edge fires exactly once per real keypress.
|
||||||
|
- `release_key(k)` — `held` removes `k`; `released` adds `k`. The release
|
||||||
|
edge fires whether or not the key was previously tracked as held, so the
|
||||||
|
occasional "release without matching press" the OS delivers (focus
|
||||||
|
changes, alt-tab) still produces a usable signal.
|
||||||
|
- `end_frame()` — clears `pressed` and `released` (and the per-frame mouse
|
||||||
|
delta + scroll). `held` and the cursor anchor are untouched.
|
||||||
|
- `WindowEvent::Focused(false)` — every currently-held key and mouse button
|
||||||
|
is force-released (released-edge fires for each), so a key held when the
|
||||||
|
user alt-tabbed away cannot remain stuck after the window comes back.
|
||||||
|
|
||||||
|
Mouse buttons mirror the keyboard rules exactly. Cursor + delta and scroll
|
||||||
|
use the same end-of-frame reset.
|
||||||
|
|
||||||
|
## Cursor and mouse delta
|
||||||
|
|
||||||
|
Cursor position is stored as physical pixels relative to the window. The
|
||||||
|
**delta** is the sum of the segment vectors between `set_cursor` calls
|
||||||
|
*within the frame*, not the gross displacement from the first event. The
|
||||||
|
first `set_cursor` after construction (or after `forget_cursor` /
|
||||||
|
`WindowEvent::CursorLeft`) seeds the anchor without contributing to the
|
||||||
|
delta — so the first frame the cursor enters the window never produces a
|
||||||
|
phantom jump.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frame 1: cursor enters at (100, 100) → delta = (0, 0)
|
||||||
|
Frame 2: moves (100,100)→(105,98)→(108,95) → delta = (8, -5)
|
||||||
|
Frame 3: no movement → delta = (0, 0)
|
||||||
|
(cursor still at (108, 95))
|
||||||
|
```
|
||||||
|
|
||||||
|
`add_mouse_delta(dx, dy)` exists for relative-motion sources that don't
|
||||||
|
go through `CursorMoved` (a future `DeviceEvent::MouseMotion` pump, a
|
||||||
|
pointer-lock toggle, or a synthesized test). It layers on top of the
|
||||||
|
cursor-based delta.
|
||||||
|
|
||||||
|
## Scroll
|
||||||
|
|
||||||
|
Scroll is reported in **line-equivalent units**: wheel notches arrive as
|
||||||
|
`LineDelta` and pass through unchanged; trackpad pixel deltas are divided
|
||||||
|
by a fixed pixels-per-line constant (40) so a touchpad gesture and a wheel
|
||||||
|
notch produce comparable numbers.
|
||||||
|
|
||||||
|
## Testing inputs directly
|
||||||
|
|
||||||
|
The mutator API (`press_key`, `release_mouse`, `set_cursor`,
|
||||||
|
`add_mouse_delta`, `add_scroll`, `forget_cursor`, `release_all_held`) is
|
||||||
|
the same path `handle_event` uses, and is intentionally public. Tests
|
||||||
|
should call it directly rather than try to fabricate `WindowEvent`s —
|
||||||
|
winit 0.30's `DeviceId` cannot be constructed outside a real event loop,
|
||||||
|
so most input variants are unreachable from synthesized events. The
|
||||||
|
mutators are unit-tested and exercised end-to-end by `stage7` integration
|
||||||
|
tests in the [`tests`](../tests) crate.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
|
||||||
|
let mut input = InputState::new();
|
||||||
|
input.press_key(KeyCode::Space);
|
||||||
|
assert!(input.pressed(KeyCode::Space));
|
||||||
|
assert!(input.held(KeyCode::Space));
|
||||||
|
|
||||||
|
input.end_frame();
|
||||||
|
assert!(!input.pressed(KeyCode::Space));
|
||||||
|
assert!(input.held(KeyCode::Space));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Named actions and remapping
|
||||||
|
|
||||||
|
`InputState` answers "is `KeyCode::Space` down?". Game code shouldn't ask
|
||||||
|
that question: physical keys are user-settings territory, and querying
|
||||||
|
them directly couples gameplay to a fixed keyboard layout. `ActionMap`
|
||||||
|
adds the indirection — game code asks "is `\"Jump\"` engaged?" and the
|
||||||
|
map resolves it to whatever the user (or the program's default) has
|
||||||
|
bound.
|
||||||
|
|
||||||
|
### The data model
|
||||||
|
|
||||||
|
An action carries two binding lists:
|
||||||
|
|
||||||
|
- **`defaults`** — the bindings registered from code at startup. They
|
||||||
|
never change at runtime.
|
||||||
|
- **`current`** — the bindings actually queried each frame. Initially a
|
||||||
|
clone of `defaults`; remapped by the settings screen; restored by the
|
||||||
|
"Restore defaults" button.
|
||||||
|
|
||||||
|
Persistence saves only `current`. On reload, the program first registers
|
||||||
|
actions from code (defaults reappear from source), then applies the saved
|
||||||
|
overrides on top. Actions that vanished from code never break an old
|
||||||
|
settings file — they're silently skipped.
|
||||||
|
|
||||||
|
### Setting up actions
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::winit::event::MouseButton;
|
||||||
|
use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
|
||||||
|
let mut actions = ActionMap::new();
|
||||||
|
actions
|
||||||
|
.register("Jump", [Binding::Key(KeyCode::Space)])
|
||||||
|
.register(
|
||||||
|
"Sprint",
|
||||||
|
[
|
||||||
|
Binding::Key(KeyCode::ShiftLeft),
|
||||||
|
Binding::Key(KeyCode::ShiftRight),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.register("Fire", [Binding::Mouse(MouseButton::Left)]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-bind on either axis is supported: an action can list several
|
||||||
|
bindings (the `Sprint` example), and one physical key can drive several
|
||||||
|
actions (e.g. `Space` → both `"Jump"` and `"Confirm"`).
|
||||||
|
|
||||||
|
### Querying actions
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
# let mut actions = ActionMap::new();
|
||||||
|
# actions.register("Jump", [Binding::Key(KeyCode::Space)]);
|
||||||
|
# let input = InputState::new();
|
||||||
|
if actions.action_pressed("Jump", &input) {
|
||||||
|
// Fires once, on the frame Jump becomes engaged.
|
||||||
|
}
|
||||||
|
if actions.action_held("Jump", &input) {
|
||||||
|
// True every frame Jump is engaged (at least one binding held).
|
||||||
|
}
|
||||||
|
if actions.action_released("Jump", &input) {
|
||||||
|
// Fires once, when the last engaged binding releases.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Action edges have **hysteresis at the action level**, not the binding
|
||||||
|
level: pressing a second binding while the action is already engaged does
|
||||||
|
not retrigger `action_pressed`, and releasing one binding while another
|
||||||
|
is still held does not fire `action_released`. The edge fires only on
|
||||||
|
the action's transition between engaged and disengaged. (See the
|
||||||
|
[`action_pressed`](../engine/src/input/action.rs) rustdoc for the
|
||||||
|
precise definition.)
|
||||||
|
|
||||||
|
Querying an unregistered action returns `false` everywhere — never a
|
||||||
|
panic — so typo'd action names are graceful.
|
||||||
|
|
||||||
|
### Runtime remap
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
# let mut actions = ActionMap::new();
|
||||||
|
# actions.register("Jump", [Binding::Key(KeyCode::Space)]);
|
||||||
|
// A bindings preferences page calls these — game code is untouched.
|
||||||
|
actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]);
|
||||||
|
actions.add_binding("Jump", Binding::Key(KeyCode::Space)); // restore as alt
|
||||||
|
actions.remove_binding("Jump", Binding::Key(KeyCode::KeyW));
|
||||||
|
actions.clear_bindings("Jump"); // make Jump temporarily unbindable
|
||||||
|
actions.restore_defaults("Jump"); // ↩ user's defaults
|
||||||
|
actions.restore_all_defaults(); // ↩ everything
|
||||||
|
```
|
||||||
|
|
||||||
|
### Persistence via the Stage-6 settings framework
|
||||||
|
|
||||||
|
`ActionOverrides` is the serializable projection of an `ActionMap`'s
|
||||||
|
current bindings, and it derives `Default + Serialize + Deserialize` so
|
||||||
|
it plugs straight into `Settings::register::<ActionOverrides>(name)` —
|
||||||
|
no framework code changes needed. The whole cycle:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
|
||||||
|
// One-time setup at startup.
|
||||||
|
let mut actions = ActionMap::new();
|
||||||
|
actions.register("Jump", [Binding::Key(KeyCode::Space)]);
|
||||||
|
|
||||||
|
let mut settings = Settings::new();
|
||||||
|
settings.register::<ActionOverrides>("input.bindings");
|
||||||
|
|
||||||
|
// User remap → write into Settings → export to disk.
|
||||||
|
actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]);
|
||||||
|
*settings.get_mut::<ActionOverrides>("input.bindings").unwrap() =
|
||||||
|
actions.overrides();
|
||||||
|
let on_disk = settings.export(); // RON map, persist however you like
|
||||||
|
|
||||||
|
// On the next launch, after re-registering defaults from code:
|
||||||
|
settings.import(&on_disk);
|
||||||
|
actions.apply_overrides(settings.get::<ActionOverrides>("input.bindings").unwrap());
|
||||||
|
// Jump is now bound to W again.
|
||||||
|
```
|
||||||
|
|
||||||
|
The `apply_overrides` step is order-independent with respect to which
|
||||||
|
actions the file knows about: unknown names are skipped, and registered
|
||||||
|
actions absent from the file keep their defaults.
|
||||||
|
|
||||||
|
## Directional axes
|
||||||
|
|
||||||
|
Buttons answer "is this engaged?". Movement and camera control want a
|
||||||
|
**direction with magnitude**. `AxisBinding` (1D, returns `f32`) and
|
||||||
|
`Axis2DBinding` (2D, returns `Vec2`) compose direction sets of
|
||||||
|
`Binding`s into those values. They live in the same [`ActionMap`] as
|
||||||
|
button actions but in **separate name spaces**, so `"Move"` can be a 2D
|
||||||
|
axis and `"MoveSlower"` a button without conflict — and a `"Move"`
|
||||||
|
button can coexist with a `"Move"` axis if a project wants it to.
|
||||||
|
|
||||||
|
### 1D axes
|
||||||
|
|
||||||
|
An `AxisBinding` is a pair of binding sets (one for the +1 direction,
|
||||||
|
one for −1). Any binding held on a side contributes a full unit; if
|
||||||
|
both sides are held simultaneously they cancel to 0 — a "soft brake"
|
||||||
|
the player gets for free.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
|
||||||
|
let mut actions = ActionMap::new();
|
||||||
|
actions.register_axis(
|
||||||
|
"MoveX",
|
||||||
|
AxisBinding::new(
|
||||||
|
[Binding::Key(KeyCode::KeyD)],
|
||||||
|
[Binding::Key(KeyCode::KeyA)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut input = InputState::new();
|
||||||
|
input.press_key(KeyCode::KeyD);
|
||||||
|
assert_eq!(actions.axis("MoveX", &input), 1.0);
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple bindings on the same direction do **not** stack (`D` and `→`
|
||||||
|
both held still reads as `1.0`, not `2.0`) — the axis reports
|
||||||
|
direction, not accumulated input.
|
||||||
|
|
||||||
|
### 2D axes
|
||||||
|
|
||||||
|
`Axis2DBinding::new(right, left, up, down)` composes four direction
|
||||||
|
sets into a `Vec2`. Diagonals are intentionally **not normalized** — a
|
||||||
|
game that wants unit-length movement normalizes at the call site; a
|
||||||
|
game that wants diagonal-faster gets it for free. The cancel-on-both
|
||||||
|
rule applies independently on each axis.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
let mut actions = ActionMap::new();
|
||||||
|
actions.register_axis_2d(
|
||||||
|
"Move",
|
||||||
|
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::KeyW);
|
||||||
|
input.press_key(KeyCode::KeyA);
|
||||||
|
let v = actions.axis_2d("Move", &input);
|
||||||
|
assert_eq!(v, oxide_engine::math::Vec2::new(-1.0, 1.0));
|
||||||
|
// If you want unit-length: `if v != Vec2::ZERO { v.normalize() } else { v }`
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remap, restore, and persistence
|
||||||
|
|
||||||
|
`set_axis_bindings` / `set_axis_2d_bindings` swap the current bindings
|
||||||
|
without renaming the action. `restore_axis_defaults` /
|
||||||
|
`restore_axis_2d_defaults` revert to the code-defined bindings.
|
||||||
|
`restore_all_defaults` covers every action across all three kinds in
|
||||||
|
one call.
|
||||||
|
|
||||||
|
[`ActionOverrides`] carries axis overrides alongside button overrides
|
||||||
|
in three sub-maps. The settings round-trip is identical to the button
|
||||||
|
case — `ActionOverrides` is the same settings-section type:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# let mut actions = ActionMap::new();
|
||||||
|
# let mut settings = Settings::new();
|
||||||
|
settings.register::<ActionOverrides>("input.bindings");
|
||||||
|
*settings.get_mut::<ActionOverrides>("input.bindings").unwrap() =
|
||||||
|
actions.overrides();
|
||||||
|
let on_disk = settings.export(); // axes, axes_2d, and buttons all persist
|
||||||
|
```
|
||||||
|
|
||||||
|
Older settings files written before axes existed (i.e. with no `axes` or
|
||||||
|
`axes_2d` field in the RON) load cleanly — the missing sub-maps
|
||||||
|
deserialize as empty, and registered axes keep their code-defined
|
||||||
|
defaults.
|
||||||
|
|
||||||
|
## Status and what's next
|
||||||
|
|
||||||
|
- **Piece 1 (✅).** Raw `InputState` + runner integration.
|
||||||
|
- **Piece 2 (✅).** Named button action mapping with defaults, multi-bind
|
||||||
|
in either direction, runtime remap, and RON persistence through the
|
||||||
|
Stage-6 settings framework.
|
||||||
|
- **Piece 3 (✅ — this section).** 1D and 2D directional axes composed
|
||||||
|
from `Binding` direction sets, sharing `ActionMap` storage and the
|
||||||
|
same `ActionOverrides` persistence payload.
|
||||||
|
- **Editor pieces (planned).** Flythrough camera using the action map,
|
||||||
|
bindings page in Preferences contributed via the Stage-6 extension API,
|
||||||
|
transform gizmos.
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
# Layers, Groups & Tags
|
||||||
|
|
||||||
|
`oxide_engine::layer` is the engine's shared answer to *"which things interact
|
||||||
|
with which?"*. Instead of every subsystem inventing its own notion of
|
||||||
|
collision groups, render masks, or query filters, they all reference one
|
||||||
|
primitive — the [`LayerMask`] — so a layer named once is honored everywhere.
|
||||||
|
|
||||||
|
This system lands in Stage 5 (Engine Core Framework). The consumers below are
|
||||||
|
wired up in their own stages, but they all build on the types here.
|
||||||
|
|
||||||
|
## The model: one Layer, many Groups
|
||||||
|
|
||||||
|
Oxide follows the **Unity model**, separating two concepts:
|
||||||
|
|
||||||
|
- **[`Layer`]** — *single-valued* membership. Every entity is on exactly **one**
|
||||||
|
of 32 logical layers (its index `0..32`). This is the fast filter slot used by
|
||||||
|
rendering, physics, and queries.
|
||||||
|
- **[`Tags`]** — *multi-valued* gameplay grouping. An entity can be in **any
|
||||||
|
number** of named groups (`"Enemies"`, `"Interactables"`, `"SaveOnExit"`) that
|
||||||
|
game code and scripts look up by name.
|
||||||
|
|
||||||
|
So: *what kind of thing is this, for fast filtering?* → one **Layer**. *What
|
||||||
|
gameplay categories does it belong to?* → many **Groups** (tags). The two never
|
||||||
|
fight over the same slot.
|
||||||
|
|
||||||
|
## `LayerMask`: the filter primitive
|
||||||
|
|
||||||
|
A `LayerMask` is a 32-slot bitset packed into a `u32`. An entity's `Layer` is a
|
||||||
|
single index, but the things that *select* entities carry a **mask** — a
|
||||||
|
camera's visibility, a physics collision filter, a raycast's query mask — so a
|
||||||
|
filter can target several layers at once. An entity is selected when its layer
|
||||||
|
is one of the bits in the filter:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::layer::{Layer, LayerMask};
|
||||||
|
|
||||||
|
let entity = Layer::on(2); // membership: layer 2
|
||||||
|
let filter = LayerMask::NONE.with(1).with(2); // a filter selecting 1 or 2
|
||||||
|
assert!(entity.matches(filter)); // layer 2 is in the mask → selected
|
||||||
|
```
|
||||||
|
|
||||||
|
`a.intersects(b)` (equivalently `(a & b) != 0`) is the universal mask
|
||||||
|
interaction test. Everything else (`with`/`without`/`union`/`intersection`/
|
||||||
|
`complement`, the `|`/`&`/`^`/`!` operators, `iter`) is convenience around that.
|
||||||
|
|
||||||
|
Layer indices run `0..32`. Passing an index `>= 32` panics in every build — a
|
||||||
|
mistake worth catching loudly rather than wrapping silently.
|
||||||
|
|
||||||
|
`Layer` is **node-baked**: `Scene::spawn` auto-attaches `Layer::DEFAULT` (index
|
||||||
|
0) to every entity, so a fresh entity is visible to broad "see everything"
|
||||||
|
filters out of the box.
|
||||||
|
|
||||||
|
## Naming layers: `LayerRegistry`
|
||||||
|
|
||||||
|
Bits are not self-documenting, so a project keeps a [`LayerRegistry`] mapping
|
||||||
|
indices to names. Index 0 is seeded as `"Default"`; the rest are unnamed until
|
||||||
|
assigned. The registry is project-level data (serialized with the project in a
|
||||||
|
later stage), and renaming a layer never moves an entity — only the label
|
||||||
|
changes.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::layer::LayerRegistry;
|
||||||
|
|
||||||
|
let mut registry = LayerRegistry::new();
|
||||||
|
registry.set(1, "Player");
|
||||||
|
registry.set(2, "NPC");
|
||||||
|
|
||||||
|
// Author a filter by name instead of by magic number:
|
||||||
|
let visible_to_camera = registry.mask_of(["Player", "NPC"]);
|
||||||
|
assert_eq!(registry.index_of("Player"), Some(1));
|
||||||
|
```
|
||||||
|
|
||||||
|
The editor seeds a small, generally-useful starter set — `Default`, `UI`,
|
||||||
|
`Player`, `World` — and exposes the registry through its **Layer Names** editor
|
||||||
|
(opened from the inspector's `Layer` dropdown). Because both a camera's
|
||||||
|
visibility mask and a raycast's filter can be built from the same names, the
|
||||||
|
*named layer* is the single source of truth.
|
||||||
|
|
||||||
|
## Gameplay grouping: `Tags` + `GroupRegistry`
|
||||||
|
|
||||||
|
[`Tags`] is the per-entity, string-keyed set that holds an entity's **group**
|
||||||
|
membership — the multi-valued counterpart to its single `Layer`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::layer::Tags;
|
||||||
|
|
||||||
|
let mut tags = Tags::single("Enemy");
|
||||||
|
tags.insert("Flying");
|
||||||
|
assert!(tags.contains("Enemy"));
|
||||||
|
```
|
||||||
|
|
||||||
|
The set of *valid* group names is project-level data in a [`GroupRegistry`], so
|
||||||
|
the editor offers a fixed vocabulary to pick from (predefined, like layers)
|
||||||
|
rather than free-typed strings. Defining or deleting a group only changes that
|
||||||
|
vocabulary — it never touches the tags already on entities:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::layer::GroupRegistry;
|
||||||
|
|
||||||
|
let mut groups = GroupRegistry::new();
|
||||||
|
groups.define("Enemies");
|
||||||
|
groups.define("Interactables");
|
||||||
|
assert!(groups.contains("Enemies"));
|
||||||
|
```
|
||||||
|
|
||||||
|
In the editor, the inspector's **Groups** dropdown is a multi-select of the
|
||||||
|
defined groups (each a checkbox toggling membership in the entity's `Tags`), with
|
||||||
|
an **Edit groups…** entry opening the Groups editor to manage the vocabulary.
|
||||||
|
|
||||||
|
Use a **layer** when something must filter quickly and en masse (rendering,
|
||||||
|
physics, queries). Use a **group** when you need to ask "what gameplay
|
||||||
|
categories is this in?" and an entity may be in several at once.
|
||||||
|
|
||||||
|
## Attaching to entities
|
||||||
|
|
||||||
|
`Layer` is auto-attached; `Tags` is an ordinary ECS component — attach it through
|
||||||
|
the scene's world and query it like anything else:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::layer::{LayerMask, Layer, Tags};
|
||||||
|
|
||||||
|
let mut scene = Scene::new();
|
||||||
|
let guard = scene.spawn("guard", Transform::IDENTITY); // Layer::DEFAULT auto-attached
|
||||||
|
scene.world_mut().insert_one(guard, Layer::on(2)).unwrap(); // move to layer 2
|
||||||
|
scene.world_mut().insert_one(guard, Tags::single("Enemy")).unwrap();
|
||||||
|
|
||||||
|
// Find everything a layer-2 query would hit:
|
||||||
|
let filter = LayerMask::layer(2);
|
||||||
|
for (entity, layer) in scene.world().query::<&Layer>().iter() {
|
||||||
|
if layer.matches(filter) {
|
||||||
|
// ... this entity is selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All five types (`LayerMask`, `LayerRegistry`, `Layer`, `Tags`, `GroupRegistry`)
|
||||||
|
are `serde`-serializable, so layer/group data round-trips through RON and is
|
||||||
|
dual-editable from the editor and from scripts/AI agents like every other engine
|
||||||
|
component.
|
||||||
|
|
||||||
|
## Who consumes this
|
||||||
|
|
||||||
|
| Consumer | Stage | How it uses layers |
|
||||||
|
|----------|-------|--------------------|
|
||||||
|
| Render pass pipeline | 5 | A camera holds a visibility `LayerMask`; only entities whose `Layer` is in it are drawn |
|
||||||
|
| Physics | 9 | A collider's membership + filter masks drive collision groups and sensor/trigger filtering |
|
||||||
|
| Scene queries | 9 | A raycast/shape-cast carries a filter mask tested against candidates' layer |
|
||||||
|
| Editor | 6+ | Single-select `Layer` dropdown + Layer Names editor; multi-select `Groups` dropdown + Groups editor |
|
||||||
|
|
||||||
|
[`LayerMask`]: ../engine/src/layer/mask.rs
|
||||||
|
[`LayerRegistry`]: ../engine/src/layer/registry.rs
|
||||||
|
[`Layer`]: ../engine/src/layer/components.rs
|
||||||
|
[`Tags`]: ../engine/src/layer/components.rs
|
||||||
|
[`GroupRegistry`]: ../engine/src/layer/groups.rs
|
||||||
+353
@@ -0,0 +1,353 @@
|
|||||||
|
# Math & Core Primitives
|
||||||
|
|
||||||
|
The `oxide_engine::math` module is the foundation every other system depends on.
|
||||||
|
It builds on [`glam`](https://docs.rs/glam) for vectors, quaternions, and
|
||||||
|
matrices, and adds the engine's higher-level geometric and utility types.
|
||||||
|
|
||||||
|
This document is the usage reference for the module as delivered in **Stage 1**.
|
||||||
|
For the conventions these types follow (handedness, units, color space), see
|
||||||
|
[conventions.md](conventions.md).
|
||||||
|
|
||||||
|
## Importing
|
||||||
|
|
||||||
|
Everything is available through the module path or the prelude:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::math::{Transform, Aabb, Ray, Plane, Frustum, Color, Rect, Range3};
|
||||||
|
// or, more commonly:
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
```
|
||||||
|
|
||||||
|
The prelude also re-exports the `glam` types you need for everyday work — `Vec2`,
|
||||||
|
`Vec3`, `Vec4`, `Quat`, `Mat3`, `Mat4`, and `EulerRot` — so downstream crates
|
||||||
|
need no direct `glam` dependency.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|------|------|
|
||||||
|
| [`Transform`](#transform) | Placement: translation + rotation + scale |
|
||||||
|
| [`Aabb`](#aabb) | Axis-aligned bounding box (geometry/bounds/culling) |
|
||||||
|
| [`Ray`](#ray) | Origin + normalized direction (picking, queries) |
|
||||||
|
| [`Plane`](#plane) | Infinite plane in Hessian normal form |
|
||||||
|
| [`Frustum`](#frustum) | Six-plane view volume for visibility culling |
|
||||||
|
| [`Color`](#color) | Linear RGBA color with sRGB conversion |
|
||||||
|
| [`Rect`](#rect) | 2D rectangle (UI, viewports, texture regions) |
|
||||||
|
| [`Range3`](#range3) | 3D value range (clamp, lerp, remap) |
|
||||||
|
|
||||||
|
All types are `Copy`, `PartialEq`, and `serde`-(de)serializable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transform
|
||||||
|
|
||||||
|
A 3D affine transform stored in **decomposed** form — `translation` (`Vec3`),
|
||||||
|
`rotation` (`Quat`), and `scale` (`Vec3`) — so each channel stays editable
|
||||||
|
without matrix round-trips. The effective matrix is `T * R * S`.
|
||||||
|
|
||||||
|
### Construction
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let a = Transform::IDENTITY;
|
||||||
|
let b = Transform::from_translation(Vec3::new(0.0, 1.0, 0.0));
|
||||||
|
let c = Transform::from_rotation(Quat::from_rotation_y(90_f32.to_radians()));
|
||||||
|
let d = Transform::from_scale(Vec3::splat(2.0));
|
||||||
|
let e = Transform::from_trs(
|
||||||
|
Vec3::new(1.0, 2.0, 3.0),
|
||||||
|
Quat::from_euler(EulerRot::XYZ, 0.1, 0.2, 0.3),
|
||||||
|
Vec3::splat(1.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
// From / to a 4x4 matrix:
|
||||||
|
let m = e.to_matrix(); // glam::Mat4
|
||||||
|
let back = Transform::from_matrix(m);
|
||||||
|
let affine = e.to_affine(); // glam::Affine3A (cheaper to compose)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Composition and hierarchy
|
||||||
|
|
||||||
|
`mul_transform` composes parent-first, so this is how you resolve a child into
|
||||||
|
its parent's space:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let parent = Transform::from_trs(
|
||||||
|
Vec3::new(10.0, 0.0, 0.0),
|
||||||
|
Quat::from_rotation_y(90_f32.to_radians()),
|
||||||
|
Vec3::splat(2.0),
|
||||||
|
);
|
||||||
|
let child_local = Transform::from_translation(Vec3::new(0.0, 0.0, 1.0));
|
||||||
|
let child_world = parent.mul_transform(&child_local);
|
||||||
|
// child_world.translation == (12, 0, 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
Composition is exact for uniform scale and a closest-fit approximation for
|
||||||
|
non-uniform scale combined with rotation (see
|
||||||
|
[conventions](conventions.md#transform-composition)).
|
||||||
|
|
||||||
|
### Applying a transform
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let t = Transform::from_trs(
|
||||||
|
Vec3::new(1.0, 0.0, 0.0),
|
||||||
|
Quat::from_rotation_z(90_f32.to_radians()),
|
||||||
|
Vec3::splat(2.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let p = t.transform_point(Vec3::new(1.0, 0.0, 0.0)); // affected by T, R, S
|
||||||
|
let v = t.transform_vector(Vec3::new(1.0, 0.0, 0.0)); // R and S only (no translation)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inverse
|
||||||
|
|
||||||
|
`inverse()` returns a transform that undoes this one. It is exact for uniform
|
||||||
|
scale; with any zero scale component the transform is not invertible and the
|
||||||
|
inverse scale will contain infinities (check with `is_finite()`).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let undo = t.inverse();
|
||||||
|
let identity = t.mul_transform(&undo); // ≈ Transform::IDENTITY
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direction and orientation helpers
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let dir_forward = t.forward(); // local -Z
|
||||||
|
let dir_up = t.up(); // local +Y
|
||||||
|
let dir_right = t.right(); // local +X
|
||||||
|
|
||||||
|
// Build an orientation that looks from `eye` toward `target`:
|
||||||
|
let cam = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||||
|
// cam.forward() points at the target. Degenerate (eye == target) → identity rotation.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edge cases handled
|
||||||
|
|
||||||
|
- **Zero scale** → non-invertible; the forward transform is still finite.
|
||||||
|
- **Gimbal-lock orientations** (e.g. ±90° pitch) round-trip through a matrix
|
||||||
|
without losing orthonormality of the basis vectors.
|
||||||
|
- **Degenerate `looking_at`** (eye == target) returns identity rotation rather
|
||||||
|
than producing NaNs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aabb
|
||||||
|
|
||||||
|
An axis-aligned bounding box defined by `min` and `max` corners. Used for bounds,
|
||||||
|
broad-phase overlap, and frustum culling. A box is *empty* when any `min`
|
||||||
|
component exceeds its `max`; `Aabb::EMPTY` is the identity for `union`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let a = Aabb::new(Vec3::new(1.0, 5.0, -2.0), Vec3::new(-1.0, 0.0, 3.0)); // corners auto-sorted
|
||||||
|
let b = Aabb::from_center_half_extents(Vec3::ZERO, Vec3::splat(1.0));
|
||||||
|
let c = Aabb::from_points([Vec3::ZERO, Vec3::new(2.0, -1.0, 4.0), Vec3::new(-3.0, 5.0, 1.0)]);
|
||||||
|
|
||||||
|
// Queries:
|
||||||
|
let center = b.center();
|
||||||
|
let size = b.size();
|
||||||
|
let inside = b.contains_point(Vec3::ZERO);
|
||||||
|
let near = b.closest_point(Vec3::new(5.0, 0.0, 0.0));
|
||||||
|
|
||||||
|
// Set operations:
|
||||||
|
let u = a.union(&b);
|
||||||
|
let i = a.intersection(&b); // Aabb::EMPTY if disjoint
|
||||||
|
let hit = a.intersects(&b); // bool (touching counts)
|
||||||
|
|
||||||
|
// Acceleration-structure metrics:
|
||||||
|
let area = b.surface_area(); // for SAH
|
||||||
|
let vol = b.volume();
|
||||||
|
let pts = b.corners(); // [Vec3; 8]
|
||||||
|
|
||||||
|
// Ray test (slab method): returns entry distance t, or None.
|
||||||
|
let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::X);
|
||||||
|
if let Some(t) = b.ray_intersection(&ray) {
|
||||||
|
let point = ray.at(t);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A ray whose origin is inside the box returns `Some(0.0)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ray
|
||||||
|
|
||||||
|
A half-line with an `origin` and a **normalized** `direction`. Because the
|
||||||
|
direction is unit-length, the parameter `t` in `at(t)` is a true distance.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let r = Ray::new(Vec3::ZERO, Vec3::new(0.0, 5.0, 0.0)); // direction normalized to +Y
|
||||||
|
let r2 = Ray::from_to(Vec3::ZERO, Vec3::new(0.0, 0.0, 10.0));
|
||||||
|
|
||||||
|
let p = r.at(4.0); // origin + direction * 4
|
||||||
|
let valid = r.is_valid(); // false if direction was zero-length
|
||||||
|
let near = r.closest_point(target); // clamped to t >= 0
|
||||||
|
let dist = r.distance_to_point(target);
|
||||||
|
```
|
||||||
|
|
||||||
|
If you pass a zero-length direction, the ray is left degenerate; check
|
||||||
|
`is_valid()` before relying on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plane
|
||||||
|
|
||||||
|
An infinite plane in Hessian normal form: a unit `normal` and a signed distance
|
||||||
|
`d`, such that every point on the plane satisfies `normal·p + d = 0`. The
|
||||||
|
positive half-space is the side the normal points toward.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let p1 = Plane::from_point_normal(Vec3::new(0.0, 2.0, 0.0), Vec3::Y);
|
||||||
|
let p2 = Plane::from_points(Vec3::ZERO, Vec3::X, Vec3::Y); // normal via right-hand rule → +Z
|
||||||
|
let p3 = Plane::new(Vec3::new(0.0, 0.0, 4.0), 8.0); // normalized on construction
|
||||||
|
|
||||||
|
let sd = p1.signed_distance(Vec3::new(0.0, 5.0, 0.0)); // +3.0 (in front)
|
||||||
|
let proj = p1.project_point(Vec3::new(3.0, 7.0, -2.0)); // orthogonal projection onto plane
|
||||||
|
let flipped = p1.flipped(); // same plane, reversed normal
|
||||||
|
|
||||||
|
// Ray test: None if parallel or pointing away.
|
||||||
|
let ray = Ray::new(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y);
|
||||||
|
if let Some(t) = p1.ray_intersection(&ray) {
|
||||||
|
let hit = ray.at(t);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frustum
|
||||||
|
|
||||||
|
A view volume represented by six planes (left, right, bottom, top, near, far),
|
||||||
|
each with its normal pointing **inward**. A point is inside when it lies in the
|
||||||
|
positive half-space of every plane. Built from a combined view-projection matrix
|
||||||
|
via the Gribb–Hartmann method; works for perspective and orthographic
|
||||||
|
projections alike.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let proj = Mat4::perspective_rh(60_f32.to_radians(), 16.0 / 9.0, 0.1, 100.0);
|
||||||
|
let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||||
|
let frustum = Frustum::from_view_projection(proj * view);
|
||||||
|
|
||||||
|
let visible_point = frustum.contains_point(Vec3::ZERO);
|
||||||
|
|
||||||
|
let bb = Aabb::from_center_half_extents(Vec3::ZERO, Vec3::splat(1.0));
|
||||||
|
let visible_box = frustum.intersects_aabb(&bb);
|
||||||
|
|
||||||
|
let visible_sphere = frustum.intersects_sphere(Vec3::ZERO, 1.0);
|
||||||
|
```
|
||||||
|
|
||||||
|
`intersects_aabb` is **conservative**: it never culls a box that is actually
|
||||||
|
visible, though it may very rarely keep one that is just outside a corner. That
|
||||||
|
is the correct trade-off for rendering, where false positives cost a wasted draw
|
||||||
|
but false negatives cause visible pop-out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color
|
||||||
|
|
||||||
|
Linear RGBA color with `f32` channels. The engine works in **linear** space;
|
||||||
|
conversions to/from 8-bit sRGB are explicit. Values may exceed `1.0` to represent
|
||||||
|
HDR/emissive intensity and are not clamped in storage.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let white = Color::WHITE;
|
||||||
|
let custom = Color::rgba(0.2, 0.4, 0.6, 1.0);
|
||||||
|
let from_pdf = Color::from_srgb_u8(135, 206, 235); // sRGB bytes → linear
|
||||||
|
let from_hex = Color::from_hex(0x87CEEB); // #87CEEB → linear
|
||||||
|
|
||||||
|
let bytes = custom.to_srgb_u8(); // [u8; 4] sRGB, clamped to [0,1]
|
||||||
|
let v4 = custom.to_vec4(); // Vec4 [r,g,b,a]
|
||||||
|
let v3 = custom.to_vec3(); // Vec3 rgb
|
||||||
|
let faded = custom.with_alpha(0.5);
|
||||||
|
let mid = Color::BLACK.lerp(Color::WHITE, 0.5); // t clamped to [0,1]
|
||||||
|
```
|
||||||
|
|
||||||
|
Constants: `BLACK`, `WHITE`, `RED`, `GREEN`, `BLUE`, `TRANSPARENT`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rect
|
||||||
|
|
||||||
|
A 2D axis-aligned rectangle for UI, viewports, and texture regions.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let r1 = Rect::new(Vec2::new(4.0, 1.0), Vec2::new(0.0, 5.0)); // corners auto-sorted
|
||||||
|
let r2 = Rect::from_min_size(Vec2::ZERO, Vec2::new(3.0, 4.0));
|
||||||
|
let r3 = Rect::from_center_size(Vec2::ZERO, Vec2::splat(2.0));
|
||||||
|
|
||||||
|
let (w, h) = (r2.width(), r2.height());
|
||||||
|
let size = r2.size();
|
||||||
|
let area = r2.area();
|
||||||
|
let c = r2.center();
|
||||||
|
let empty = Rect::ZERO.is_empty();
|
||||||
|
|
||||||
|
let inside = r2.contains_point(Vec2::ONE);
|
||||||
|
let hit = r2.intersects(&r3);
|
||||||
|
let i = r2.intersection(&r3); // Rect::ZERO if disjoint
|
||||||
|
let u = r2.union(&r3);
|
||||||
|
let near = r2.closest_point(Vec2::new(5.0, -1.0));
|
||||||
|
let grown = r2.expanded(1.0); // negative shrinks
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Range3
|
||||||
|
|
||||||
|
A 3D **value** range — an inclusive `[min, max]` interval per axis. Unlike
|
||||||
|
`Aabb` (which models geometry), `Range3` models a *value range* for clamping,
|
||||||
|
interpolation, and remapping. It deliberately provides `lerp`/`inverse_lerp`/
|
||||||
|
`remap`, which an `Aabb` does not.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let r = Range3::new(Vec3::ZERO, Vec3::splat(100.0));
|
||||||
|
let unit = Range3::UNIT; // [0, 1] per axis
|
||||||
|
let sym = Range3::symmetric(Vec3::splat(2.0)); // [-2, 2] per axis
|
||||||
|
|
||||||
|
let span = r.span();
|
||||||
|
let center = r.center();
|
||||||
|
let clamped = r.clamp(Vec3::new(-5.0, 50.0, 200.0)); // → (0, 50, 100)
|
||||||
|
let inside = r.contains(Vec3::splat(50.0));
|
||||||
|
|
||||||
|
let v = r.lerp(Vec3::splat(0.25)); // NOT clamped — extrapolates outside [0,1]
|
||||||
|
let t = r.inverse_lerp(Vec3::splat(25.0)); // → 0.25 per axis (0.0 on zero-span axes)
|
||||||
|
let remapped = r.remap(Vec3::splat(50.0), &unit); // 50 in [0,100] → 0.5 in [0,1]
|
||||||
|
```
|
||||||
|
|
||||||
|
`inverse_lerp` guards against division by zero: an axis with zero span yields
|
||||||
|
`0.0` rather than `NaN`/`inf`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing and performance
|
||||||
|
|
||||||
|
- **Unit tests** for every type live in that type's source file
|
||||||
|
(`engine/src/math/*.rs`), covering core behavior and edge cases (zero scale,
|
||||||
|
gimbal lock, degenerate rays/planes, empty boxes, zero-span ranges).
|
||||||
|
- **Integration / fuzz tests** live in the `oxide-tests` crate
|
||||||
|
(`stage1::fuzz_transform_chains_stay_stable` builds long random transform
|
||||||
|
chains and verifies stability and inverse round-trips).
|
||||||
|
- **Benchmark:** `cargo bench -p oxide-engine` runs the `transform` benchmark.
|
||||||
|
The Stage 1 budget — 1,000,000 transform compositions in under 10 ms — is met
|
||||||
|
with margin (~5 ms on a typical desktop).
|
||||||
|
|
||||||
|
## Runnable example
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-examples --bin math_demo
|
||||||
|
```
|
||||||
|
|
||||||
|
`examples/src/bin/math_demo.rs` exercises every type above and prints the
|
||||||
|
results — a good place to see the API in use end to end.
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
# App, Modules & Scheduling
|
||||||
|
|
||||||
|
`oxide_engine::app` is where the Stage-5 core framework comes together. An
|
||||||
|
[`App`] owns the shared engine state and a [`Schedule`] of systems; functionality
|
||||||
|
is added by **modules**. This is the spine the rest of the engine plugs into: the
|
||||||
|
engine is *composed* rather than hard-wired, and an exported game compiles in
|
||||||
|
only the modules it registers.
|
||||||
|
|
||||||
|
## The App
|
||||||
|
|
||||||
|
An `App` owns:
|
||||||
|
|
||||||
|
- the active [`Scene`](scene.md),
|
||||||
|
- the shared [`AssetServer`](assets.md),
|
||||||
|
- the [`TypeRegistry`](reflection.md) (dual-editable components),
|
||||||
|
- the project's [`LayerRegistry`](layers.md),
|
||||||
|
- frame [`Time`], and
|
||||||
|
- arbitrary user **resources** (a type-keyed store).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::app::{App, DefaultModules};
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_modules(DefaultModules);
|
||||||
|
app.update(1.0 / 60.0); // advance one frame
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: the application core is `oxide_engine::app::App`. It is intentionally
|
||||||
|
> *not* in the prelude, to avoid clashing with the windowing
|
||||||
|
> [`App`](windowing.md) trait (the per-window event handler). Import it directly.
|
||||||
|
|
||||||
|
### Resources
|
||||||
|
|
||||||
|
Resources are shared singletons addressed by type — the home for state that
|
||||||
|
isn't per-entity (an input map, a physics world, game settings):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::app::App;
|
||||||
|
# let mut app = App::new();
|
||||||
|
app.insert_resource(0u32);
|
||||||
|
*app.get_resource_mut::<u32>().unwrap() += 1;
|
||||||
|
assert_eq!(app.get_resource::<u32>(), Some(&1));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Systems and the schedule
|
||||||
|
|
||||||
|
A **system** is any `FnMut(&mut App)` attached to a [`Schedule`] phase. Phases run
|
||||||
|
in a fixed order each frame; within a phase, systems run in registration order,
|
||||||
|
so behavior is fully deterministic.
|
||||||
|
|
||||||
|
| Phase | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `First` | start-of-frame bookkeeping |
|
||||||
|
| `Input` | gather input (Stage 7) |
|
||||||
|
| `PreUpdate` | engine work before game logic |
|
||||||
|
| `FixedUpdate` | fixed-timestep work; runs **0..n** times per frame (physics, Stage 9) |
|
||||||
|
| `Update` | per-frame game logic |
|
||||||
|
| `PostUpdate` | engine work after game logic |
|
||||||
|
| `Render` | drawing (Stage 5 pipeline onward) |
|
||||||
|
| `Last` | end-of-frame cleanup |
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::app::{App, Schedule};
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.scene.spawn("spinner", Transform::IDENTITY);
|
||||||
|
app.add_system(Schedule::Update, |app| {
|
||||||
|
let dt = app.time.delta;
|
||||||
|
for e in app.scene.entities().collect::<Vec<_>>() {
|
||||||
|
if let Some(mut t) = app.scene.get_mut::<Transform>(e) {
|
||||||
|
t.translation.x += dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Systems get exclusive `&mut App` while running (the schedule is moved out of the
|
||||||
|
app for the duration), so a system can freely read and mutate the scene,
|
||||||
|
resources, and assets.
|
||||||
|
|
||||||
|
### Fixed timestep
|
||||||
|
|
||||||
|
`FixedUpdate` is driven by an accumulator so simulation is frame-rate
|
||||||
|
independent: each `update(dt)` runs as many whole `fixed_delta` steps as the
|
||||||
|
accumulated time allows. The number of steps per frame is **capped** so a long
|
||||||
|
stall (a breakpoint, a hitch) cannot trigger an unbounded catch-up "spiral of
|
||||||
|
death". Set the rate with `app.set_fixed_timestep(seconds)` (default 1/60).
|
||||||
|
|
||||||
|
The whole frame's scheduling overhead is a few dozen nanoseconds even with
|
||||||
|
several systems registered (see `cargo bench -p oxide-engine --bench app`), so it
|
||||||
|
is lost in the noise next to any real per-frame work.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
A [`Module`] is the unit of engine extension. Its [`build`](Module::build) method
|
||||||
|
registers systems, component types, asset loaders, and resources through the
|
||||||
|
`App` facade. Everything registered during `build` is **attributed to the
|
||||||
|
module**, so it can be enabled, disabled, or removed as one unit.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::app::{App, Module, Schedule};
|
||||||
|
|
||||||
|
struct HeartbeatModule;
|
||||||
|
impl Module for HeartbeatModule {
|
||||||
|
fn name(&self) -> &'static str { "heartbeat" }
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.insert_resource(0u64);
|
||||||
|
app.add_system(Schedule::Update, |app| {
|
||||||
|
*app.get_resource_mut::<u64>().unwrap() += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_module(HeartbeatModule);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable / disable / remove
|
||||||
|
|
||||||
|
These power the editor's module management (Stage 6) and the "ship only what you
|
||||||
|
use" principle:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::app::{App, Module, Schedule};
|
||||||
|
# struct HeartbeatModule;
|
||||||
|
# impl Module for HeartbeatModule {
|
||||||
|
# fn name(&self) -> &'static str { "heartbeat" }
|
||||||
|
# fn build(&self, app: &mut App) {}
|
||||||
|
# }
|
||||||
|
# let mut app = App::new();
|
||||||
|
# app.add_module(HeartbeatModule);
|
||||||
|
app.set_module_enabled("heartbeat", false); // systems skipped, nothing removed
|
||||||
|
app.set_module_enabled("heartbeat", true); // resumes
|
||||||
|
app.remove_module("heartbeat"); // systems, types, loaders, resources gone
|
||||||
|
```
|
||||||
|
|
||||||
|
`remove_module` undoes every registration the module made — its systems,
|
||||||
|
reflected types, asset loaders, and resources — leaving no dangling references.
|
||||||
|
Disabling is the cheap, reversible version (systems are skipped but kept).
|
||||||
|
|
||||||
|
### Built-in modules
|
||||||
|
|
||||||
|
`DefaultModules` bundles the engine's standard set:
|
||||||
|
|
||||||
|
- **`core`** — registers the always-present scene component types (`Transform`,
|
||||||
|
`Node`, `Layer`, `Tags`) for reflection, exposing them to the editor and
|
||||||
|
scripts.
|
||||||
|
- **`render`** — registers the renderable `MeshRenderer` component and (as the
|
||||||
|
data-driven render pipeline grows) the render-phase systems.
|
||||||
|
|
||||||
|
Subsystems from Stage 9 on (physics, audio, …) are built as their own
|
||||||
|
feature-gated crates, each exposing a `Module`, so a project pays for them only
|
||||||
|
by registering them.
|
||||||
|
|
||||||
|
[`App`]: ../engine/src/app/mod.rs
|
||||||
|
[`Time`]: ../engine/src/app/mod.rs
|
||||||
|
[`Schedule`]: ../engine/src/app/schedule.rs
|
||||||
|
[`Module`]: ../engine/src/app/module.rs
|
||||||
|
[`Scene`]: ../engine/src/scene/graph.rs
|
||||||
+299
@@ -0,0 +1,299 @@
|
|||||||
|
# Physics (`oxide-physics`) — Stage 9
|
||||||
|
|
||||||
|
Oxide's physics is a **feature-gated module** (`oxide-physics`) built on
|
||||||
|
[`rapier3d`](https://rapier.rs), added to an app through the Stage-5 module
|
||||||
|
system. It is a comprehensive rigid-body system — collision, scene queries,
|
||||||
|
joints, and a kinematic character controller — not a thin wrapper. This document
|
||||||
|
grows piece by piece as Stage 9 lands; it currently covers the **data model and
|
||||||
|
module wiring** (piece 1).
|
||||||
|
|
||||||
|
## Design: the ECS is the source of truth
|
||||||
|
|
||||||
|
A physics object is described by two plain, serializable, reflected components on
|
||||||
|
a scene entity:
|
||||||
|
|
||||||
|
- [`RigidBody`](#rigidbody) — *how* it moves (or that it doesn't).
|
||||||
|
- [`Collider`](#collider) — *what shape* it is, its material, and the
|
||||||
|
[`LayerMask`](layers.md) filtering of what it collides with.
|
||||||
|
|
||||||
|
The rapier simulation world is a **transient resource rebuilt from these
|
||||||
|
components**, never the authoritative store. That has a deliberate payoff: the
|
||||||
|
Stage-8.7 [play-mode snapshot](play-mode.md) captures these components like any
|
||||||
|
other, so **Play** runs the simulation, **Stop** reverts the authored components,
|
||||||
|
and the next **Play** rebuilds the rapier world fresh — with no special-casing.
|
||||||
|
The [`Transform`](scene.md) is the authoritative pose; the simulation writes it
|
||||||
|
back each fixed step (a later piece).
|
||||||
|
|
||||||
|
## Adding physics to an app
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::app::{App, DefaultModules};
|
||||||
|
use oxide_physics::PhysicsModule;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_modules(DefaultModules);
|
||||||
|
app.add_module(PhysicsModule); // registers RigidBody/Collider + PhysicsSettings
|
||||||
|
```
|
||||||
|
|
||||||
|
`PhysicsModule` registers the component types for reflection (so they are
|
||||||
|
dual-editable from the inspector and from scripts/RON, and captured by the play
|
||||||
|
snapshot) and inserts a [`PhysicsSettings`] resource holding the global gravity
|
||||||
|
vector. Like every module it can be enabled, disabled, or removed as a unit, so a
|
||||||
|
game that never uses physics never compiles it in.
|
||||||
|
|
||||||
|
## RigidBody
|
||||||
|
|
||||||
|
The dynamics half of a physics object — attach alongside a `Collider`.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `kind` | `Dynamic` (simulated), `Kinematic` (game-moved, unaffected by forces), or `Static` (immovable world geometry) |
|
||||||
|
| `mass` | Mass in kg; `0` derives it from the collider's `density` |
|
||||||
|
| `linear_damping` / `angular_damping` | Velocity drag (`0` = none) |
|
||||||
|
| `gravity_scale` | Per-body gravity multiplier (`1` normal, `0` floats) |
|
||||||
|
| `ccd` | Continuous collision detection for fast bodies (off by default) |
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_physics::{RigidBody, RigidBodyKind};
|
||||||
|
|
||||||
|
let dynamic = RigidBody::default(); // a fully-simulated body
|
||||||
|
let floor = RigidBody::static_body(); // immovable
|
||||||
|
let platform = RigidBody::kinematic(); // moved by the game
|
||||||
|
```
|
||||||
|
|
||||||
|
## Collider
|
||||||
|
|
||||||
|
The shape + material half — attach on its own for static geometry, or with a
|
||||||
|
`RigidBody` for a moving body. The shape is a flat selector plus dimension fields
|
||||||
|
(mirroring `MeshRenderer`'s `PrimitiveShape`), so the inspector renders a clean
|
||||||
|
combo + drag-values:
|
||||||
|
|
||||||
|
| `shape` | Dimensions used |
|
||||||
|
|---------|-----------------|
|
||||||
|
| `Box` | `half_extents` (per-axis half sizes) |
|
||||||
|
| `Sphere` | `radius` |
|
||||||
|
| `Capsule` | `radius` + `half_height` (axis = local `+Y`) |
|
||||||
|
| `Cylinder` | `radius` + `half_height` (axis = local `+Y`) |
|
||||||
|
|
||||||
|
Material/filter fields: `friction`, `restitution`, `density`, `sensor` (a trigger
|
||||||
|
that reports overlap without resolving contact), and the `membership` / `filter`
|
||||||
|
[`LayerMask`](layers.md)s. Two colliders interact only when each one's
|
||||||
|
`membership` intersects the other's `filter`, so collision groups, triggers, and
|
||||||
|
(later) scene queries all use the engine's one shared filtering primitive.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::math::Vec3;
|
||||||
|
use oxide_engine::layer::LayerMask;
|
||||||
|
use oxide_physics::Collider;
|
||||||
|
|
||||||
|
let ground = Collider::cuboid(Vec3::new(10.0, 0.5, 10.0));
|
||||||
|
let ball = Collider::ball(0.5);
|
||||||
|
let trigger = Collider::cuboid(Vec3::splat(1.0)).as_sensor()
|
||||||
|
.with_layers(LayerMask::layer(0), LayerMask::layer(1)); // only fires for layer 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Convex-hull and triangle-mesh colliders (which need mesh data) are a later
|
||||||
|
Stage-9 piece.
|
||||||
|
|
||||||
|
## Simulation
|
||||||
|
|
||||||
|
`PhysicsModule` installs a [`PhysicsWorld`] resource — the rapier simulation plus
|
||||||
|
an entity ↔ body map — and a `FixedUpdate` system that, each fixed step:
|
||||||
|
|
||||||
|
1. **syncs** the rapier world to the scene (inserts a body+collider for each new
|
||||||
|
physics entity, removes bodies for despawned ones, pushes kinematic targets),
|
||||||
|
2. **steps** rapier by one [`fixed_delta`](modules.md), then
|
||||||
|
3. **writes back** each moved body's pose onto its entity's `Transform`.
|
||||||
|
|
||||||
|
So physics runs whenever the app advances its fixed timestep — including the
|
||||||
|
editor's **Play** mode, which is the first real consumer (Play drops a body,
|
||||||
|
Stop reverts it). Nothing extra is wired: the play snapshot already captures the
|
||||||
|
components.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::app::{App, DefaultModules};
|
||||||
|
# use oxide_engine::math::{Transform, Vec3};
|
||||||
|
# use oxide_physics::{PhysicsModule, RigidBody, Collider};
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_modules(DefaultModules);
|
||||||
|
app.add_module(PhysicsModule);
|
||||||
|
|
||||||
|
let ball = app.scene.spawn("ball", Transform::from_translation(Vec3::new(0.0, 5.0, 0.0)));
|
||||||
|
app.scene.world_mut().insert_one(ball, RigidBody::default()).unwrap();
|
||||||
|
app.scene.world_mut().insert_one(ball, Collider::ball(0.5)).unwrap();
|
||||||
|
|
||||||
|
for _ in 0..120 { app.step(); } // one fixed tick each
|
||||||
|
// the ball has fallen; its Transform.translation.y is now lower
|
||||||
|
```
|
||||||
|
|
||||||
|
### Forces & control
|
||||||
|
|
||||||
|
[`PhysicsWorld`] exposes by-entity control so game code never touches rapier
|
||||||
|
handles: `set_linear_velocity` / `linear_velocity`, `apply_impulse`,
|
||||||
|
`apply_force`, `apply_torque_impulse`, and `wake`. Reach the resource with
|
||||||
|
`app.get_resource_mut::<PhysicsWorld>()`.
|
||||||
|
|
||||||
|
### Collision & trigger events
|
||||||
|
|
||||||
|
When two colliders start or stop touching, the step records a `CollisionEvent`
|
||||||
|
`{ a, b, started, sensor }`. `started` distinguishes **enter** (`true`) from
|
||||||
|
**exit** (`false`); `sensor` distinguishes a **trigger** overlap (one collider is
|
||||||
|
a sensor — nothing was resolved, things pass through) from a **solid contact**.
|
||||||
|
Events accumulate across every fixed sub-step and are cleared at the first step
|
||||||
|
of each frame, so a system reading after `Update` sees them all:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_physics::PhysicsWorld;
|
||||||
|
# fn read(app: &oxide_engine::app::App, player: oxide_engine::scene::Entity) {
|
||||||
|
let physics = app.get_resource::<PhysicsWorld>().unwrap();
|
||||||
|
for ev in physics.trigger_events() {
|
||||||
|
if let Some(other) = ev.other(player) {
|
||||||
|
if ev.started { /* player entered a trigger zone */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
"Stay" (ongoing overlap) is not an event — query the current state with
|
||||||
|
`is_intersecting(a, b)` or `intersecting_pairs()`. Collision filtering uses the
|
||||||
|
collider `membership`/`filter` `LayerMask`s: two colliders interact only when
|
||||||
|
each one's membership intersects the other's filter, so a trigger can be made to
|
||||||
|
fire only for, say, the Player layer.
|
||||||
|
|
||||||
|
### Scene queries
|
||||||
|
|
||||||
|
[`PhysicsWorld`] answers spatial questions against the simulated colliders, all
|
||||||
|
filtered by a `LayerMask` (pass `LayerMask::ALL` to hit anything):
|
||||||
|
|
||||||
|
| Query | Returns |
|
||||||
|
|-------|---------|
|
||||||
|
| `raycast(origin, dir, max_distance, mask)` | first `RayHit { entity, toi, point, normal }` |
|
||||||
|
| `sphere_cast(origin, radius, dir, max_distance, mask)` | first hit of a swept sphere (a "thick raycast") |
|
||||||
|
| `overlap_sphere(center, radius, mask)` | every entity whose collider overlaps the sphere |
|
||||||
|
| `point_overlap(point, mask)` | every entity whose collider contains the point |
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::math::Vec3;
|
||||||
|
# use oxide_engine::layer::LayerMask;
|
||||||
|
# use oxide_physics::PhysicsWorld;
|
||||||
|
# fn pick(physics: &PhysicsWorld) {
|
||||||
|
if let Some(hit) = physics.raycast(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, 100.0, LayerMask::ALL) {
|
||||||
|
// hit.entity / hit.point / hit.normal
|
||||||
|
}
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
During simulation the step keeps the query world in sync. To query the **edited**
|
||||||
|
scene outside Play (e.g. the editor's raycast probe), build a transient world and
|
||||||
|
sync it once: `sync_to_scene(scene)` rebuilds the rapier world from the scene's
|
||||||
|
`Collider`/`RigidBody` components and refreshes the query pipeline **without
|
||||||
|
stepping**, so a following `raycast`/`overlap_*` reflects the current colliders.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::math::Vec3;
|
||||||
|
# use oxide_engine::layer::LayerMask;
|
||||||
|
# use oxide_engine::scene::Scene;
|
||||||
|
# use oxide_physics::PhysicsWorld;
|
||||||
|
# fn probe(scene: &Scene) {
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
world.sync_to_scene(scene); // query-able, no Play / no step
|
||||||
|
let _ = world.raycast(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, 100.0, LayerMask::ALL);
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Joints / constraints
|
||||||
|
|
||||||
|
`add_joint(a, b, kind, anchor_a, anchor_b)` connects two entities' bodies and
|
||||||
|
returns a `JointId` (`remove_joint` undoes it). Both bodies must already be in
|
||||||
|
the simulation (true after the first step in which their components exist). The
|
||||||
|
`JointKind`s and the DOF they leave:
|
||||||
|
|
||||||
|
| Kind | Constraint |
|
||||||
|
|------|-----------|
|
||||||
|
| `Fixed` | a rigid weld — zero relative DOF |
|
||||||
|
| `Spherical` | ball-and-socket — anchors stay coincident, free rotation (3 rot. DOF) |
|
||||||
|
| `Revolute { axis }` | hinge — 1 rotational DOF about `axis` |
|
||||||
|
| `Prismatic { axis }` | slider — 1 translational DOF along `axis` |
|
||||||
|
|
||||||
|
Joints live in the `PhysicsWorld` (not the ECS), so — unlike components — they
|
||||||
|
are not captured by the play snapshot; game/setup code recreates them on each
|
||||||
|
Play. Editor-authored joint *components* await a serializable entity-reference
|
||||||
|
type (backlog).
|
||||||
|
|
||||||
|
### Character controller
|
||||||
|
|
||||||
|
A `CharacterController` component is a **kinematic capsule** — it never reacts to
|
||||||
|
forces. The game asks it to move and the controller resolves that against the
|
||||||
|
world (move-and-slide along walls, auto-step small ledges, snap to ground on
|
||||||
|
slopes, refuse slopes steeper than `max_slope_degrees`), reporting whether the
|
||||||
|
character ended up grounded. The entity carries only a `CharacterController` (no
|
||||||
|
`RigidBody`/`Collider`), so it is never simulated and never self-collides.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::math::Vec3;
|
||||||
|
# use oxide_physics::PhysicsWorld;
|
||||||
|
# fn tick(app: &mut oxide_engine::app::App, player: oxide_engine::scene::Entity, dt: f32) {
|
||||||
|
let desired = Vec3::new(input_x, -9.81 * dt, input_z); // move + gravity
|
||||||
|
let movement = {
|
||||||
|
let physics = app.get_resource::<PhysicsWorld>().unwrap();
|
||||||
|
physics.move_character(&app.scene, player, desired, dt).unwrap()
|
||||||
|
};
|
||||||
|
let mut t = app.scene.local_transform(player).unwrap();
|
||||||
|
t.translation += movement.translation; // apply the resolved move
|
||||||
|
app.scene.set_local_transform(player, t);
|
||||||
|
// movement.grounded → e.g. allow jump
|
||||||
|
# let (input_x, input_z) = (0.0, 0.0);
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
Tune the capsule (`radius`, `half_height`) and the rules (`max_slope_degrees`,
|
||||||
|
`step_offset`, `snap_to_ground`, `skin_width`) on the component. This is the
|
||||||
|
basis reused by the Stage-15 prototyping kit's character.
|
||||||
|
|
||||||
|
### Limitations (current)
|
||||||
|
|
||||||
|
`Transform::scale` is not yet applied to collider dimensions (author the shape at
|
||||||
|
its true size), and bodies are simulated in world space — keep physics bodies at
|
||||||
|
the scene root (or under unscaled parents) for now. Both are lifted in later
|
||||||
|
pieces.
|
||||||
|
|
||||||
|
## Roadmap (Stage 9 pieces)
|
||||||
|
|
||||||
|
1. **Component data model + module wiring** — `RigidBody`, `Collider`,
|
||||||
|
`PhysicsModule`, `PhysicsSettings`. ✅
|
||||||
|
2. **Rapier-backed simulation** — build the world from components, step on
|
||||||
|
`FixedUpdate`, write transforms back, by-entity forces/velocities. ✅
|
||||||
|
3. **Collision groups/masks via `LayerMask`, sensors, collision/trigger
|
||||||
|
events** (enter/exit + stay queries). ✅
|
||||||
|
4. **Scene queries** — raycast, sphere-cast, sphere/point overlap, all
|
||||||
|
`LayerMask`-filtered. ✅
|
||||||
|
5. **Joints/constraints** — fixed, spherical, revolute, prismatic (programmatic
|
||||||
|
API). ✅
|
||||||
|
6. **Kinematic character controller** — capsule move-and-slide, step offset,
|
||||||
|
slope limit, grounded. ✅
|
||||||
|
7. **Examples** — `physics_stack` (boxes settle + ball lands) and
|
||||||
|
`character_capsule` (walk/climb/jump/wall), headless console demos. ✅ (this
|
||||||
|
piece)
|
||||||
|
8. Editor: **(8a)** RigidBody/Collider/CharacterController are addable and
|
||||||
|
editable in the inspector (no per-type code), and `PhysicsModule` runs in
|
||||||
|
**Play** — drop a body, watch it fall, Stop reverts it (on `dev`, awaiting
|
||||||
|
eye-check). **(8b)** collider shape wireframe gizmos in the viewport — box,
|
||||||
|
sphere, capsule, and cylinder outlines, **green** for solid colliders and
|
||||||
|
**amber** for sensors (triggers), with the selected entity drawn thicker.
|
||||||
|
The outline mirrors the simulation, which ignores `Transform::scale`, so it
|
||||||
|
shows the exact shape rapier builds (translation + rotation only). Toggle it
|
||||||
|
from **View ▸ Show Colliders** (on by default). **(8c)** raycast debug viz —
|
||||||
|
a **View ▸ Raycast Probe** toggle (off by default, a debug aid to verify the
|
||||||
|
raycast API and inspect colliders): with it on, a viewport **click** casts the
|
||||||
|
editor camera→cursor ray against the edited scene's colliders (via
|
||||||
|
[`PhysicsWorld::sync_to_scene`](#scene-queries), so it needs no Play) and
|
||||||
|
**freezes** the ray into the world. The viewport then redraws it every frame —
|
||||||
|
the ray in **cyan**, plus on a hit a **magenta** dot at the surface point and
|
||||||
|
a short whisker along the surface normal — so **orbiting the camera reveals it
|
||||||
|
as a real 3D line** (a ray cast from the live camera is otherwise just a point
|
||||||
|
in that same camera's view). ✅
|
||||||
|
|
||||||
|
**Stage 9 is complete** — all pieces are on `main`.
|
||||||
|
|
||||||
|
[`PhysicsSettings`]: #adding-physics-to-an-app
|
||||||
|
[`PhysicsWorld`]: #simulation
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Editor Play Mode (Stage 8.7)
|
||||||
|
|
||||||
|
Play mode lets the editor **run the open scene in place** — play, pause, single-
|
||||||
|
step, and stop — driving the same fixed-timestep
|
||||||
|
[`Schedule`](modules.md) a shipped game uses, so what you see while playing
|
||||||
|
behaves like the real runtime. Mutations made while playing (physics moving
|
||||||
|
bodies, scripts spawning entities) are reverted on **Stop**, so the authored
|
||||||
|
scene is never corrupted.
|
||||||
|
|
||||||
|
This page covers the play-state model, the snapshot/restore that makes Stop
|
||||||
|
safe, and how the host runner drives the engine. The standalone **"Launch"**
|
||||||
|
button (running the *real* exported runtime in a separate process) is a Stage 16
|
||||||
|
follow-up and is **not** part of play mode.
|
||||||
|
|
||||||
|
## The play states
|
||||||
|
|
||||||
|
`oxide_editor::state::PlayState` is a three-state machine held on `EditorState`:
|
||||||
|
|
||||||
|
| State | Meaning | Schedule ticked? |
|
||||||
|
|-------|---------|------------------|
|
||||||
|
| `Editing` | Normal authoring | no |
|
||||||
|
| `Playing` | Running | every frame (`App::update`) |
|
||||||
|
| `Paused` | Frozen, still live | only on **Step** (one fixed tick) |
|
||||||
|
|
||||||
|
Transitions are methods on `EditorState`:
|
||||||
|
|
||||||
|
- `enter_play()` — snapshots the scene and switches to `Playing`. No-op if
|
||||||
|
already running (re-entering must not clobber the original snapshot).
|
||||||
|
- `toggle_pause()` — `Playing` ⇄ `Paused`; no-op while `Editing`.
|
||||||
|
- `stop()` — restores the snapshot, clears the selection and any in-flight gizmo
|
||||||
|
drag (entity handles change on restore), and returns to `Editing`.
|
||||||
|
|
||||||
|
The shell wraps these with status messages and undo-history clearing (see
|
||||||
|
[Controls](#controls)); `is_in_play()` is the "running or paused" predicate the
|
||||||
|
host uses to gate the runtime.
|
||||||
|
|
||||||
|
## Snapshot / restore
|
||||||
|
|
||||||
|
Pressing Play captures a [`SceneSnapshot`](scene.md) of the current scene;
|
||||||
|
pressing Stop restores it. Unlike `Scene::to_ron` (which records only the node-
|
||||||
|
baked `Node`/`Transform`/hierarchy), a snapshot is **registry-aware**: it also
|
||||||
|
serializes every reflected component on each entity, plus the engine-intrinsic
|
||||||
|
non-reflected components (`Tags` and `DisabledComponents`). That makes Stop a
|
||||||
|
**bit-for-bit** revert across the full component set, not just transforms.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::scene::SceneSnapshot;
|
||||||
|
|
||||||
|
let snap = scene.snapshot(®istry); // capture
|
||||||
|
// … play-mode mutations …
|
||||||
|
let scene = snap.restore(®istry)?; // revert (fresh entity handles)
|
||||||
|
```
|
||||||
|
|
||||||
|
Fidelity is bounded by what the [`TypeRegistry`](reflection.md) knows: a
|
||||||
|
component that is neither registered nor one of the two intrinsics is invisible
|
||||||
|
to capture. Modules register their components anyway (that is what makes them
|
||||||
|
editable), so they survive play mode automatically.
|
||||||
|
|
||||||
|
`SceneSnapshot` also exposes `to_ron`/`from_ron`, so the same capture format
|
||||||
|
seeds full scene files in a later stage.
|
||||||
|
|
||||||
|
## Driving the schedule
|
||||||
|
|
||||||
|
The editor holds the live scene in `EditorState.scene`, not in an
|
||||||
|
[`App`](modules.md). On Play the host runner (`oxide_editor::main`) builds a play
|
||||||
|
`App` (currently just `DefaultModules`; Stage 9's physics module and the
|
||||||
|
project's modules will register here too) and, each frame:
|
||||||
|
|
||||||
|
1. **swaps** `state.scene` into `app.scene` (an O(1) move),
|
||||||
|
2. advances the engine, then
|
||||||
|
3. **swaps** the scene back out.
|
||||||
|
|
||||||
|
So the `App` only "holds" the editor scene for the duration of a tick, and
|
||||||
|
`state.scene` stays the single source of truth the inspector, hierarchy, and
|
||||||
|
viewport read between frames. The `App` persists its `Time` and resources across
|
||||||
|
frames (so a physics world accumulates correctly) and is dropped on Stop.
|
||||||
|
|
||||||
|
How far to advance is decided by a small pure function,
|
||||||
|
`oxide_editor::play::tick_for`, kept separate from the (un-testable) GUI runner
|
||||||
|
so the contract is pinned in a unit test:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_editor::play::{tick_for, Tick};
|
||||||
|
|
||||||
|
match tick_for(state.play, step_requested) {
|
||||||
|
Tick::Frame => app.update(dt), // Playing
|
||||||
|
Tick::FixedStep => app.step(), // Paused + Step
|
||||||
|
Tick::Idle => {} // Editing, or Paused with no Step
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`App::step()` (engine) advances **exactly one fixed timestep** — one
|
||||||
|
`FixedUpdate` plus the per-frame phases once, bypassing the accumulator — which
|
||||||
|
is the Step primitive. `App::update(dt)` runs a normal frame, fixed steps driven
|
||||||
|
by the accumulator as usual (see [modules.md](modules.md)).
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
A toolbar below the menu bar exposes **Play / Pause / Step / Stop**, gated by the
|
||||||
|
play state (Pause/Step/Stop enable only while running) with an
|
||||||
|
`Editing`/`PLAYING`/`PAUSED` badge. The viewport additionally draws a coloured
|
||||||
|
border + corner label while running (green = playing, amber = paused) so
|
||||||
|
edit-vs-play is unmistakable.
|
||||||
|
|
||||||
|
Shortcuts:
|
||||||
|
|
||||||
|
- **Ctrl+P** — Play when editing; Pause ⇄ Resume while running.
|
||||||
|
- **Ctrl+.** — Step one fixed tick (while paused).
|
||||||
|
|
||||||
|
Entering Play and pressing Stop both **clear the undo history**: the scene is
|
||||||
|
restored wholesale on Stop, so play-mode edits are deliberately not part of
|
||||||
|
edit-mode undo. The reflection inspector and gizmos stay live while paused (and
|
||||||
|
playing), so a field can be tweaked and the result observed immediately — the
|
||||||
|
payoff of the Stage-8.5 reflection work.
|
||||||
|
|
||||||
|
## Not in play mode (Stage 16)
|
||||||
|
|
||||||
|
A **"Launch standalone"** button that runs the real exported runtime in a
|
||||||
|
separate window/process — the truest-to-ship check — is deferred to Stage 16,
|
||||||
|
where it reuses the export builder rather than the in-editor loop.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Prefabs
|
||||||
|
|
||||||
|
`oxide_engine::prefab` provides **named spawn templates**. A prefab is a thing
|
||||||
|
you can "drop into the scene" that already carries a set of components — a
|
||||||
|
`Cube`, a `Camera`, a `Directional Light` — without the editor or game code
|
||||||
|
hard-coding one spawn path per kind of object.
|
||||||
|
|
||||||
|
## An entity is its components — a prefab is just data
|
||||||
|
|
||||||
|
Oxide has **no parallel "object type" system**. An entity *is* its set of
|
||||||
|
components (see [scene.md](scene.md) and [reflection.md](reflection.md)). A
|
||||||
|
[`Prefab`] is therefore nothing more than a **name** plus a list of
|
||||||
|
**(component name, RON value)** specs, applied on spawn through the
|
||||||
|
[`TypeRegistry`](reflection.md):
|
||||||
|
|
||||||
|
> "Spawn a Cube" = spawn an entity, then set its `MeshRenderer` to a cube.
|
||||||
|
|
||||||
|
Because a spec is the same name-keyed RON the editor and scripts already use,
|
||||||
|
prefabs are **pure data**: serializable, dual-editable, and free of bespoke
|
||||||
|
code. This is what lets the editor's add-menu be **data-driven** — it lists the
|
||||||
|
prefabs in a [`PrefabRegistry`] instead of one hard-coded button per type.
|
||||||
|
|
||||||
|
## The types
|
||||||
|
|
||||||
|
- [`ComponentSpec`] — `{ type_name, ron }`: one component to attach. Build it
|
||||||
|
from a value with [`ComponentSpec::of`] (serializes to RON) or from a raw
|
||||||
|
string with [`ComponentSpec::new`].
|
||||||
|
- [`Prefab`] — `{ name, components }`: the node name plus the specs to apply.
|
||||||
|
Built fluently with [`Prefab::new`] + [`Prefab::with`].
|
||||||
|
- [`PrefabRegistry`] — prefabs keyed by name; the source for an add-menu.
|
||||||
|
|
||||||
|
Every spawned entity already carries the node-baked `Node`, `Transform`, and
|
||||||
|
`Layer` (auto-attached by [`Scene::spawn`](scene.md)); a prefab's specs are
|
||||||
|
layered on top. A spec named `"Transform"` overrides the identity transform
|
||||||
|
`spawn` starts with, so a prefab can place itself.
|
||||||
|
|
||||||
|
## Spawning
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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: 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(); // root entity
|
||||||
|
let child = prefabs.spawn_child("Cube", cube, &mut scene, &types); // parented
|
||||||
|
```
|
||||||
|
|
||||||
|
[`PrefabRegistry::spawn`] returns the new entity, or `None` if the 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). Validate a prefab against a registry up front with
|
||||||
|
[`PrefabRegistry::unknown_specs`], which lists the type names the registry
|
||||||
|
doesn't know — handy for catching authoring typos.
|
||||||
|
|
||||||
|
## Relationship to "multiple of the same component"
|
||||||
|
|
||||||
|
Prefabs spawn **one entity**. Because an archetypal ECS allows only one
|
||||||
|
component of a given type per entity, a prefab that needs several of a thing
|
||||||
|
(e.g. multiple meshes) composes them as **child entities** — spawn the prefab,
|
||||||
|
then `spawn_child` the extras (each a real, gizmo-movable node). See the
|
||||||
|
component-multiplicity notes in `PLAN.md`.
|
||||||
|
|
||||||
|
[`ComponentSpec`]: ../engine/src/prefab.rs
|
||||||
|
[`ComponentSpec::of`]: ../engine/src/prefab.rs
|
||||||
|
[`ComponentSpec::new`]: ../engine/src/prefab.rs
|
||||||
|
[`Prefab`]: ../engine/src/prefab.rs
|
||||||
|
[`Prefab::new`]: ../engine/src/prefab.rs
|
||||||
|
[`Prefab::with`]: ../engine/src/prefab.rs
|
||||||
|
[`PrefabRegistry`]: ../engine/src/prefab.rs
|
||||||
|
[`PrefabRegistry::spawn`]: ../engine/src/prefab.rs
|
||||||
|
[`PrefabRegistry::unknown_specs`]: ../engine/src/prefab.rs
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Project System
|
||||||
|
|
||||||
|
`oxide_engine::project` defines what a game is, on disk: a **project**. A project
|
||||||
|
is a root directory containing a project file plus a defined folder layout. The
|
||||||
|
format lives in the engine (not the editor) because the exported runtime and the
|
||||||
|
Stage-16 packer read it too — the editor just adds the create/open/save UI.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
my-game/
|
||||||
|
├── project.oxide # the project file (RON)
|
||||||
|
├── scenes/ # scene files
|
||||||
|
├── assets/ # meshes, textures, audio, …
|
||||||
|
└── scripts/ # game scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
The project file records the project name, the engine version it was saved with,
|
||||||
|
the enabled [modules](modules.md), and per-project settings.
|
||||||
|
|
||||||
|
## Create, open, save
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::project::Project;
|
||||||
|
|
||||||
|
// Scaffold a new project (creates the folders + project file).
|
||||||
|
let mut project = Project::create("/path/to/my-game", "My Game")?;
|
||||||
|
|
||||||
|
project.enable_module("render");
|
||||||
|
project.save()?;
|
||||||
|
|
||||||
|
// Reopen later — by directory or by the project file path.
|
||||||
|
let project = Project::open("/path/to/my-game")?;
|
||||||
|
assert_eq!(project.name(), "My Game");
|
||||||
|
assert!(project.is_module_enabled("render"));
|
||||||
|
# Ok::<(), oxide_engine::project::ProjectError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
Path helpers (`scenes_dir()`, `assets_dir()`, `scripts_dir()`,
|
||||||
|
`project_file_path()`) resolve locations against the root. `create` refuses to
|
||||||
|
overwrite an existing project; `open` reports `NotFound` when there is no project
|
||||||
|
file.
|
||||||
|
|
||||||
|
## Settings storage
|
||||||
|
|
||||||
|
Per-project settings are stored as **opaque per-section RON blobs** keyed by
|
||||||
|
section name, which keeps the project format independent of any particular
|
||||||
|
settings schema:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::project::Project;
|
||||||
|
# let mut project = Project::create(std::env::temp_dir().join("oxide_doc_proj"), "x").unwrap();
|
||||||
|
project.set_settings_section("editor", "(theme:\"dark\")");
|
||||||
|
assert_eq!(project.settings_section("editor"), Some("(theme:\"dark\")"));
|
||||||
|
```
|
||||||
|
|
||||||
|
The typed settings framework serializes its sections to and from these strings,
|
||||||
|
so a section round-trips through the project file without this module knowing the
|
||||||
|
section's shape.
|
||||||
|
|
||||||
|
## The editor's New / Open dialogs
|
||||||
|
|
||||||
|
The editor's **File ▸ New Project… / Open Project…** dialogs wrap
|
||||||
|
`Project::create`/`Project::open`. Each path field has a **Browse…** button
|
||||||
|
opening the **native folder picker** (`rfd` with the `xdg-portal` backend — one
|
||||||
|
pure-Rust build serves both Wayland and X11 through `xdg-desktop-portal`). The
|
||||||
|
dialog runs on a helper thread so the editor keeps rendering while it is up;
|
||||||
|
the picked folder lands back in the field, which stays hand-editable — if no
|
||||||
|
portal service is running the picker simply doesn't appear and the typed path
|
||||||
|
still works.
|
||||||
|
|
||||||
|
## Recent projects
|
||||||
|
|
||||||
|
`RecentProjects` is a small most-recently-used list, persisted globally as an
|
||||||
|
editor preference (not inside any project). It de-duplicates and caps:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::project::RecentProjects;
|
||||||
|
|
||||||
|
let mut recent = RecentProjects::new(10);
|
||||||
|
recent.record("/path/to/my-game");
|
||||||
|
// recent.save("~/.config/oxide/recent.ron")?; / RecentProjects::load(...)
|
||||||
|
assert_eq!(recent.entries().len(), 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Who consumes this
|
||||||
|
|
||||||
|
| Consumer | Stage | Use |
|
||||||
|
|----------|-------|-----|
|
||||||
|
| Editor | 6 | New/Open/Save Project UI, recent list, Project panel/asset browser |
|
||||||
|
| File watcher | 6 | watches `scenes/`, `assets/`, `scripts/` for live reload |
|
||||||
|
| Settings framework | 6 | persists per-project sections into the project file |
|
||||||
|
| Exported runtime / packer | 16 | reads the layout + enabled modules to bundle the game |
|
||||||
|
|
||||||
|
[`Project`]: ../engine/src/project.rs
|
||||||
|
[`RecentProjects`]: ../engine/src/project.rs
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Reflection / Type Registry
|
||||||
|
|
||||||
|
`oxide_engine::reflect` is the backbone of the engine's **dual-editable types**
|
||||||
|
principle: every component should be readable and writable from the editor, from
|
||||||
|
scripts, and from external tools through *one* representation — without each of
|
||||||
|
those callers knowing the concrete Rust type.
|
||||||
|
|
||||||
|
The [`TypeRegistry`] is that bridge. It lands in Stage 5; the editor inspector
|
||||||
|
(Stage 6) and the scripting layer (Stage 10) are its first real consumers.
|
||||||
|
|
||||||
|
## The idea
|
||||||
|
|
||||||
|
ECS components are concrete Rust types. An inspector panel or a script engine,
|
||||||
|
however, only has a *name* (`"Transform"`) and some text — they cannot name the
|
||||||
|
type at the call site. The registry closes that gap: register a type once, and
|
||||||
|
afterwards address it generically by name.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::reflect::TypeRegistry;
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let mut registry = TypeRegistry::new();
|
||||||
|
registry.register::<Transform>("Transform");
|
||||||
|
registry.register::<Node>("Node");
|
||||||
|
```
|
||||||
|
|
||||||
|
`register::<T>(name)` requires `T: Component + Serialize + DeserializeOwned`.
|
||||||
|
Internally it stores a small set of monomorphized function pointers, so there is
|
||||||
|
no per-call generic dispatch and no `dyn Any` downcasting at the boundary.
|
||||||
|
|
||||||
|
## Generic read / write
|
||||||
|
|
||||||
|
Once registered, any holder of the name can round-trip a component on an entity
|
||||||
|
as RON text — exactly what a generic inspector or a script needs:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::reflect::TypeRegistry;
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# let mut registry = TypeRegistry::new();
|
||||||
|
# registry.register::<Transform>("Transform");
|
||||||
|
let mut scene = Scene::new();
|
||||||
|
let e = scene.spawn("thing", Transform::IDENTITY);
|
||||||
|
|
||||||
|
// Read generically...
|
||||||
|
let text = registry.get_ron(scene.world(), e, "Transform").unwrap();
|
||||||
|
// ...edit the text (a script or the inspector would)...
|
||||||
|
// ...and write it back — no concrete type at the call site.
|
||||||
|
registry.set_ron(scene.world_mut(), e, "Transform", &text).unwrap();
|
||||||
|
```
|
||||||
|
|
||||||
|
`set_ron` inserts the component if absent or replaces it if present, so the same
|
||||||
|
call covers "add component" and "edit component".
|
||||||
|
|
||||||
|
## Enumerating an entity's components
|
||||||
|
|
||||||
|
A generic inspector renders an entity by asking the registry which *registered*
|
||||||
|
component types it currently carries — sorted, and again with no concrete types
|
||||||
|
in hand:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::reflect::TypeRegistry;
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# let mut registry = TypeRegistry::new();
|
||||||
|
# registry.register::<Transform>("Transform");
|
||||||
|
# registry.register::<Node>("Node");
|
||||||
|
# let mut scene = Scene::new();
|
||||||
|
# let e = scene.spawn("thing", Transform::IDENTITY);
|
||||||
|
for name in registry.components_on(scene.world(), e) {
|
||||||
|
let _ron = registry.get_ron(scene.world(), e, name).unwrap();
|
||||||
|
// ... render an editor for `name` from its RON text
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`has` and `remove` round out the surface (check for / detach a component by
|
||||||
|
name). Errors are specific — [`UnknownType`], [`NoSuchEntity`], [`Missing`], and
|
||||||
|
[`Parse`] — so callers can tell "no such type" from "bad text".
|
||||||
|
|
||||||
|
## Per-field reflection — `#[derive(Reflect)]` (Stage 8.5)
|
||||||
|
|
||||||
|
Whole-value reflection is enough for serialization and scripts, but a
|
||||||
|
Unity/Godot-style inspector needs to see a component's **named fields** so it
|
||||||
|
can render one widget per field. The [`Reflect`] trait provides that, and
|
||||||
|
`#[derive(Reflect)]` generates it:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::reflect::Reflect;
|
||||||
|
|
||||||
|
#[derive(Reflect, serde::Serialize, serde::Deserialize)]
|
||||||
|
struct Timer {
|
||||||
|
pub repeating: bool,
|
||||||
|
pub duration: f32,
|
||||||
|
#[reflect(skip)]
|
||||||
|
pub elapsed: f32, // runtime state — not an authored field
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut t = Timer { repeating: true, duration: 2.5, elapsed: 0.0 };
|
||||||
|
|
||||||
|
// Enumerate fields (name + syntactic type) — what an inspector iterates.
|
||||||
|
for field in t.fields() {
|
||||||
|
let _value_ron = t.get_field(field.name); // Some("true"), Some("2.5"), …
|
||||||
|
// pick a widget from `field.type_name`: "bool" → checkbox, "f32" → drag, …
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit one field without touching the rest; round-trips as RON.
|
||||||
|
t.set_field("duration", "9.0").unwrap();
|
||||||
|
```
|
||||||
|
|
||||||
|
Selection rules (deliberate, matching the engine's *public-fields-are-the-
|
||||||
|
editable-surface* convention):
|
||||||
|
|
||||||
|
- **Only `pub` fields** are reflected. Private fields are implementation detail.
|
||||||
|
- `#[reflect(skip)]` excludes a public field (e.g. runtime-only state).
|
||||||
|
- Each reflected field must be `serde`-serializable — get/set round-trip through
|
||||||
|
RON, the same representation the whole-value path uses.
|
||||||
|
|
||||||
|
[`FieldInfo::type_name`] is the field type's *syntactic* spelling (`"f32"`,
|
||||||
|
`"bool"`, `"Vec3"`, `"Handle < Font >"`). The generic inspector dispatches a
|
||||||
|
widget on it and falls back to a raw RON editor for types it does not recognize.
|
||||||
|
Per-field errors are [`UnknownField`] and [`FieldParse`].
|
||||||
|
|
||||||
|
The derive lives in the small `oxide-engine-derive` proc-macro crate and is
|
||||||
|
re-exported as `oxide_engine::reflect::Reflect` (the macro shares its name with
|
||||||
|
the trait, the same way `serde`'s `Serialize` does). This is the spine of the
|
||||||
|
reflection-driven editor inspector and the dual-editable-types principle — a new
|
||||||
|
component becomes editor- and script-editable with `#[derive(Reflect)]` plus one
|
||||||
|
registration line, no per-type editor code.
|
||||||
|
|
||||||
|
### Through the registry: fields by type name + entity
|
||||||
|
|
||||||
|
A type registered with `register_reflected::<T>("Name")` (instead of plain
|
||||||
|
`register`) exposes its fields through the [`TypeRegistry`] too, so the editor
|
||||||
|
can reach a field given only a **type name + entity** — no concrete type at the
|
||||||
|
call site:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::reflect::TypeRegistry;
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# let mut registry = TypeRegistry::new();
|
||||||
|
registry.register_reflected::<Transform>("Transform");
|
||||||
|
# let mut scene = Scene::new();
|
||||||
|
# let e = scene.spawn("thing", Transform::IDENTITY);
|
||||||
|
for field in registry.field_infos(scene.world(), e, "Transform").unwrap() {
|
||||||
|
let _ron = registry.get_field(scene.world(), e, "Transform", field.name).unwrap();
|
||||||
|
// render a widget from field.type_name, write edits back with set_field(...)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Transform` and `Node` are registered reflected by default. Field access on a
|
||||||
|
whole-value-only type returns [`NotReflected`]. Whole-value (`get_ron`/`set_ron`)
|
||||||
|
and per-field (`field_infos`/`get_field`/`set_field`) coexist: the registry
|
||||||
|
addresses *types* by name; per-field reaches *fields* within a value.
|
||||||
|
|
||||||
|
### Enum fields — `#[derive(ReflectEnum)]`
|
||||||
|
|
||||||
|
Per-field reflection tells the inspector a field's *type name* but not, for an
|
||||||
|
enum-typed field, which values it may take. `#[derive(ReflectEnum)]` (unit
|
||||||
|
variants only) exposes the variant list so the inspector renders a dropdown
|
||||||
|
instead of a free-text RON box:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::reflect::{ReflectEnum, TypeRegistry};
|
||||||
|
|
||||||
|
#[derive(ReflectEnum, serde::Serialize, serde::Deserialize)]
|
||||||
|
enum Facing { North, East, South, West }
|
||||||
|
|
||||||
|
let mut registry = TypeRegistry::new();
|
||||||
|
registry.register_enum::<Facing>("Facing");
|
||||||
|
assert_eq!(registry.enum_variants("Facing"), Some(["North","East","South","West"].as_slice()));
|
||||||
|
```
|
||||||
|
|
||||||
|
Each variant name is valid RON for that unit variant, so a chosen name writes
|
||||||
|
straight back through `set_field`. An enum is a field *type*, not a component, so
|
||||||
|
`register_enum` is independent of component registration.
|
||||||
|
|
||||||
|
## Who owns the registry
|
||||||
|
|
||||||
|
The registry is owned by the app / module system (Stage 5): each module
|
||||||
|
registers the component types it introduces, so the editor and scripts can reach
|
||||||
|
every type any module added. Because names are the identity used in serialized
|
||||||
|
data and UI, keep them stable across versions.
|
||||||
|
|
||||||
|
[`TypeRegistry`]: ../engine/src/reflect.rs
|
||||||
|
[`UnknownType`]: ../engine/src/reflect.rs
|
||||||
|
[`NoSuchEntity`]: ../engine/src/reflect.rs
|
||||||
|
[`Missing`]: ../engine/src/reflect.rs
|
||||||
|
[`Parse`]: ../engine/src/reflect.rs
|
||||||
|
[`Reflect`]: ../engine/src/reflect.rs
|
||||||
|
[`FieldInfo::type_name`]: ../engine/src/reflect.rs
|
||||||
|
[`UnknownField`]: ../engine/src/reflect.rs
|
||||||
|
[`FieldParse`]: ../engine/src/reflect.rs
|
||||||
|
[`NotReflected`]: ../engine/src/reflect.rs
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Render Context & GPU Setup
|
||||||
|
|
||||||
|
Stage 2 reference for `oxide_engine::render` — how the engine acquires the
|
||||||
|
GPU and drives a window surface. For the event loop that calls into this each
|
||||||
|
frame, see [windowing.md](windowing.md).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Stage 2 rendering is deliberately minimal: acquire the GPU, configure the
|
||||||
|
window surface, and clear it to a configurable color every frame. Meshes,
|
||||||
|
materials, and passes arrive in Stage 4+. The module still establishes the
|
||||||
|
two long-lived types every later stage builds on:
|
||||||
|
|
||||||
|
- **`Gpu`** — instance, adapter, and the device/queue pair. Everything that
|
||||||
|
touches the GPU goes through these four objects.
|
||||||
|
- **`RenderContext`** — a `Gpu` plus a window's surface and its
|
||||||
|
configuration; owns the per-frame acquire → clear → present cycle.
|
||||||
|
|
||||||
|
Both are created for you by [`run()`](windowing.md); applications normally
|
||||||
|
reach them through `AppCtx::render()`.
|
||||||
|
|
||||||
|
## `Gpu`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let gpu = Gpu::headless()?; // offscreen / tests
|
||||||
|
let device: &wgpu::Device = gpu.device();
|
||||||
|
let queue: &wgpu::Queue = gpu.queue();
|
||||||
|
# Ok::<(), oxide_engine::render::RenderError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
Acquisition asks for a high-performance adapter (compatible with the window
|
||||||
|
surface in the windowed path) and a default-limits device. The chosen adapter
|
||||||
|
and backend are logged at `info` level on startup.
|
||||||
|
|
||||||
|
`Gpu::headless()` skips the surface entirely — used by offscreen rendering
|
||||||
|
and the automated Stage 2 integration test. Backend selection and debug flags
|
||||||
|
remain overridable through wgpu's standard `WGPU_*` environment variables
|
||||||
|
(e.g. `WGPU_BACKEND=vulkan`).
|
||||||
|
|
||||||
|
## `RenderContext`
|
||||||
|
|
||||||
|
Owns the surface lifecycle:
|
||||||
|
|
||||||
|
- **Creation** — builds the wgpu instance (the window doubles as the display
|
||||||
|
handle), creates the surface, acquires the `Gpu`, and configures the
|
||||||
|
surface with `get_default_config` (the platform's preferred format and
|
||||||
|
present mode).
|
||||||
|
- **`resize(width, height)`** — reconfigures the surface. Zero dimensions
|
||||||
|
(minimized windows) are clamped to 1 so the surface stays valid. Called
|
||||||
|
automatically by the event loop on `Resized`.
|
||||||
|
- **`set_clear_color(color)` / `clear_color()`** — the color the next frame
|
||||||
|
is cleared to. The engine's `Color` is linear f32 RGBA, matching what the
|
||||||
|
surface expects (conversion to `wgpu::Color` is `render::to_wgpu_color`).
|
||||||
|
- **`render_frame()`** — one frame: acquire the next surface texture, record
|
||||||
|
a clear pass, submit, present.
|
||||||
|
- **`size()`, `gpu()`** — current surface size (physical pixels) and the
|
||||||
|
underlying `Gpu`.
|
||||||
|
|
||||||
|
### Frame acquisition and transient failures
|
||||||
|
|
||||||
|
`get_current_texture` can fail for reasons that are *normal* during resizes
|
||||||
|
and window-manager activity. `render_frame()` maps them as follows:
|
||||||
|
|
||||||
|
| Surface state | Behavior |
|
||||||
|
|---------------|----------|
|
||||||
|
| `Success` / `Suboptimal` | Clear and present (a suboptimal frame is still presentable; the next resize reconfigures anyway) |
|
||||||
|
| `Lost` / `Outdated` | Reconfigure the surface, skip the frame |
|
||||||
|
| `Timeout` / `Occluded` | Skip the frame |
|
||||||
|
| `Validation` | Returned as `RenderError::SurfaceValidation` — a real bug, not transient |
|
||||||
|
|
||||||
|
Skipped frames are invisible in practice: the next `RedrawRequested` arrives
|
||||||
|
within milliseconds.
|
||||||
|
|
||||||
|
## `clear_view`
|
||||||
|
|
||||||
|
The single render operation Stage 2 owns:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
oxide_engine::render::clear_view(device, queue, &texture_view, Color::RED);
|
||||||
|
```
|
||||||
|
|
||||||
|
Records and submits a render pass whose only work is a load-op clear. Both
|
||||||
|
the windowed path (`render_frame`) and offscreen targets go through it, which
|
||||||
|
is what makes the GPU path automatically testable: the integration test
|
||||||
|
`stage2::headless_clear_fills_texture_with_clear_color` clears an offscreen
|
||||||
|
texture headless, reads the pixels back, and asserts the exact clear color —
|
||||||
|
no window or human needed. (It self-skips on machines with no GPU adapter.)
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
`RenderError` (a `thiserror` enum) distinguishes the failure modes callers
|
||||||
|
might handle: `NoAdapter`, `Device`, `CreateSurface`, `UnsupportedSurface`,
|
||||||
|
and `SurfaceValidation`. Binaries typically just propagate it via `anyhow`
|
||||||
|
out of `run()`.
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
- The window is held as `Arc<winit::window::Window>` so the surface, which
|
||||||
|
borrows the window, can be `'static` — winit hands windows out from inside
|
||||||
|
its event loop, and wgpu surfaces must outlive every frame.
|
||||||
|
- `Gpu` and `RenderContext` are separate types on purpose: later stages (and
|
||||||
|
tests today) need the device/queue without any window, and composability is
|
||||||
|
a core project principle.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Render Pass Pipeline
|
||||||
|
|
||||||
|
Stage 4 drew everything in one hardcoded pass. Stage 5 generalizes that into a
|
||||||
|
[`RenderPipeline`]: an ordered, named list of composable [`RenderPass`]es that
|
||||||
|
share one frame's targets. 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 like a VCR filter) versus a full realistic stack with
|
||||||
|
shadows and post-processing, paying only for the passes turned on.
|
||||||
|
|
||||||
|
The Stage-4 forward renderer is retrofitted onto this as [`ForwardPass`], so the
|
||||||
|
default pipeline is just `[Clear, Forward]` and produces pixel-identical output.
|
||||||
|
Later stages (shadows, post-process, overlay UI) add passes **without touching
|
||||||
|
the renderer core** — they register a pass.
|
||||||
|
|
||||||
|
## The pieces
|
||||||
|
|
||||||
|
- [`RenderPass`] — a trait with one method, `run(&mut self, frame)`. Implement it
|
||||||
|
to add a stage of the frame.
|
||||||
|
- [`FrameContext`] — everything a pass operates on for one frame: the shared
|
||||||
|
`color` target, size, clear color, camera + its world transform, lighting, and
|
||||||
|
the (already culled) drawables.
|
||||||
|
- [`RenderPipeline`] — owns the passes and runs every *enabled* one in order.
|
||||||
|
- Built-in passes: [`ClearPass`] (clears the color target) and [`ForwardPass`]
|
||||||
|
(the lit forward draw).
|
||||||
|
|
||||||
|
## Composing a frame
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::render::{RenderPipeline, FrameContext, ForwardPass};
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# fn demo(device: &oxide_engine::wgpu::Device, queue: &oxide_engine::wgpu::Queue,
|
||||||
|
# target: &oxide_engine::wgpu::TextureView, cube: &GpuMesh) {
|
||||||
|
// The default pipeline: Clear then Forward (pixel-identical to Stage 4).
|
||||||
|
let mut pipeline = RenderPipeline::forward(device, oxide_engine::wgpu::TextureFormat::Rgba8Unorm);
|
||||||
|
|
||||||
|
let camera = Camera::default();
|
||||||
|
let view = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||||
|
let lighting = Lighting::default();
|
||||||
|
let objects = [RenderObject { mesh: cube, material: Material::diffuse(Color::RED), transform: Transform::IDENTITY }];
|
||||||
|
|
||||||
|
pipeline.render(&mut FrameContext {
|
||||||
|
device, queue,
|
||||||
|
color: target,
|
||||||
|
size: (1280, 720),
|
||||||
|
clear_color: Color::rgb(0.05, 0.06, 0.09),
|
||||||
|
camera: &camera,
|
||||||
|
view_transform: &view,
|
||||||
|
lighting: &lighting,
|
||||||
|
objects: &objects,
|
||||||
|
});
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data-driven: add, toggle, remove
|
||||||
|
|
||||||
|
Passes are addressed by name and managed without touching any pass's code:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::render::RenderPipeline;
|
||||||
|
# struct Bloom; impl oxide_engine::render::RenderPass for Bloom {
|
||||||
|
# fn run(&mut self, _f: &mut oxide_engine::render::FrameContext<'_>) {} }
|
||||||
|
# let mut pipeline = RenderPipeline::new();
|
||||||
|
pipeline.add_pass("forward", /* ForwardPass */
|
||||||
|
# { struct F; impl oxide_engine::render::RenderPass for F { fn run(&mut self, _f: &mut oxide_engine::render::FrameContext<'_>) {} } F }
|
||||||
|
);
|
||||||
|
pipeline.add_pass("bloom", Bloom); // a post effect (Stage 13)
|
||||||
|
pipeline.set_enabled("bloom", false); // turn it off, keep it registered
|
||||||
|
pipeline.insert_before("forward", "shadows",
|
||||||
|
# { struct S; impl oxide_engine::render::RenderPass for S { fn run(&mut self, _f: &mut oxide_engine::render::FrameContext<'_>) {} } S }
|
||||||
|
); // slot a pass into a fixed position
|
||||||
|
pipeline.remove("bloom"); // drop it entirely
|
||||||
|
```
|
||||||
|
|
||||||
|
A stylized game ships a pipeline with no post passes (and pays nothing for them);
|
||||||
|
a realistic game enables shadows, SSAO, bloom, tone-mapping. Same engine, same
|
||||||
|
renderer core — different pass list.
|
||||||
|
|
||||||
|
## Windowed vs offscreen clearing
|
||||||
|
|
||||||
|
The window runner already clears the surface to the configured clear color before
|
||||||
|
`App::render` runs, so the **editor and windowed examples use a forward-only
|
||||||
|
pipeline** (no `ClearPass`) and let the runner clear. `RenderPipeline::forward`
|
||||||
|
(Clear + Forward) is for offscreen/standalone rendering where nothing else
|
||||||
|
clears the target — e.g. the headless render tests.
|
||||||
|
|
||||||
|
## Camera layer visibility
|
||||||
|
|
||||||
|
A [`Camera`](rendering.md) carries a `visibility` [`LayerMask`](layers.md): it
|
||||||
|
renders an entity only if the entity's [`Layer`] is in that mask (default
|
||||||
|
[`LayerMask::ALL`] — sees everything). The host applies it while gathering
|
||||||
|
drawables:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# use oxide_engine::layer::Layer;
|
||||||
|
# let scene = Scene::new();
|
||||||
|
# let camera = Camera::default();
|
||||||
|
# let entity = scene.entities().next();
|
||||||
|
# if let Some(entity) = entity {
|
||||||
|
let layer = scene.get::<Layer>(entity).map(|l| *l).unwrap_or_default();
|
||||||
|
if camera.sees(layer) {
|
||||||
|
// include this entity in the draw list
|
||||||
|
}
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
This is how a minimap camera, a first-person view-model camera, or editor-only
|
||||||
|
gizmo layers are kept to their own cameras.
|
||||||
|
|
||||||
|
[`RenderPipeline`]: ../engine/src/render/pipeline.rs
|
||||||
|
[`RenderPass`]: ../engine/src/render/pipeline.rs
|
||||||
|
[`FrameContext`]: ../engine/src/render/pipeline.rs
|
||||||
|
[`ClearPass`]: ../engine/src/render/pipeline.rs
|
||||||
|
[`ForwardPass`]: ../engine/src/render/pipeline.rs
|
||||||
|
[`Layer`]: ../engine/src/layer/components.rs
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Rendering (Stage 4 — Basic 3D Rendering)
|
||||||
|
|
||||||
|
Stage 4 turns the clear-color surface from Stage 2 into a 3D renderer: it draws
|
||||||
|
**meshes**, placed by **transforms**, shaded by **materials**, as seen through a
|
||||||
|
**camera**, lit by a directional light — all through a single-pass
|
||||||
|
**forward renderer**.
|
||||||
|
|
||||||
|
> Status: the rendering core (this document), the glTF importer, and the
|
||||||
|
> editor's 3D viewport (orbit/pan/zoom + material inspector) are all implemented.
|
||||||
|
> The engine paths are covered by headless GPU tests; the editor viewport is on
|
||||||
|
> `dev` awaiting the maintainer's manual sign-off before Stage 4 is marked done
|
||||||
|
> (tracked in [PLAN.md](../PLAN.md)).
|
||||||
|
|
||||||
|
All of these types live in `oxide_engine::render` and are re-exported from the
|
||||||
|
[prelude](getting-started.md).
|
||||||
|
|
||||||
|
## The pieces
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|------|------|
|
||||||
|
| [`Vertex`] | One vertex: `position`, `normal`, `uv` (GPU-ready, `repr(C)`) |
|
||||||
|
| [`Mesh`] | CPU-side indexed triangle geometry + primitive builders |
|
||||||
|
| [`GpuMesh`] | A `Mesh` uploaded into GPU vertex/index buffers |
|
||||||
|
| [`Material`] | PBR-lite surface: `albedo`, `metallic`, `roughness` |
|
||||||
|
| [`Camera`] | Perspective projection; the *view* comes from a `Transform` |
|
||||||
|
| [`DirectionalLight`] / [`Lighting`] | One sun light + an ambient term |
|
||||||
|
| [`RenderObject`] | A drawable: `&GpuMesh` + `Material` + `Transform` |
|
||||||
|
| [`ForwardRenderer`] | Owns the pipeline + depth buffer; draws a list of objects |
|
||||||
|
|
||||||
|
[`Vertex`]: ../engine/src/render/mesh.rs
|
||||||
|
[`Mesh`]: ../engine/src/render/mesh.rs
|
||||||
|
[`GpuMesh`]: ../engine/src/render/mesh.rs
|
||||||
|
[`Material`]: ../engine/src/render/material.rs
|
||||||
|
[`Camera`]: ../engine/src/render/camera.rs
|
||||||
|
[`DirectionalLight`]: ../engine/src/render/forward.rs
|
||||||
|
[`Lighting`]: ../engine/src/render/forward.rs
|
||||||
|
[`RenderObject`]: ../engine/src/render/forward.rs
|
||||||
|
[`ForwardRenderer`]: ../engine/src/render/forward.rs
|
||||||
|
|
||||||
|
## Building geometry
|
||||||
|
|
||||||
|
Meshes are built on the CPU and uploaded once. Built-in primitives cover the
|
||||||
|
common prototyping shapes:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let cube = Mesh::cube(); // unit cube, per-face normals
|
||||||
|
let plane = Mesh::plane(10.0); // 10×10 ground on XZ, facing +Y
|
||||||
|
let sphere = Mesh::uv_sphere(0.8, 32, 16); // radius, sectors, stacks
|
||||||
|
|
||||||
|
// Upload to the GPU (needs a `&wgpu::Device`, e.g. from `RenderCtx`/`Gpu`).
|
||||||
|
let gpu_cube: GpuMesh = cube.upload(device, "cube");
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also build a mesh directly from `Vertex` + index data, and query its
|
||||||
|
object-space bounds with `Mesh::bounds()` (used later for culling).
|
||||||
|
|
||||||
|
### Importing glTF
|
||||||
|
|
||||||
|
Static meshes load from glTF/GLB via `oxide_engine::asset`. The node hierarchy is
|
||||||
|
flattened into world space and each primitive becomes a `GltfMesh` (geometry +
|
||||||
|
PBR-lite material + transform); missing normals are generated, missing UVs default
|
||||||
|
to zero. Skinning/animation are deferred to the animation stage.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let model = load_gltf("assets/models/cube.gltf")?;
|
||||||
|
let drawables: Vec<_> = model
|
||||||
|
.meshes
|
||||||
|
.iter()
|
||||||
|
.map(|m| (m.mesh.upload(device, "gltf"), m.material, m.transform))
|
||||||
|
.collect();
|
||||||
|
// Build `RenderObject`s from `drawables` and hand them to `ForwardRenderer::render`.
|
||||||
|
```
|
||||||
|
|
||||||
|
`load_gltf_slice(&bytes)` is the in-memory variant (buffers must be embedded),
|
||||||
|
used for tests and bundled assets.
|
||||||
|
|
||||||
|
## Camera
|
||||||
|
|
||||||
|
A `Camera` holds only projection parameters (`fov_y`, `z_near`, `z_far`); its
|
||||||
|
*position and orientation* are a [`Transform`](scene.md) given at render time, so
|
||||||
|
a camera can live in the scene as an entity. Use `Transform::looking_at` to aim
|
||||||
|
it:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let camera = Camera::default(); // 60° FOV, 0.1–1000 range
|
||||||
|
let view = Transform::looking_at(Vec3::new(4.0, 2.5, 5.0), Vec3::ZERO, Vec3::Y);
|
||||||
|
```
|
||||||
|
|
||||||
|
The projection uses a `0..1` NDC depth range (the wgpu/Vulkan/DX/Metal
|
||||||
|
convention), matching the depth buffer the forward renderer clears to `1.0`.
|
||||||
|
|
||||||
|
## Drawing a frame
|
||||||
|
|
||||||
|
The `ForwardRenderer` is built once for a given **color target format** — the
|
||||||
|
window surface format for on-screen rendering, or e.g. `Rgba8Unorm` offscreen.
|
||||||
|
Then each frame you hand it a list of `RenderObject`s:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Once (e.g. lazily on the first frame, when the surface format is known):
|
||||||
|
let mut renderer = ForwardRenderer::new(device, ctx.surface_format);
|
||||||
|
|
||||||
|
// Each frame, inside `App::render`:
|
||||||
|
renderer.render(
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
ctx.view, // the target view (already cleared to the clear color)
|
||||||
|
ctx.size, // (width, height) in physical pixels
|
||||||
|
&camera,
|
||||||
|
&view, // the camera's world transform
|
||||||
|
&Lighting::default(),
|
||||||
|
&[
|
||||||
|
RenderObject { mesh: &gpu_plane, material: Material::diffuse(Color::WHITE), transform: ground },
|
||||||
|
RenderObject { mesh: &gpu_cube, material: Material::diffuse(Color::RED), transform: spin },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The color attachment is **loaded, not cleared**, so whatever cleared the surface
|
||||||
|
beforehand (the window's clear color from Stage 2, or a `clear_view` call) shows
|
||||||
|
through as the background. The depth buffer is owned by the renderer, resized to
|
||||||
|
match the target, and cleared to `1.0` every call.
|
||||||
|
|
||||||
|
See the full runnable example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-examples --bin hello_mesh # spinning cube + sphere + ground
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- **One pipeline, one pass.** Geometry is drawn front-to-back-agnostic; a
|
||||||
|
`Depth32Float` depth buffer with `Less` compare resolves occlusion, so draw
|
||||||
|
order does not affect the result.
|
||||||
|
- **Per-object data via dynamic uniform offsets.** Globals (view-projection,
|
||||||
|
camera position, light) live in one uniform buffer (bind group 0). Each
|
||||||
|
object's model matrix, normal matrix, and material live in a second uniform
|
||||||
|
buffer addressed with a dynamic offset (bind group 1), so an arbitrary number
|
||||||
|
of objects draw from one buffer that grows as needed.
|
||||||
|
- **PBR-lite shading.** [`shaders/lit.wgsl`](../engine/src/render/shaders/lit.wgsl)
|
||||||
|
does Lambert diffuse + ambient + a Blinn-Phong specular term whose sharpness
|
||||||
|
comes from `roughness` and whose color comes from `metallic`. It outputs linear
|
||||||
|
color; an sRGB surface converts on write.
|
||||||
|
|
||||||
|
## In the editor
|
||||||
|
|
||||||
|
The editor renders the active scene in a 3D viewport beneath its egui panels.
|
||||||
|
Entities become visible by carrying a `MeshRenderer` component (`oxide_engine::render`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
// Make an entity render a cube with a custom material.
|
||||||
|
let e = scene.spawn("crate", Transform::from_translation(Vec3::new(2.0, 0.5, 0.0)));
|
||||||
|
scene.world_mut().insert_one(
|
||||||
|
e,
|
||||||
|
MeshRenderer::with_material(PrimitiveShape::Cube, Material::diffuse(Color::RED)),
|
||||||
|
).unwrap();
|
||||||
|
```
|
||||||
|
|
||||||
|
`MeshRenderer` names a built-in `PrimitiveShape` (cube/sphere/plane) rather than
|
||||||
|
embedding geometry, so it is tiny and serializable (RON) — editable from both the
|
||||||
|
inspector and, later, scripts/AI agents. The editor caches one GPU mesh per shape
|
||||||
|
and draws every `MeshRenderer` entity through the `ForwardRenderer`, with an
|
||||||
|
orbit camera (drag to orbit, right-drag to pan, scroll to zoom).
|
||||||
|
|
||||||
|
Entities are selected by **clicking them in the viewport** (a ray is cast against
|
||||||
|
each renderable's world-space bounds) or from the hierarchy panel. The inspector
|
||||||
|
edits the selection's **transform** (position, rotation as euler degrees, and
|
||||||
|
scale) and its **material** (albedo / metallic / roughness — roughness controls
|
||||||
|
specular-highlight sharpness, most visible on glossy/metallic surfaces).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The window/viewport halves need a human eye, but the render path itself is
|
||||||
|
verified headlessly (`tests/` `stage4`): render to an offscreen texture and read
|
||||||
|
the pixels back to assert that lit geometry appears, the background shows through
|
||||||
|
elsewhere, and a near object occludes a farther one through the depth buffer.
|
||||||
|
Camera projection/view math has unit tests in `render/camera.rs`.
|
||||||
|
|
||||||
|
See also: [render-context.md](render-context.md) (surface/clear loop),
|
||||||
|
[conventions.md](conventions.md) (handedness, color space), [scene.md](scene.md)
|
||||||
|
(transforms and the hierarchy that feeds object placement).
|
||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
# Scene Graph & Entity System
|
||||||
|
|
||||||
|
The `oxide_engine::scene` module is the world model every later system plugs
|
||||||
|
into. It pairs a lightweight ECS ([`hecs`](https://docs.rs/hecs)) with a
|
||||||
|
parent/child [`Transform`](math.md) hierarchy, so entities can hold arbitrary
|
||||||
|
components *and* live in a spatial tree.
|
||||||
|
|
||||||
|
This document is the usage reference for the module as delivered in **Stage 3**.
|
||||||
|
For the math types it builds on, see [math.md](math.md); for coordinate and
|
||||||
|
units conventions, see [conventions.md](conventions.md).
|
||||||
|
|
||||||
|
## Importing
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::scene::{Scene, Node, Entity, DespawnPolicy, SceneError};
|
||||||
|
// or, for the common types, via the prelude:
|
||||||
|
use oxide_engine::prelude::*; // Scene, Node, Entity, DespawnPolicy, SceneError, Transform, …
|
||||||
|
```
|
||||||
|
|
||||||
|
The full ECS is re-exported as `oxide_engine::hecs` so you share one copy of
|
||||||
|
`Entity` and the query API with the engine.
|
||||||
|
|
||||||
|
## Mental model
|
||||||
|
|
||||||
|
- An **entity** is a `hecs::Entity` handle — a small `Copy` id.
|
||||||
|
- Every entity created through the scene carries a [`Node`](#node) (name +
|
||||||
|
enabled flag) and a **local** [`Transform`](math.md).
|
||||||
|
- The **hierarchy** (which entity parents which) is owned by the `Scene`, not
|
||||||
|
stored as components. This keeps child ordering deterministic and makes
|
||||||
|
reparenting cheap.
|
||||||
|
- A **local** transform is what you author. A **world** transform is the local
|
||||||
|
composed with every ancestor: `world = parent_world * local`. The scene
|
||||||
|
resolves these on demand; it does not cache them.
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|------|------|
|
||||||
|
| [`Scene`](#scene) | Owns entities + hierarchy; spawn, despawn, reparent, query, resolve transforms |
|
||||||
|
| [`Node`](#node) | Per-entity metadata: `name`, `enabled` |
|
||||||
|
| [`DespawnPolicy`](#despawning) | Whether despawn takes the subtree or detaches children |
|
||||||
|
| [`SceneError`](#errors) | Reparent / (de)serialization failures |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Scene`
|
||||||
|
|
||||||
|
### Building a hierarchy
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let mut scene = Scene::new();
|
||||||
|
|
||||||
|
// A root entity (no parent).
|
||||||
|
let sun = scene.spawn("sun", Transform::IDENTITY);
|
||||||
|
|
||||||
|
// Children. `spawn_child` panics if the parent is not a live entity.
|
||||||
|
let planet = scene.spawn_child(
|
||||||
|
sun,
|
||||||
|
"planet",
|
||||||
|
Transform::from_translation(Vec3::new(10.0, 0.0, 0.0)),
|
||||||
|
);
|
||||||
|
let moon = scene.spawn_child(
|
||||||
|
planet,
|
||||||
|
"moon",
|
||||||
|
Transform::from_translation(Vec3::new(0.0, 0.0, 2.0)),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`spawn`/`spawn_child` take anything that converts into a `Node`, so a bare
|
||||||
|
`&str` works as a name (`"planet"` ≡ `Node::new("planet")`); pass a `Node`
|
||||||
|
directly when you need to set `enabled`.
|
||||||
|
|
||||||
|
### Resolving world transforms
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// One entity (walks up the parent chain):
|
||||||
|
let moon_world = scene.world_transform(moon).unwrap();
|
||||||
|
|
||||||
|
// Every entity at once (single top-down pass — prefer this in bulk):
|
||||||
|
let worlds = scene.world_transforms(); // HashMap<Entity, Transform>
|
||||||
|
```
|
||||||
|
|
||||||
|
`world_transforms()` is the path the renderer will use; it resolves a
|
||||||
|
10,000-entity, 5-level scene in well under a millisecond (see the
|
||||||
|
`world_transforms_10k_depth5` benchmark).
|
||||||
|
|
||||||
|
### Reparenting
|
||||||
|
|
||||||
|
```rust
|
||||||
|
scene.set_parent(moon, Some(sun))?; // moon now orbits the sun directly
|
||||||
|
scene.set_parent(moon, None)?; // moon becomes a root
|
||||||
|
```
|
||||||
|
|
||||||
|
Reparenting preserves the **local** transform (it does not compensate to keep
|
||||||
|
the world transform fixed). Cycles are rejected: parenting an entity to itself
|
||||||
|
or to one of its descendants returns [`SceneError::WouldCycle`], leaving the
|
||||||
|
hierarchy untouched.
|
||||||
|
|
||||||
|
### Despawning
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::scene::DespawnPolicy;
|
||||||
|
|
||||||
|
// Remove the entity and its entire subtree:
|
||||||
|
scene.despawn(planet, DespawnPolicy::Recursive);
|
||||||
|
|
||||||
|
// Remove only the entity; its children move up to its parent (or become roots
|
||||||
|
// if it was a root):
|
||||||
|
scene.despawn(planet, DespawnPolicy::DetachChildren);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Editing nodes
|
||||||
|
|
||||||
|
```rust
|
||||||
|
scene.set_name(planet, "earth");
|
||||||
|
scene.set_enabled(moon, false); // later systems skip disabled subtrees
|
||||||
|
scene.set_local_transform(moon, Transform::IDENTITY);
|
||||||
|
|
||||||
|
let name = scene.name(planet); // Option<String>
|
||||||
|
let on = scene.is_enabled(moon); // Option<bool>
|
||||||
|
let local = scene.local_transform(moon); // Option<Transform>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Querying
|
||||||
|
|
||||||
|
```rust
|
||||||
|
for &root in scene.roots() { /* … */ }
|
||||||
|
for &child in scene.children(planet) { /* … */ }
|
||||||
|
let parent = scene.parent(moon); // Option<Entity>
|
||||||
|
let n = scene.len();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extra components (it's a real ECS)
|
||||||
|
|
||||||
|
Entities are full `hecs` entities, so later stages attach their own components
|
||||||
|
(meshes, rigid bodies, …) alongside the `Node`/`Transform`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
scene.world_mut().insert_one(planet, /* e.g. */ 0u32).unwrap();
|
||||||
|
let value = scene.get::<u32>(planet); // Option<hecs::Ref<u32>>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `world()` for read-only queries and `world_mut()` for adding/removing
|
||||||
|
*non-hierarchy* components. Drive lifecycle and parenting through the `Scene`
|
||||||
|
methods so the hierarchy bookkeeping stays consistent — spawning or despawning
|
||||||
|
directly on the world bypasses it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Node`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Node {
|
||||||
|
pub name: String, // display name; not required to be unique
|
||||||
|
pub enabled: bool, // honored by later systems, not by transform resolution
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Node::new(name)` builds an enabled node. `enabled` is a declaration of intent:
|
||||||
|
Stage 3 only stores and toggles it; rendering/physics/audio will skip disabled
|
||||||
|
subtrees in later stages. It deliberately does **not** affect
|
||||||
|
`world_transform`, which is purely geometric.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Serialization
|
||||||
|
|
||||||
|
A scene round-trips through RON. Because `hecs::Entity` handles are not stable
|
||||||
|
across a save/load, the scene is flattened to an indexed node list in a
|
||||||
|
deterministic pre-order walk, so serialize → deserialize → serialize is
|
||||||
|
byte-for-byte stable.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let ron: String = scene.to_ron()?;
|
||||||
|
let restored = Scene::from_ron(&ron)?;
|
||||||
|
assert_eq!(ron, restored.to_ron()?); // identical
|
||||||
|
```
|
||||||
|
|
||||||
|
Corrupt input (out-of-range child indices, a node listed as both root and
|
||||||
|
child) is rejected with [`SceneError::Deserialize`].
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum SceneError {
|
||||||
|
NoSuchEntity, // operation referenced a dead entity
|
||||||
|
WouldCycle, // reparent would make an entity its own ancestor
|
||||||
|
Serialize(String), // encoding to RON failed
|
||||||
|
Deserialize(String), // decoding failed or data was inconsistent
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
A complete, runnable tour lives in
|
||||||
|
[`examples/src/bin/scene_basic.rs`](../examples/src/bin/scene_basic.rs):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-examples --bin scene_basic
|
||||||
|
```
|
||||||
|
|
||||||
|
It builds a sun/planet/moon hierarchy, prints local vs. world transforms,
|
||||||
|
reparents a node, round-trips through RON, and despawns with a detach policy.
|
||||||
|
|
||||||
|
## In the editor
|
||||||
|
|
||||||
|
`oxide-editor` renders a **Scene Hierarchy** panel (left) and an **Inspector**
|
||||||
|
(right) over the viewport, driving this same API: select a node, rename it,
|
||||||
|
toggle its `enabled` flag, reparent it via the Inspector's parent dropdown, and
|
||||||
|
add/delete nodes from the toolbar. The egui integration is editor-only — the
|
||||||
|
engine exposes a generic post-clear draw hook ([`App::render`]) and keeps egui
|
||||||
|
out of its own dependency tree.
|
||||||
|
|
||||||
|
[`App::render`]: ../engine/src/window/app.rs
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
- **Why hierarchy outside the ECS?** Storing `Parent`/`Children` as components
|
||||||
|
is idiomatic but makes ordered iteration and reparenting awkward (archetype
|
||||||
|
moves, borrow juggling) and gives no ordering guarantee. Keeping the tree in
|
||||||
|
the `Scene` yields deterministic child order — which serialization and the
|
||||||
|
editor both rely on — and O(1) link edits. The ECS still owns all entity
|
||||||
|
*data*.
|
||||||
|
- **World transforms are resolved, not stored.** There is no dirty-flag cache
|
||||||
|
yet; `world_transforms()` recomputes in one pass. A cache can be added later
|
||||||
|
behind the same API without changing callers.
|
||||||
|
- **Despawn policies** map onto the two things callers actually want: delete a
|
||||||
|
whole subtree, or remove one node and keep its children.
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
# Scripting (`oxide-script`, rhai) — Stage 10
|
||||||
|
|
||||||
|
The scripting module makes game logic live in **watched `.rhai` scripts** that
|
||||||
|
hot-reload while the editor runs. It is a feature-gated module built on
|
||||||
|
[`rhai`](https://rhai.rs) — an embeddable, sandboxed, Rust-friendly scripting
|
||||||
|
language — and plugs into the engine through the Stage-5 module system exactly
|
||||||
|
like [physics](physics.md): add [`ScriptModule`](#scriptmodule) to an `App` and
|
||||||
|
the [`Script`](#the-script-component) component becomes live.
|
||||||
|
|
||||||
|
> **Status.** This page tracks Stage 10 as it lands piece by piece. **Done so
|
||||||
|
> far:** the component data model, the script asset + `.rhai` loader, the engine
|
||||||
|
> wrapper, the module wiring, the per-frame lifecycle (`init` / `update(dt)`)
|
||||||
|
> driven by the [`ScriptHost`](#the-scripthost-lifecycle), the engine API
|
||||||
|
> (`Vec3` + ambient transform functions) scripts use to read/write their
|
||||||
|
> entity's `Transform`, **headless [live reload](#live-reload)** (edit a
|
||||||
|
> `.rhai` file → the running script recompiles, no restart), and **[editor
|
||||||
|
> integration](#editor-integration)** (Script is addable in the inspector; Play
|
||||||
|
> runs scripts and live-reload reaches a *playing* scene), and **[error/output
|
||||||
|
> surfacing](#errors-and-output-in-the-console)** (a paused script's error and
|
||||||
|
> its `print` output show in the editor Console panel), and a **[command
|
||||||
|
> terminal](#the-command-terminal)** in that panel, plus an **[interactive PTY
|
||||||
|
> terminal](#interactive-terminal-pty)** that runs shells / TUIs / AI-agent CLIs
|
||||||
|
> like `claude`. **Remaining for the stage:** a richer script API (spawn/despawn
|
||||||
|
> + component add/edit, beyond `Transform`).
|
||||||
|
|
||||||
|
## The model: ECS is the source of truth
|
||||||
|
|
||||||
|
As with physics, the **ECS owns the truth**. An entity opts into scripting with
|
||||||
|
one serializable, reflected component — [`Script`](#the-script-component) — that
|
||||||
|
carries only *authoring* inputs (which script, enabled or not). The script's
|
||||||
|
behaviour is **not** stored on the component:
|
||||||
|
|
||||||
|
- The source lives on disk as a `.rhai` file, loaded as a
|
||||||
|
[`ScriptAsset`](#scriptasset-the-loaded-source) (kept as plain text so a live
|
||||||
|
edit just recompiles).
|
||||||
|
- The compiled AST and any per-entity runtime state live in the host (a later
|
||||||
|
piece), keyed by entity.
|
||||||
|
|
||||||
|
Because the authored component carries no runtime state, **play-mode
|
||||||
|
snapshot/restore works for free**: Stop reverts the authored `Script` components
|
||||||
|
and the next Play recompiles fresh.
|
||||||
|
|
||||||
|
## The `Script` component
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::asset::AssetRef;
|
||||||
|
use oxide_script::{Script, ScriptAsset};
|
||||||
|
|
||||||
|
// Empty + disabled by default; `new` points at a source and enables it.
|
||||||
|
let script = Script::new(AssetRef::new(uid)); // uid from the asset database
|
||||||
|
assert!(script.enabled);
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `source` | `AssetRef<ScriptAsset>` | Which `.rhai` script the entity runs. Empty until assigned. |
|
||||||
|
| `enabled` | `bool` | Whether the script runs; clear to suspend it without detaching. |
|
||||||
|
|
||||||
|
`source` is an [`AssetRef<T>`](assets.md), not a live `Handle<T>`, so it is
|
||||||
|
serializable and stable across runs; the inspector recognises the
|
||||||
|
`AssetRef<ScriptAsset>` spelling and offers a picker filtered to the `scripts/`
|
||||||
|
folder (`AssetKind::Script`). The component derives `Reflect`, so it is
|
||||||
|
dual-editable from the inspector, from scripts, and from external tools with no
|
||||||
|
per-type editor code.
|
||||||
|
|
||||||
|
## `ScriptAsset`: the loaded source
|
||||||
|
|
||||||
|
A `ScriptAsset` is just the script's source text plus a diagnostic name (the
|
||||||
|
file stem). It is deliberately inert — holding *source*, not behaviour — so the
|
||||||
|
same file can be recompiled on every live reload with no engine-specific data
|
||||||
|
baked into the asset cache.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_script::ScriptAsset;
|
||||||
|
|
||||||
|
let asset = ScriptAsset::from_source("spin", "let t = 0.0;");
|
||||||
|
assert_eq!(asset.name, "spin");
|
||||||
|
```
|
||||||
|
|
||||||
|
The `ScriptLoader` reads `.rhai` files; it is registered by `ScriptModule`, so
|
||||||
|
`assets.load::<ScriptAsset>("scripts/spin.rhai")` works once the module is added.
|
||||||
|
`.rhai` files map to the new `AssetKind::Script` (folder `scripts/`).
|
||||||
|
|
||||||
|
## The `ScriptEngine` wrapper
|
||||||
|
|
||||||
|
`ScriptEngine` owns one configured `rhai` interpreter the host reuses to compile
|
||||||
|
and run every script, so sandbox configuration lives in one place:
|
||||||
|
|
||||||
|
- `print` / `debug` output is routed to the `log` crate (so the editor console
|
||||||
|
can surface it rather than leaking to stdout);
|
||||||
|
- an operation cap (`set_max_operations`) turns a runaway loop into a **runtime
|
||||||
|
error** instead of hanging the editor.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_script::{ScriptAsset, ScriptEngine};
|
||||||
|
|
||||||
|
let engine = ScriptEngine::new();
|
||||||
|
let asset = ScriptAsset::from_source("ok", "let x = 1 + 2; print(x);");
|
||||||
|
let compiled = engine.compile(&asset)?; // -> CompiledScript (reusable AST)
|
||||||
|
engine.run(&compiled)?; // evaluate the top level
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors are a `thiserror` enum, [`ScriptError`], with two variants that both name
|
||||||
|
the offending script so the console can attribute the failure:
|
||||||
|
|
||||||
|
- `ScriptError::Compile` — the source failed to parse/compile;
|
||||||
|
- `ScriptError::Runtime` — it compiled but raised an error (or hit the operation
|
||||||
|
cap) while running.
|
||||||
|
|
||||||
|
A runtime error is **returned, never panicked**, so the host can pause just that
|
||||||
|
one script rather than crash the editor — the foundation for Stage 10's
|
||||||
|
error-isolation goal.
|
||||||
|
|
||||||
|
## `ScriptModule`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::app::App;
|
||||||
|
use oxide_script::ScriptModule;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_module(ScriptModule);
|
||||||
|
assert!(app.has_module("script"));
|
||||||
|
assert!(app.types.is_registered("Script"));
|
||||||
|
```
|
||||||
|
|
||||||
|
`ScriptModule::build` registers the `Script` component type for reflection
|
||||||
|
(making it dual-editable and snapshot-captured), installs the `.rhai`
|
||||||
|
`ScriptLoader`, inserts the [`ScriptHost`](#the-scripthost-lifecycle), and adds
|
||||||
|
the `run_scripts` system on the `Update` schedule (after `FixedUpdate`, so
|
||||||
|
scripts observe post-physics poses). Removing the module drops everything it
|
||||||
|
contributed, as for any module.
|
||||||
|
|
||||||
|
## The `ScriptHost` (lifecycle)
|
||||||
|
|
||||||
|
`ScriptHost` is the scripting counterpart to physics' `PhysicsWorld`: a transient
|
||||||
|
`App` resource holding the **per-entity runtime state** — the compiled AST and a
|
||||||
|
persistent `rhai` `Scope` — that the authored `Script` component deliberately
|
||||||
|
does not. The `run_scripts` system drives it each frame:
|
||||||
|
|
||||||
|
1. find every entity with an **enabled** `Script` that names a source;
|
||||||
|
2. resolve each one's `.rhai` text through the `AssetDatabase` + `AssetServer`;
|
||||||
|
3. **(re)compile + `start`** any script that is new or whose source text changed
|
||||||
|
(this content check is the hook live reload builds on);
|
||||||
|
4. stage the entity's `Transform` into the engine, call `update(dt)`, and write
|
||||||
|
the (possibly mutated) transform back to the scene.
|
||||||
|
|
||||||
|
Because runtime state lives in the host keyed by entity — never on the component
|
||||||
|
— play-mode snapshot/restore is unaffected. A script that fails to compile or
|
||||||
|
raises a runtime error is **paused** (its error remembered, reported via
|
||||||
|
`ScriptHost::error_of`) rather than retried every frame or allowed to crash the
|
||||||
|
host — the start of Stage 10's error-isolation goal.
|
||||||
|
|
||||||
|
## Live reload
|
||||||
|
|
||||||
|
Editing a script while the app runs takes effect with **no restart**. The host
|
||||||
|
keeps each running script's `Handle<ScriptAsset>` alive, so the asset stays in
|
||||||
|
the server's cache. When the Stage-6 file watcher sees a `.rhai` file change it
|
||||||
|
calls
|
||||||
|
[`reload_changed_assets`](file-watching.md), which reruns the loader **in place**
|
||||||
|
on the live handle; the next frame the host reads the new source through that
|
||||||
|
handle, sees it differs from what it compiled, and recompiles + restarts that one
|
||||||
|
script. The entity keeps its current transform and the rest of the scene is
|
||||||
|
untouched.
|
||||||
|
|
||||||
|
`examples/script_spin` proves this headlessly: it spins an entity at 1 rad/s,
|
||||||
|
rewrites the script to 3 rad/s, reloads it the way the watcher does, and the spin
|
||||||
|
rate jumps mid-run while the orientation carries over.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-examples --bin script_spin
|
||||||
|
```
|
||||||
|
|
||||||
|
> Wiring this into the editor's **play loop** (so editing a script in an external
|
||||||
|
> editor or via an AI agent updates a *playing* scene) is a following piece; the
|
||||||
|
> reload mechanism itself is done and tested.
|
||||||
|
|
||||||
|
### Lifecycle hooks
|
||||||
|
|
||||||
|
A script may define either or both of these functions; top-level statements run
|
||||||
|
once at start (a constructor for defining functions and one-shot setup):
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
// scripts/spin.rhai — rotate this entity around Y at a constant rate.
|
||||||
|
let speed = 1.5; // top-level state, set once at start
|
||||||
|
|
||||||
|
fn init() { // optional: called once, after the top level
|
||||||
|
print("spin starting");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(dt) { // optional: called every frame with the frame delta
|
||||||
|
rotate_y(dt * 1.5);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Hook | When | Signature |
|
||||||
|
|------|------|-----------|
|
||||||
|
| top level | once, when the script (re)starts | statements at file scope |
|
||||||
|
| `init()` | once, right after the top level | `fn init()` |
|
||||||
|
| `update(dt)` | every frame | `fn update(dt)` — `dt` is seconds |
|
||||||
|
|
||||||
|
## Editor integration
|
||||||
|
|
||||||
|
Scripting plugs into the editor the same way physics does:
|
||||||
|
|
||||||
|
- **Add Component.** `register_builtin_types` registers `Script` as an *addable*
|
||||||
|
reflected component, so it appears in the inspector's Add Component menu and is
|
||||||
|
rendered generically from its fields. The `source` field is an
|
||||||
|
`AssetRef<ScriptAsset>`, which the inspector shows as an asset picker filtered
|
||||||
|
to the project's `scripts/` folder (`AssetKind::Script`).
|
||||||
|
- **New Script button.** The `Script` section of the inspector ends with a name
|
||||||
|
field + **➕ New Script**: it writes a fresh `.rhai` from the built-in template
|
||||||
|
into `assets/scripts/` (name sanitized, collisions suffixed `_2`, `_3`, …),
|
||||||
|
registers it in the asset database, and assigns it to the component through
|
||||||
|
the undo stack — no round-trip through an external file manager. The template
|
||||||
|
is `oxide_editor::assets::script_template` (a unit test compiles it under the
|
||||||
|
sandboxed engine so it can never ship a syntax error).
|
||||||
|
- **Open in editor.** Next to New Script sits **✏ Edit** (enabled once a script
|
||||||
|
is assigned), and double-clicking a script in the **Project panel** does the
|
||||||
|
same: the file opens in your editor. Resolution order: the **External Editor**
|
||||||
|
command from Preferences (`editor.external_editor`, run as `<command> <file>`,
|
||||||
|
flags allowed — e.g. `code -g`); else `$VISUAL`/`$EDITOR` in a new
|
||||||
|
**Terminal-panel tab** (TUI editors work in-editor); else `xdg-open`. However
|
||||||
|
it opens, saving feeds the file watcher's live reload — including into a
|
||||||
|
playing scene. Double-clicking a non-script asset opens it via `xdg-open`.
|
||||||
|
- **Play loop.** When Play starts, the editor's play `App` adds `ScriptModule`
|
||||||
|
alongside `PhysicsModule`, **shares the editor's `AssetServer`**, and is handed
|
||||||
|
a snapshot of the project `AssetDatabase`. Sharing the server is what lets the
|
||||||
|
file watcher's in-place reloads — which target the editor server — reach a
|
||||||
|
**playing** scene: edit a `.rhai` file (by hand, an external editor, or an AI
|
||||||
|
agent) and the running script recompiles without leaving Play.
|
||||||
|
- **Snapshot/restore.** Because `Script` is a reflected component, the play-mode
|
||||||
|
snapshot captures it; a script attached or detached *during* play is reverted
|
||||||
|
on Stop, like any other component.
|
||||||
|
|
||||||
|
## Errors and output in the Console
|
||||||
|
|
||||||
|
A script that fails to compile or raises a runtime error is **paused** — it stops
|
||||||
|
running but the editor stays alive (the error is caught, never panicked). The
|
||||||
|
host logs the failure once via `log::warn!(target: "oxide_script", …)` and
|
||||||
|
remembers it (`ScriptHost::error_of`), so it is not retried until the source
|
||||||
|
changes.
|
||||||
|
|
||||||
|
The editor's **Console panel** captures the `log` stream into a ring buffer and
|
||||||
|
renders it, coloured by severity. Because script `print`/`debug` and the
|
||||||
|
"script paused: …" errors all flow through `log` under the `oxide_script` target,
|
||||||
|
they appear in the Console automatically — so you see a script's output and its
|
||||||
|
failures without leaving the editor.
|
||||||
|
|
||||||
|
## The command terminal
|
||||||
|
|
||||||
|
The Console panel doubles as a **command terminal**: a `$` prompt runs a shell
|
||||||
|
command (`sh -c`) with the working directory set to the open project's root, and
|
||||||
|
its stdout/stderr stream back into the same panel line by line as they arrive
|
||||||
|
(stdout at info, stderr at warn, plus the echoed command and an exit-status
|
||||||
|
line). A long-running command — a build, a watcher, an AI-agent CLI — streams
|
||||||
|
rather than blocking the editor: reader threads push each line to the shared
|
||||||
|
console buffer and the panel re-renders next frame.
|
||||||
|
|
||||||
|
This is the surface for non-interactive dev tools. Running arbitrary commands
|
||||||
|
from the editor is intentional (a developer tool, compiled out of an exported
|
||||||
|
game).
|
||||||
|
|
||||||
|
### Interactive terminal (PTY)
|
||||||
|
|
||||||
|
The command console pipes output and can't run programs that need a real
|
||||||
|
terminal. The separate **Terminal panel** can: it opens a pseudo-terminal with
|
||||||
|
[`portable-pty`] (Linux now, Windows later), parses the program's byte stream
|
||||||
|
with [`vt100`] into a screen grid, renders that grid in egui, and routes
|
||||||
|
keystrokes back — so it runs **interactive / full-screen programs**: a shell, a
|
||||||
|
REPL, `vim`, or an **AI-agent CLI like `claude`**. Click *Shell* to start your
|
||||||
|
`$SHELL` in the project directory, then run whatever you need inside it
|
||||||
|
(`claude`, an editor, a build watcher). Tab, the arrow keys, and Escape are
|
||||||
|
delivered to the program (not used for egui focus navigation) while the panel is
|
||||||
|
focused, so completion, history, and full-screen apps work. Sessions are
|
||||||
|
**tabbed** — `+ Shell` opens another, each tab has a close button, and a tab
|
||||||
|
**auto-closes when its program exits** (type `exit` and the tab disappears).
|
||||||
|
This is what hosts agents that edit the watched scripts live — their edits flow
|
||||||
|
back through [live reload](#live-reload).
|
||||||
|
|
||||||
|
The two pieces compose: use the **Console** for builds/git/log output, the
|
||||||
|
**Terminal** for interactive sessions.
|
||||||
|
|
||||||
|
[`portable-pty`]: https://docs.rs/portable-pty
|
||||||
|
[`vt100`]: https://docs.rs/vt100
|
||||||
|
|
||||||
|
## The engine API scripts call
|
||||||
|
|
||||||
|
A script does not get a raw ECS pointer. The host stages the active entity's
|
||||||
|
`Transform` into a shared context before each call; the script reads and mutates
|
||||||
|
it through **ambient functions**, and the host writes the result back. This is
|
||||||
|
the `Transform` half of dual-editability: a script and the inspector edit the
|
||||||
|
**same** transform.
|
||||||
|
|
||||||
|
| Function | Effect |
|
||||||
|
|----------|--------|
|
||||||
|
| `position() -> Vec3` | the entity's local translation |
|
||||||
|
| `set_position(Vec3)` | set the translation |
|
||||||
|
| `translate(Vec3)` / `translate(x, y, z)` | add to the translation |
|
||||||
|
| `scale() -> Vec3` / `set_scale(Vec3)` | get/set the local scale |
|
||||||
|
| `rotate_x/rotate_y/rotate_z(radians)` | spin about an axis (accumulates) |
|
||||||
|
| `dt() -> f32` | the current frame delta (also passed to `update`) |
|
||||||
|
|
||||||
|
The `Vec3` type is registered with `vec3(x, y, z)` / `vec3()`, `.x`/`.y`/`.z`
|
||||||
|
get/set, `+ - *` (and scalar `*`), `length()`, `normalize()`, and `to_string()`.
|
||||||
|
The `rhai` float type is configured to `f32` (the `f32_float` feature), so engine
|
||||||
|
math values bridge into scripts with no casts.
|
||||||
|
|
||||||
|
### Spawning entities and editing components
|
||||||
|
|
||||||
|
Beyond its own transform, a script can mutate the whole scene graph: spawn and
|
||||||
|
despawn entities, and add, edit, or remove **any registered component** on any
|
||||||
|
entity. The same "stage in, read back out" discipline applies — a script cannot
|
||||||
|
borrow the ECS directly (the `rhai` functions must be `Send + Sync`), so each of
|
||||||
|
these calls **buffers a command** that the host drains and applies against the
|
||||||
|
scene + reflection registry after the script returns. Component edits go through
|
||||||
|
**RON**, so a script authors data exactly like the inspector or an AI agent does
|
||||||
|
— the same dual-editable representation.
|
||||||
|
|
||||||
|
| Function | Effect |
|
||||||
|
|----------|--------|
|
||||||
|
| `entity() -> Entity` | the entity this script runs on |
|
||||||
|
| `spawn_entity() -> Entity` / `spawn_entity(name)` | create a root entity, returns a handle usable immediately |
|
||||||
|
| `despawn(Entity)` | remove an entity (and its subtree) |
|
||||||
|
| `add_component(Entity, type_name)` | add a default-constructed component if the type is *addable* and absent |
|
||||||
|
| `set_component(Entity, type_name, ron)` | insert or replace a component from its RON form |
|
||||||
|
| `remove_component(Entity, type_name)` | remove the named component if present |
|
||||||
|
|
||||||
|
`spawn_entity` returns a **provisional** `Entity` handle: the entity does not
|
||||||
|
exist in the ECS yet, but the script can configure it in the same frame
|
||||||
|
(`set_component(e, …)`, `despawn(e)`) — the host resolves the provisional id to
|
||||||
|
the real entity when it applies the buffered commands, in issue order. (`spawn`
|
||||||
|
is a reserved word in `rhai`, hence the longer name.)
|
||||||
|
|
||||||
|
`type_name` is the name the component was registered under (e.g. `"MeshRenderer"`,
|
||||||
|
`"RigidBody"`, or your own `register_type::<T>("…")`). A command that names an
|
||||||
|
unregistered type, or whose RON fails to parse, is logged to the Console and
|
||||||
|
skipped — one bad call never aborts the rest or crashes the host.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Spawn a pickup and configure it in one frame:
|
||||||
|
fn init() {
|
||||||
|
let pickup = spawn_entity("Coin");
|
||||||
|
set_component(pickup, "MeshRenderer", "(mesh: Some(\"coin\"), ...)");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
[`ScriptError`]: #the-scriptengine-wrapper
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Settings & Preferences Framework
|
||||||
|
|
||||||
|
`oxide_engine::settings` is the engine's unified, serialized configuration store.
|
||||||
|
The engine, the editor, and every module register typed **sections**; the
|
||||||
|
framework persists them all to RON and restores them — without any central code
|
||||||
|
knowing the sections' shapes. It is the backbone of the editor's Preferences
|
||||||
|
window and of per-module settings.
|
||||||
|
|
||||||
|
## Sections
|
||||||
|
|
||||||
|
A section is any plain `serde`-serializable struct with a `Default`. Register it
|
||||||
|
once under a name and the store owns a typed instance:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::settings::Settings;
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
|
struct EditorPrefs { theme: String, grid: bool }
|
||||||
|
|
||||||
|
let mut settings = Settings::new();
|
||||||
|
settings.register::<EditorPrefs>("editor");
|
||||||
|
|
||||||
|
// Typed read/write.
|
||||||
|
settings.get_mut::<EditorPrefs>("editor").unwrap().theme = "dark".into();
|
||||||
|
assert_eq!(settings.get::<EditorPrefs>("editor").unwrap().theme, "dark");
|
||||||
|
```
|
||||||
|
|
||||||
|
`set` replaces a section's value (only if the registered type matches), and
|
||||||
|
`reset` returns it to `Default`. Accessing a section as the wrong type returns
|
||||||
|
`None` rather than panicking.
|
||||||
|
|
||||||
|
## Persisting
|
||||||
|
|
||||||
|
Every section serializes to a `name → RON` map via `export`, and `import` loads
|
||||||
|
matching sections back. This map is exactly the shape a
|
||||||
|
[`Project`](projects.md) stores, so per-project settings round-trip through the
|
||||||
|
project file:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::settings::Settings;
|
||||||
|
# use serde::{Serialize, Deserialize};
|
||||||
|
# #[derive(Serialize, Deserialize, Default)] struct EditorPrefs { theme: String }
|
||||||
|
# let mut settings = Settings::new();
|
||||||
|
# settings.register::<EditorPrefs>("editor");
|
||||||
|
let saved = settings.export(); // BTreeMap<String, String>
|
||||||
|
|
||||||
|
let mut restored = Settings::new();
|
||||||
|
restored.register::<EditorPrefs>("editor");
|
||||||
|
restored.import(&saved); // matching sections restored
|
||||||
|
```
|
||||||
|
|
||||||
|
`import` is deliberately lenient: an **unknown** section (e.g. one owned by a
|
||||||
|
disabled module) is ignored, and a **malformed** section is skipped, leaving its
|
||||||
|
current value. This means a project saved with a module enabled still opens
|
||||||
|
cleanly with that module disabled, and vice-versa.
|
||||||
|
|
||||||
|
## How the layers fit together
|
||||||
|
|
||||||
|
| Scope | Lives where | Persisted to |
|
||||||
|
|-------|-------------|--------------|
|
||||||
|
| Engine preferences (render/quality defaults) | a `Settings` section | global prefs file / project |
|
||||||
|
| Editor preferences (theme, layout, shortcuts) | a `Settings` section | global editor prefs file |
|
||||||
|
| Per-module settings | each module registers a section | the [project](projects.md) it's enabled in |
|
||||||
|
|
||||||
|
`Project` stores the per-project subset (`set_settings_section` /
|
||||||
|
`settings_section` hold the same RON blobs `export`/`import` produce). The editor
|
||||||
|
keeps its global preferences in a separate file using the same `Settings` API.
|
||||||
|
|
||||||
|
[`Settings`]: ../engine/src/settings.rs
|
||||||
+860
@@ -0,0 +1,860 @@
|
|||||||
|
# UI System
|
||||||
|
|
||||||
|
The `oxide_engine::ui` module is the engine's **in-game** UI system — what an
|
||||||
|
exported game uses to draw menus, HUDs, and tools. It is intentionally
|
||||||
|
separate from the editor's `egui` (which stays editor-only): a shipped game
|
||||||
|
cannot link `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 ships in pieces. This document covers what is live today and tells
|
||||||
|
you where the rest is going.
|
||||||
|
|
||||||
|
## What's live today
|
||||||
|
|
||||||
|
- **Piece 1 — widget tree + layout** (data structures, three layout modes,
|
||||||
|
pure-logic layout function). See [below](#whats-in-piece-1--widget-tree--layout).
|
||||||
|
- **Piece 2 — styling & theming** (per-widget visual overrides, named-style
|
||||||
|
themes, RON cascade). See [below](#whats-in-piece-2--styling--theming).
|
||||||
|
- **Piece 3 — text shaping & glyph atlas** (TTF loading via `ab_glyph`,
|
||||||
|
shelf-packed R8 atlas, multi-font line wrapping with alignment + DPI
|
||||||
|
scaling). See [below](#whats-in-piece-3--text-shaping--glyph-atlas).
|
||||||
|
- **Piece 4a — screen-space overlay render pass** (`paint` turns a laid-out
|
||||||
|
tree into draw commands; `UiOverlayPass` batches them through wgpu with one
|
||||||
|
R8 atlas and one alpha-blended pipeline). See [below](#whats-in-piece-4a--screen-space-overlay-render-pass).
|
||||||
|
- **Piece 4b — world-space UI panels** (`UiPanel` carries a `Widget` tree +
|
||||||
|
pixel/world sizes; `UiBatch::world_space(...)` composes the MVP that
|
||||||
|
places the UI on a 3D quad through a perspective camera). See [below](#whats-in-piece-4b--world-space-ui-panels).
|
||||||
|
- **Piece 5 — input routing** (`Router` walks the `LayoutTree` against the
|
||||||
|
Stage-7 `InputState`, tracks hover / press / focus per widget, and emits
|
||||||
|
events plus capture flags the host uses to decide whether the game also
|
||||||
|
receives the input). See [below](#whats-in-piece-5--input-routing).
|
||||||
|
- **Piece 6 — events + data binding** (immediate-mode queries on
|
||||||
|
`RouterFrame` — `clicked_left("play")` etc. — plus typed `WidgetValue`s
|
||||||
|
on the tree so game state and widget state round-trip each frame). See
|
||||||
|
[below](#whats-in-piece-6--events--data-binding).
|
||||||
|
- **Piece 7 — `examples/ui_menu`** (runnable main menu + settings panel
|
||||||
|
built entirely from the Stage-8 stack: themed buttons, a draggable
|
||||||
|
volume slider, a clickable invert-Y checkbox, Back/Quit navigation).
|
||||||
|
Run with `cargo run -p oxide-examples --bin ui_menu`.
|
||||||
|
- **Piece 8 — `examples/ui_hud`** (a game HUD composited on top of a live
|
||||||
|
3D scene: the Stage-4 `ForwardPass` renders the spinning cube/sphere/
|
||||||
|
plane, then a screen-space `UiOverlayPass` draws corner-anchored HP/Ammo
|
||||||
|
chips, a minimap stand-in with an orbiting dot, and a centre crosshair —
|
||||||
|
with animated digits that demonstrate the glyph-atlas cache reaching
|
||||||
|
steady state). Run with `cargo run -p oxide-examples --bin ui_hud`. See
|
||||||
|
[below](#whats-in-piece-8--examplesui_hud).
|
||||||
|
- **Editor UI canvas** (Stage 8.5 piece 7) — the editor's **UI Canvas** panel
|
||||||
|
authors a `UiPanel` document visually: a widget-tree view (positional
|
||||||
|
[`WidgetPath`](../engine/src/ui/widget.rs) addressing), an Add palette
|
||||||
|
(Leaf/Row/Column/Grid/Anchor), a scaled live preview, and a property panel
|
||||||
|
(id, text, colors, font size, **font-asset picker**, layout sizing). Edits are
|
||||||
|
undoable and the document saves as a `ui/` asset — the same RON the runtime
|
||||||
|
loads. The picker writes [`VisualStyle::font_asset`](../engine/src/ui/visual.rs),
|
||||||
|
resolved through the [asset database](assets.md).
|
||||||
|
|
||||||
|
## What's in piece 1 — widget tree + layout
|
||||||
|
|
||||||
|
Piece 1 is pure-logic: data structures + a deterministic layout function. No
|
||||||
|
GPU, no input, no async. Every test runs headlessly.
|
||||||
|
|
||||||
|
- **`Widget`** — one node in a tree. Holds an [`id`](#widget-ids), a
|
||||||
|
[`LayoutStyle`](#layoutstyle), and a [`WidgetKind`](#widgetkinds).
|
||||||
|
- **`WidgetKind`** — what the node is:
|
||||||
|
- `Leaf { intrinsic: Vec2 }` — childless node sized by an intrinsic logical
|
||||||
|
extent. Interactive widgets (label, button, image, slider, …) layer on
|
||||||
|
top of this in later pieces.
|
||||||
|
- `Stack(Stack)` — row or column container with a per-stack `gap`,
|
||||||
|
`direction`, and `main_align`.
|
||||||
|
- `Grid(Grid)` — equal-cell `cols × rows` container with a `gap: Vec2`.
|
||||||
|
- `Anchor(AnchorGroup)` — container that positions each child via the
|
||||||
|
**child's** own [`Anchor`](#anchor).
|
||||||
|
- **`LayoutStyle`** — sizing, padding, margin, alignment, and (for anchor
|
||||||
|
children) the anchor itself. The same flat struct on every widget.
|
||||||
|
- **`layout(root, viewport, scale) -> LayoutTree`** — the layout function.
|
||||||
|
Returns a `LayoutTree` of `LayoutNode`s (one per widget, root at index 0)
|
||||||
|
with each node's resolved `rect`, `content_rect` (padding-inset), and the
|
||||||
|
indices of its direct children.
|
||||||
|
|
||||||
|
The whole module lives under
|
||||||
|
[`engine/src/ui/`](../engine/src/ui/) and is re-exported through the engine
|
||||||
|
prelude under disambiguated names (`UiSizing`, `UiAnchor`, `Widget`, …) so it
|
||||||
|
doesn't collide with the Stage-1 math types.
|
||||||
|
|
||||||
|
## Building a widget tree
|
||||||
|
|
||||||
|
The `Widget::row()`, `Widget::column()`, `Widget::grid(cols, rows)`,
|
||||||
|
`Widget::anchor()`, and `Widget::leaf(intrinsic)` constructors plus the
|
||||||
|
`with_*` builder methods produce trees declaratively. Builder methods that
|
||||||
|
only make sense on certain kinds (`with_gap` on a stack, `with_grid_gap` on a
|
||||||
|
grid, `with_child` on any container) panic with a clear message when called
|
||||||
|
on the wrong kind — catching author mistakes during construction instead of
|
||||||
|
producing a silently misshapen UI at layout time.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::math::Vec2;
|
||||||
|
use oxide_engine::ui::{Insets, LayoutStyle, Sizing, Widget};
|
||||||
|
|
||||||
|
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::leaf(Vec2::new(64.0, 24.0)).with_id("edit"));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sizing
|
||||||
|
|
||||||
|
`Sizing` controls how a widget asks to be sized along one axis.
|
||||||
|
|
||||||
|
| Variant | Behavior |
|
||||||
|
|---------|----------|
|
||||||
|
| `Fixed(f32)` | Fixed logical size; multiplied by the layout scale factor. |
|
||||||
|
| `Grow(f32)` | Take a share of the parent's leftover space, weighted by `f32`. Two siblings with `Grow(1.0)` split evenly; `Grow(2.0)` next to `Grow(1.0)` takes 2/3. A non-positive weight contributes nothing. |
|
||||||
|
| `FitContent` (default) | Fit the widget's intrinsic content size — leaves use their `intrinsic`, containers use the recursive content extent. |
|
||||||
|
|
||||||
|
The defaults of `FitContent × FitContent` are intentional: leaves are sized
|
||||||
|
by what they contain, containers are sized by what they wrap. A root widget
|
||||||
|
that wants to **fill the viewport** must opt in with
|
||||||
|
`Sizing::Grow(_)` on both axes (or set `Fixed` extents) — the layout function
|
||||||
|
makes no special root case.
|
||||||
|
|
||||||
|
## Padding, margin, alignment
|
||||||
|
|
||||||
|
- **`padding`** shrinks a widget's `content_rect`, the area inside which
|
||||||
|
children are arranged. Multiplied by the scale factor.
|
||||||
|
- **`margin`** reserves space *outside* the widget's rect, so siblings don't
|
||||||
|
touch it. In a stack, margin is added to the child's main-axis footprint
|
||||||
|
before grow accounting.
|
||||||
|
- **`align_horizontal` / `align_vertical`** position a widget within its
|
||||||
|
parent's slot when the widget's resolved size is **smaller** than the slot.
|
||||||
|
In a stack, cross-axis alignment lets a short child dock to the top,
|
||||||
|
middle, or bottom of its row. (The stack-level `main_align` does the
|
||||||
|
analogous thing on the main axis when there's no `Grow` child to absorb
|
||||||
|
leftover space.)
|
||||||
|
|
||||||
|
## Layout modes
|
||||||
|
|
||||||
|
### Stack (`StackDirection::Row` / `Column`)
|
||||||
|
|
||||||
|
1. Allocate each child's **main-axis** size:
|
||||||
|
- `Fixed(v)` → `v * scale`,
|
||||||
|
- `FitContent` → recursive intrinsic measurement,
|
||||||
|
- `Grow(w)` → reserved (zero first), then assigned a share of leftover
|
||||||
|
space proportional to `w`.
|
||||||
|
2. **Cross-axis** sizing happens during the child's own `arrange_in_slot`
|
||||||
|
pass: `Grow` fills the parent's cross extent; the other variants leave
|
||||||
|
space the child's `align_*` consumes.
|
||||||
|
3. With no `Grow` child, the stack's `main_align` (Start / Center / End)
|
||||||
|
positions the children's combined footprint inside the content rect.
|
||||||
|
|
||||||
|
### Grid
|
||||||
|
|
||||||
|
Equal-cell `cols × rows` layout. Cell size is computed from the parent's
|
||||||
|
content rect after subtracting `(cols - 1) * gap.x` and `(rows - 1) * gap.y`.
|
||||||
|
Children fill cells left-to-right, top-to-bottom; extras past `cols * rows`
|
||||||
|
are ignored. Within a cell the child's own `align_*` and sizing decide how it
|
||||||
|
positions itself — `Grow` fills the cell, `Fixed`/`FitContent` aligns inside
|
||||||
|
it.
|
||||||
|
|
||||||
|
More flexible grids (auto-sized rows/columns, spans) are a follow-up; the
|
||||||
|
equal-cell case covers the Stage-7 bindings preferences page and the Stage-8
|
||||||
|
settings examples.
|
||||||
|
|
||||||
|
### Anchor
|
||||||
|
|
||||||
|
Each child specifies its own `Anchor` in `LayoutStyle::anchor`. The anchor is
|
||||||
|
two normalized points in `[0, 1]²` (the anchor rectangle) plus per-corner
|
||||||
|
offsets in logical pixels:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
The Unity/Godot convention applies: the anchor is **authoritative**. An
|
||||||
|
anchor child's `width`, `height`, `margin`, and `align_*` are ignored along
|
||||||
|
the axes the anchor constrains; padding still applies (it's an
|
||||||
|
inside-the-rect concern). The
|
||||||
|
`Anchor::FILL`, `Anchor::TOP`, `Anchor::TOP_LEFT`, `Anchor::BOTTOM_RIGHT`, …
|
||||||
|
constants cover the common cases, and `Anchor::between(min, max)` +
|
||||||
|
`with_offsets(min, max)` is the escape hatch.
|
||||||
|
|
||||||
|
## DPI
|
||||||
|
|
||||||
|
All linear inputs (sizing, padding, margin, gap, anchor offsets) are in
|
||||||
|
**logical pixels** and multiplied by the `scale` factor passed to
|
||||||
|
[`layout`]. The widget tree is DPI-independent; the layout call is where the
|
||||||
|
display's scale factor enters. The same widget tree laid out at `scale=1.0`
|
||||||
|
inside a 800 × 600 viewport and at `scale=2.0` inside a 1600 × 1200 viewport
|
||||||
|
produces identically *proportioned* rects, with every dimension doubled —
|
||||||
|
verified by an integration test.
|
||||||
|
|
||||||
|
## Widget ids and lookups
|
||||||
|
|
||||||
|
`WidgetId(pub String)` is the author-facing identifier. UI documents ship
|
||||||
|
their string ids straight through RON (`"play"`, `"volume-slider"`), so a
|
||||||
|
visual editor, a hand-edited file, and game code all refer to the same
|
||||||
|
widget. The empty id (`""`) is the default and means "anonymous"; multiple
|
||||||
|
anonymous widgets are allowed and `LayoutTree::find` rejects lookups by empty
|
||||||
|
id.
|
||||||
|
|
||||||
|
`LayoutTree::find(id)` is a linear scan — fine for the dozens-of-widgets
|
||||||
|
trees Stage 8 currently targets; a hash-map index can be added if a profile
|
||||||
|
ever says it's hot.
|
||||||
|
|
||||||
|
## RON dual-edit
|
||||||
|
|
||||||
|
Every type in the module derives `Serialize + Deserialize` and round-trips
|
||||||
|
through RON. `Widget::to_ron()` produces the pretty-printed canonical form
|
||||||
|
the editor's UI canvas saves and the runtime loads; `Widget::from_ron(text)`
|
||||||
|
parses it. The Stage-8 integration suite verifies that the round-trip
|
||||||
|
**preserves layout** — the laid-out trees match — so an external editor or AI
|
||||||
|
agent can edit the same file the runtime loads.
|
||||||
|
|
||||||
|
## What's in piece 2 — styling & theming
|
||||||
|
|
||||||
|
Visual styling is intentionally **orthogonal** to layout — layout decides
|
||||||
|
where a widget is; visual styling decides what it looks like. Adding a
|
||||||
|
`VisualStyle` or `theme_style` to a widget never changes its laid-out rect.
|
||||||
|
The integration suite verifies this with a paired `layout()` call before and
|
||||||
|
after styling.
|
||||||
|
|
||||||
|
The data:
|
||||||
|
|
||||||
|
- **`VisualStyle`** — a flat struct of `Option<T>` fields: `background`,
|
||||||
|
`foreground`, `border` (color + width), `corner_radius`, `font`, and
|
||||||
|
`font_size`. `None` means *inherit*; `Some` means *override*. Every field
|
||||||
|
serializes via `skip_serializing_if = "Option::is_none"`, so an empty
|
||||||
|
visual style vanishes from RON entirely.
|
||||||
|
- **`Theme`** — `default: VisualStyle` plus `styles: BTreeMap<String,
|
||||||
|
VisualStyle>`. The `BTreeMap` (not `HashMap`) gives deterministic RON
|
||||||
|
output, important for diff-friendly UI documents and reproducible test
|
||||||
|
snapshots.
|
||||||
|
- **`Widget`** gains two fields: `visual: VisualStyle` (per-instance
|
||||||
|
overrides) and `theme_style: Option<String>` (opt-in name into the
|
||||||
|
theme's named map).
|
||||||
|
|
||||||
|
The cascade — implemented by `Theme::resolve(style_ref, override_with)` and
|
||||||
|
exposed on the widget as `Widget::resolve_visual(&theme)`:
|
||||||
|
|
||||||
|
1. Start with `theme.default`.
|
||||||
|
2. If the widget specifies `theme_style: Some(name)` and the theme has a
|
||||||
|
matching entry, merge it on top (a missing name is treated as "no
|
||||||
|
contribution", not an error).
|
||||||
|
3. Merge the widget's per-instance `visual` on top.
|
||||||
|
|
||||||
|
Each merge is field-by-field via `VisualStyle::merged(self, override_with)`:
|
||||||
|
right-hand `Some` wins, otherwise the left-hand value is kept. The same
|
||||||
|
primitive will drive runtime state overlays in piece 5 (hover, focus,
|
||||||
|
press).
|
||||||
|
|
||||||
|
`FontRef` carries `family`, `weight: FontWeight`, and `italic: bool`. The
|
||||||
|
descriptor stores **names**, not paths: portable across machines, and the
|
||||||
|
runtime (piece 3) is free to pick the platform's best match. `FontWeight`
|
||||||
|
exposes `opentype_value()` returning the OpenType 100–900 weight scale.
|
||||||
|
|
||||||
|
`VisualStyle` also has a `font_asset: Option<AssetRef<Font>>` (Stage 8.5 piece
|
||||||
|
7): a reference to a **specific project font asset** under `assets/fonts/`,
|
||||||
|
chosen in the editor's UI canvas from the asset browser. When set it takes
|
||||||
|
precedence over the `font` descriptor — the renderer resolves the [`AssetRef`]
|
||||||
|
to a loaded face through the [asset database](assets.md) (a default-registered
|
||||||
|
`FontLoader` makes `.ttf`/`.otf` loadable via the `AssetServer`). `None` falls
|
||||||
|
back to the descriptor / theme path. This is the engine's first `AssetRef<T>`
|
||||||
|
field and the asset-picker's end-to-end target.
|
||||||
|
|
||||||
|
[`AssetRef`]: ../engine/src/asset/database.rs
|
||||||
|
|
||||||
|
### Quick example
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::math::{Color, Vec2};
|
||||||
|
use oxide_engine::ui::{Border, FontRef, Theme, VisualStyle, Widget};
|
||||||
|
|
||||||
|
let 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
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let play = Widget::leaf(Vec2::new(80.0, 24.0))
|
||||||
|
.with_id("play")
|
||||||
|
.with_theme_style("button")
|
||||||
|
.with_visual(VisualStyle {
|
||||||
|
background: Some(Color::rgb(0.2, 0.4, 0.8)), // primary-button accent
|
||||||
|
foreground: Some(Color::WHITE),
|
||||||
|
..VisualStyle::EMPTY
|
||||||
|
});
|
||||||
|
|
||||||
|
let resolved = play.resolve_visual(&theme);
|
||||||
|
assert_eq!(resolved.foreground, Some(Color::WHITE)); // per-instance wins
|
||||||
|
assert_eq!(resolved.corner_radius, Some(4.0)); // inherited from "button"
|
||||||
|
assert_eq!(resolved.font, Some(FontRef::regular("Inter"))); // inherited from default
|
||||||
|
```
|
||||||
|
|
||||||
|
### RON dual-edit
|
||||||
|
|
||||||
|
`Theme::to_ron` / `Theme::from_ron` round-trip themes through pretty-printed
|
||||||
|
RON, matching `Widget::to_ron` from piece 1. `BTreeMap`-ordered output keeps
|
||||||
|
named styles alphabetised so diffs are stable. Empty fields (`None` options,
|
||||||
|
empty maps, `FontWeight::Regular`, `italic: false`) skip serializing — the
|
||||||
|
default form of any of these structs is `()` in RON.
|
||||||
|
|
||||||
|
## What's in piece 3 — text shaping & glyph atlas
|
||||||
|
|
||||||
|
The text subsystem lives at `oxide_engine::ui::text` and splits into three
|
||||||
|
sub-modules that compose, but each is testable on its own:
|
||||||
|
|
||||||
|
- **`font`** — owns `Font` (a thin wrapper around `ab_glyph::FontVec`),
|
||||||
|
`FontId`, and `FontStore`. `Font::rasterize(glyph, size_px)` returns a
|
||||||
|
`RasterizedGlyph` with an alpha mask + per-glyph bearings + advance.
|
||||||
|
`FontStore::insert_with_descriptor(FontRef, Font)` indexes a font under a
|
||||||
|
piece-2 `FontRef`, so a theme's `font: Some(FontRef::bold("Inter"))`
|
||||||
|
resolves to a `FontId` the shaper can use.
|
||||||
|
- **`atlas`** — `GlyphAtlas::new(width, height)` allocates a single R8
|
||||||
|
(alpha-only) buffer; `get_or_rasterize(GlyphKey, &FontStore)` returns the
|
||||||
|
glyph's `AtlasEntry` (UV rect + size + bearing + advance), rasterizing
|
||||||
|
and packing on first miss and serving the cache forever after. The
|
||||||
|
packer is a **best-fit shelf packer** — simple, deterministic, and
|
||||||
|
near-optimal density for the typically-uniform glyph heights of one font
|
||||||
|
at one size. The `dirty()` flag tells the piece-4 render pass when the
|
||||||
|
texture needs re-upload.
|
||||||
|
- **`shape`** — `shape(text, style, ¶ms, &fonts)` turns a string into
|
||||||
|
a `ShapedText { lines, size }` of positioned `ShapedGlyph`s. Each glyph
|
||||||
|
carries a `GlyphKey` the renderer feeds back into the atlas, and a
|
||||||
|
`position` at the **baseline** (not the top-left). Algorithm:
|
||||||
|
greedy line-break at ASCII whitespace, multi-font runs supported via
|
||||||
|
`shape_runs(&[TextRun])`, alignment within `max_width` (Left / Center /
|
||||||
|
Right), DPI scaling via `ShapeParams::scale`.
|
||||||
|
|
||||||
|
### The atlas is the cache
|
||||||
|
|
||||||
|
`GlyphAtlas` keys entries by `(FontId, GlyphId, size_px rounded to nearest
|
||||||
|
integer)`. Every glyph is rasterized **exactly once** per (font, glyph,
|
||||||
|
size) triple — a HUD that repaints `"HP: 1234 / 1500"` every frame
|
||||||
|
rasterizes the ten ASCII characters one time at startup and then runs
|
||||||
|
purely on textured quads. The integration suite verifies this:
|
||||||
|
`shaped_hud_text_is_cached_after_one_frame` shapes a three-line HUD,
|
||||||
|
walks every glyph through the atlas twice, and asserts the atlas's
|
||||||
|
`dirty` flag stays false on the second pass — i.e., zero new
|
||||||
|
rasterizations. The library choice (ab_glyph vs fontdue) only affects
|
||||||
|
the one-time miss cost, not steady-state.
|
||||||
|
|
||||||
|
### Quick example
|
||||||
|
|
||||||
|
```no_run
|
||||||
|
use oxide_engine::math::Vec2;
|
||||||
|
use oxide_engine::ui::text::{
|
||||||
|
shape, Font, FontStore, GlyphAtlas, ShapeParams, TextAlign, TextStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut fonts = FontStore::new();
|
||||||
|
let id = fonts.insert(Font::from_path("/usr/share/fonts/.../Inter-Regular.ttf").unwrap());
|
||||||
|
let style = TextStyle { font: id, size_px: 14.0 };
|
||||||
|
let params = ShapeParams {
|
||||||
|
max_width: Some(300.0),
|
||||||
|
align: TextAlign::Center,
|
||||||
|
line_height: 1.4,
|
||||||
|
scale: 1.0,
|
||||||
|
};
|
||||||
|
let shaped = shape("Press F to pay respects", style, ¶ms, &fonts);
|
||||||
|
|
||||||
|
let mut atlas = GlyphAtlas::new(1024, 1024);
|
||||||
|
for line in &shaped.lines {
|
||||||
|
for glyph in &line.glyphs {
|
||||||
|
// Render with the atlas's bearing offset; this is exactly the
|
||||||
|
// call piece 4's overlay pass will make per glyph per frame.
|
||||||
|
if let Some(entry) = atlas.get_or_rasterize(glyph.key, &fonts) {
|
||||||
|
let quad_top_left: Vec2 = glyph.position + entry.bearing;
|
||||||
|
let _quad_size: Vec2 = entry.size_px;
|
||||||
|
let _ = (quad_top_left, entry.uv_min, entry.uv_max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limitations (deliberate, scoped to piece 3)
|
||||||
|
|
||||||
|
- One glyph per `char` — no ligatures, no combining marks, no complex-
|
||||||
|
script shaping (Arabic, Devanagari, Thai). The data path is ready for
|
||||||
|
a future `rustybuzz`-shaped intermediate; the current shaper just
|
||||||
|
doesn't invoke one.
|
||||||
|
- No BiDi or RTL — text flows left-to-right.
|
||||||
|
- No hyphenation or character-level break inside an over-wide word.
|
||||||
|
- ASCII whitespace only (`\t` and `\r` are treated as spaces).
|
||||||
|
- No bold/italic synthesis — each face is a separately-loaded `Font`.
|
||||||
|
|
||||||
|
### Font choice
|
||||||
|
|
||||||
|
The engine doesn't bundle a font; piece-3 tests use whichever sans-serif
|
||||||
|
they find on `/usr/share/fonts/` (or `/System/Library/Fonts` on macOS) via
|
||||||
|
`common_system_font_paths()`, skipping with `eprintln!("SKIP: …")` when no
|
||||||
|
candidate is present. The default UI font shipped with examples is a
|
||||||
|
piece-7 decision.
|
||||||
|
|
||||||
|
### Why ab_glyph
|
||||||
|
|
||||||
|
`ab_glyph` is a TTF parser + rasterizer only. It does not do layout —
|
||||||
|
which is fine because the shaper above already owns that. With
|
||||||
|
`fontdue` we would have gotten line wrapping for free at the cost of
|
||||||
|
living inside a fixed layout model; with `ab_glyph` we own every line-
|
||||||
|
break, kerning, and alignment decision. That control buys us a clean
|
||||||
|
path to richer features later: rich-text markup, per-character
|
||||||
|
animation, in-canvas editor caret positioning, and **SDF font
|
||||||
|
rendering** — a future follow-up where each glyph is rasterized once
|
||||||
|
as a signed-distance field and the shader scales it to any size for
|
||||||
|
free. SDF is on the Stage-8 backlog in [PLAN.md](../PLAN.md); it would
|
||||||
|
slot in beside `ab_glyph` without rewriting the shaper.
|
||||||
|
|
||||||
|
## What's in piece 4a — screen-space overlay render pass
|
||||||
|
|
||||||
|
Piece 4 splits the GPU work into two commits — **4a (screen-space, this
|
||||||
|
piece)** and **4b (world-space UI panels in 3D)**. Both share one render
|
||||||
|
pass, one shader, one R8 glyph atlas. The split is purely for review
|
||||||
|
size; the same `UiOverlayPass` handles both modes via per-batch MVP
|
||||||
|
matrices.
|
||||||
|
|
||||||
|
Two new pieces, both pure-CPU but the second one talks to wgpu:
|
||||||
|
|
||||||
|
- **`oxide_engine::ui::paint`** — `paint(&Widget, &LayoutTree, &Theme,
|
||||||
|
&FontStore, scale) -> PaintedFrame`. Walks the laid-out tree in
|
||||||
|
parent-then-children order; for each node, resolves the cascaded
|
||||||
|
[`VisualStyle`](#whats-in-piece-2--styling--theming) under the theme,
|
||||||
|
emits one `DrawCommand::Quad` if a background was resolved, and shapes
|
||||||
|
the widget's `text: Option<String>` inside its `content_rect` to emit
|
||||||
|
one `DrawCommand::Glyph` per laid-out glyph. Pure-logic; tests run
|
||||||
|
without a GPU and most without a font.
|
||||||
|
- **`oxide_engine::render::UiOverlayPass`** — implements
|
||||||
|
[`RenderPass`](render-pipeline.md) and slots into the Stage-5 pipeline
|
||||||
|
*after* the `ForwardPass`. Consumes `Vec<UiBatch>` per frame; each batch
|
||||||
|
pairs an MVP matrix with a `PaintedFrame`. For piece 4a the host builds
|
||||||
|
one batch with `UiBatch::screen_space(painted, target_size)` — an
|
||||||
|
orthographic projection from window pixels to NDC with y-down (origin at
|
||||||
|
the top-left).
|
||||||
|
|
||||||
|
### Vertex format and shader
|
||||||
|
|
||||||
|
One vertex format, one fragment path:
|
||||||
|
|
||||||
|
```text
|
||||||
|
struct UiVertex { position: vec2, uv: vec2, color: vec4 } // 32 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
The shader (`engine/src/render/shaders/ui.wgsl`) discriminates "solid quad
|
||||||
|
vs. glyph quad" by a sentinel UV: `uv.x < 0.0` skips the atlas sample. So
|
||||||
|
a solid red rectangle and a glyph from "Inter" pass through identical
|
||||||
|
pipeline state and live in the same vertex buffer — no state changes per
|
||||||
|
primitive, no separate textures. Alpha-blending is on; UI never reads
|
||||||
|
depth (it overlays).
|
||||||
|
|
||||||
|
### Atlas lifecycle
|
||||||
|
|
||||||
|
Each frame's `run`:
|
||||||
|
|
||||||
|
1. Walk every glyph in every batch, calling
|
||||||
|
`GlyphAtlas::get_or_rasterize(key, &fonts)` to ensure the entry is
|
||||||
|
cached. Misses rasterize once; hits do nothing.
|
||||||
|
2. If the atlas's `dirty` flag is set, re-upload the whole R8 buffer to
|
||||||
|
the GPU texture and clear the flag. Re-uploading the whole atlas (vs.
|
||||||
|
tracking dirty sub-rects) keeps the code simple; the buffer is small
|
||||||
|
(1 MB at 1024×1024) so this is fine. A dirty-region upload is a
|
||||||
|
straightforward follow-up if a profile says it's hot.
|
||||||
|
3. For each batch: serialize draw commands into vertices, write the MVP
|
||||||
|
uniform, set the viewport from `FrameContext::resolved_viewport()`,
|
||||||
|
and submit one draw call.
|
||||||
|
|
||||||
|
### Test strategy
|
||||||
|
|
||||||
|
The piece-4 tests live in three places:
|
||||||
|
|
||||||
|
- `engine/src/ui/paint.rs` — 5 lib tests verify the CPU paint logic: a
|
||||||
|
solid widget emits one quad at its rect, a text widget emits one glyph
|
||||||
|
command per visible char at the same baseline, layered widgets draw
|
||||||
|
parent-before-child, etc. No GPU required.
|
||||||
|
- `engine/src/render/ui_pass.rs` — 4 headless GPU pixel-readback tests:
|
||||||
|
a 20×20 red quad shows red at its centre and clear-color outside; an
|
||||||
|
empty batch list is a no-op; two quads in one batch both render to
|
||||||
|
their respective rects; the vertex buffer grows when a batch exceeds
|
||||||
|
the initial 4096-vertex capacity.
|
||||||
|
- `tests/src/lib.rs` mod stage8 — 1 integration test runs the whole
|
||||||
|
pipeline: `Widget` → `layout` → `paint` → `UiOverlayPass::run` →
|
||||||
|
pixel-readback, then asserts the centred 48×48 red panel is red in the
|
||||||
|
middle and clear-color in the gutter.
|
||||||
|
|
||||||
|
Both lib and integration GPU tests skip with `eprintln!("SKIP: ...")` if
|
||||||
|
no adapter is available, matching the Stage-4 pattern.
|
||||||
|
|
||||||
|
### Wiring it into an app
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use oxide_engine::math::Color;
|
||||||
|
use oxide_engine::render::{RenderPipeline, UiBatch, UiOverlayPass};
|
||||||
|
use oxide_engine::ui::{layout, paint, FontStore, Theme, Widget};
|
||||||
|
# fn build_pipeline(device: &wgpu::Device, format: wgpu::TextureFormat) {
|
||||||
|
let mut pipeline = RenderPipeline::forward(device, format);
|
||||||
|
let ui_pass = UiOverlayPass::new(device, format);
|
||||||
|
pipeline.add_pass("ui", ui_pass);
|
||||||
|
# }
|
||||||
|
# fn each_frame(
|
||||||
|
# ui_pass: &mut UiOverlayPass,
|
||||||
|
# document: &Widget,
|
||||||
|
# theme: &Theme,
|
||||||
|
# fonts: &FontStore,
|
||||||
|
# viewport: oxide_engine::math::Rect,
|
||||||
|
# target_size: (u32, u32),
|
||||||
|
# ) {
|
||||||
|
let tree = layout(document, viewport, 1.0);
|
||||||
|
let painted = paint(document, &tree, theme, fonts, 1.0);
|
||||||
|
ui_pass.set_batches(vec![UiBatch::screen_space(painted, target_size)]);
|
||||||
|
// pipeline.render(&mut frame); — at next frame.
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's in piece 4b — world-space UI panels
|
||||||
|
|
||||||
|
`oxide_engine::ui::UiPanel` is a pure-data holder: a `Widget` tree plus two
|
||||||
|
sizes — `pixel_size` (the resolution the UI is laid out at) and
|
||||||
|
`world_size` (the panel's physical dimensions in world units). It does
|
||||||
|
*not* own the panel's `Transform`; that lives on the entity that hosts
|
||||||
|
the panel (eventually a hecs component), so the same panel can be
|
||||||
|
duplicated across many entities with different placements.
|
||||||
|
|
||||||
|
`UiBatch::world_space(painted, pixel_size, world_size, &panel_transform,
|
||||||
|
view_projection)` composes a single MVP that the existing piece-4a pass
|
||||||
|
uses unchanged:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mvp = view_projection
|
||||||
|
* panel_transform // world placement
|
||||||
|
* scale(world.x / pixel.x, -world.y / pixel.y, 1) // pixels → world (y-flip)
|
||||||
|
* translate(-pixel.x / 2, -pixel.y / 2, 0) // recentre pixel origin
|
||||||
|
```
|
||||||
|
|
||||||
|
A pixel at `(0, 0)` in the painted frame lands at the panel's top-left
|
||||||
|
corner in world space; a pixel at `pixel_size` lands at the bottom-right.
|
||||||
|
Same pipeline, same shader, same atlas — only the MVP differs.
|
||||||
|
|
||||||
|
`UiPanel::build_batch(theme, fonts, &panel_transform, view_projection)`
|
||||||
|
is the convenience that lays out + paints + builds the batch in one call.
|
||||||
|
Hosts that want finer control compose the same three steps by hand.
|
||||||
|
|
||||||
|
### Overlay semantics
|
||||||
|
|
||||||
|
World-space panels in piece 4b render as **overlays**: no depth test, no
|
||||||
|
depth write — they draw on top of whatever's in the colour 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's Stage-8 backlog and slots in by attaching the
|
||||||
|
depth target to a second pass of the same pipeline.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- 4 lib tests on `UiPanel`: `build_batch` returns `None` on zero
|
||||||
|
`pixel_size`, succeeds on a valid panel, the panel round-trips through
|
||||||
|
RON for dual-edit, and the identity-MVP sanity check maps pixel
|
||||||
|
`(0, 0)` to world `(-world.x/2, +world.y/2)` (verifying the y-flip).
|
||||||
|
- 1 GPU pixel-readback lib test (`world_space_panel_renders_inside_its_projected_region`):
|
||||||
|
a 2 m × 2 m red panel at the origin under a 60° camera 3 m away,
|
||||||
|
asserts the framebuffer centre is red and corners stay clear.
|
||||||
|
- 1 integration test (`ui_panel_in_3d_renders_under_perspective_camera`):
|
||||||
|
exercises the full `UiPanel::build_batch` → `UiOverlayPass` path end
|
||||||
|
to end with a real perspective camera and a pixel-readback assertion.
|
||||||
|
|
||||||
|
### Quick example
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use oxide_engine::math::{Color, Transform, Vec2, Vec3};
|
||||||
|
use oxide_engine::render::{Camera, UiBatch, UiOverlayPass};
|
||||||
|
use oxide_engine::ui::{FontStore, Theme, UiPanel, VisualStyle, Widget};
|
||||||
|
|
||||||
|
# fn each_frame(pass: &mut UiOverlayPass, panel: &UiPanel) {
|
||||||
|
let camera = Camera::perspective(60_f32.to_radians(), 0.1, 100.0);
|
||||||
|
let view_transform = Transform::looking_at(Vec3::new(0.0, 1.5, 4.0), Vec3::ZERO, Vec3::Y);
|
||||||
|
let view_projection = camera.view_projection(16.0 / 9.0, &view_transform);
|
||||||
|
|
||||||
|
// Where the panel sits in the world. Treat as if it were a Transform
|
||||||
|
// component on the entity hosting the panel.
|
||||||
|
let panel_transform = Transform::default();
|
||||||
|
|
||||||
|
let theme = Theme::new();
|
||||||
|
let fonts = FontStore::new();
|
||||||
|
let batch = panel
|
||||||
|
.build_batch(&theme, &fonts, &panel_transform, view_projection)
|
||||||
|
.expect("valid panel");
|
||||||
|
pass.set_batches(vec![batch]);
|
||||||
|
// pipeline.render(&mut frame); — at next frame.
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's in piece 5 — input routing
|
||||||
|
|
||||||
|
The UI must consume input *before* the game (PLAN.md): clicking a button
|
||||||
|
shouldn't also fire the game action bound to the same mouse button.
|
||||||
|
`oxide_engine::ui::routing` gives the host one object that does this
|
||||||
|
end-to-end:
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::ui::Router;
|
||||||
|
|
||||||
|
# fn each_frame(router: &mut Router, tree: &UiLayoutTree, input: &InputState) {
|
||||||
|
let frame = router.process(tree, input);
|
||||||
|
if !frame.captured_mouse {
|
||||||
|
// game receives mouse this frame
|
||||||
|
}
|
||||||
|
if !frame.captured_keyboard {
|
||||||
|
// game receives keys this frame
|
||||||
|
}
|
||||||
|
for event in &frame.events {
|
||||||
|
// piece 6 will dispatch each event to the matching widget's callback
|
||||||
|
}
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hit-test
|
||||||
|
|
||||||
|
`hit_test(&LayoutTree, point) -> Option<&LayoutNode>` walks the laid-out
|
||||||
|
nodes in **reverse order** — the same order as paint (parents-then-
|
||||||
|
children, earlier siblings before later ones), so the topmost-drawn
|
||||||
|
widget is the first one tested. Anonymous widgets
|
||||||
|
(`WidgetId::default()`) are skipped so a decorative container doesn't
|
||||||
|
block clicks reaching the button inside it.
|
||||||
|
|
||||||
|
### State machine
|
||||||
|
|
||||||
|
The `Router` persists three pieces of state across frames:
|
||||||
|
|
||||||
|
- **hovered** — recomputed each frame from the cursor + hit-test.
|
||||||
|
- **focused** — set when the cursor presses over a widget; cleared when
|
||||||
|
the cursor presses outside any widget. Survives subsequent hover
|
||||||
|
changes so a focused text input keeps focus while the cursor moves.
|
||||||
|
- **pending presses** — per-button, the widget that received the
|
||||||
|
most-recent unreleased press. A press → release on the **same**
|
||||||
|
widget emits `Clicked`. Drag-off then release cancels the click.
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
`RouterFrame.events: Vec<RouterEvent>` collects everything that
|
||||||
|
happened: `Hovered` / `Unhovered`, `Pressed` / `Released` / `Clicked`
|
||||||
|
(per mouse button), `FocusGained` / `FocusLost`. Piece 6 will dispatch
|
||||||
|
each event to per-widget callbacks; piece 5 is purely the state machine
|
||||||
|
producing the event list.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- 13 lib tests cover hit-test (topmost wins, anonymous skipped,
|
||||||
|
outside-root → None, padding gutter resolves to parent), hover/
|
||||||
|
unhover/swap-on-move, press → focus, press + release on the same
|
||||||
|
widget → click, drag-off cancels click, press outside clears focus,
|
||||||
|
captured-flag transitions, and cursor-unset → no hover.
|
||||||
|
- 1 integration test exercises the full hover → press → release →
|
||||||
|
click → move → press-outside-loses-focus sequence end-to-end with a
|
||||||
|
synthetic `InputState`.
|
||||||
|
|
||||||
|
All tests are pure-CPU; no GPU, no font, no window.
|
||||||
|
|
||||||
|
### What's deliberately not in piece 5
|
||||||
|
|
||||||
|
- **Keyboard focus navigation** (Tab / arrow keys to move focus) — a
|
||||||
|
small follow-up on top of the existing focus state.
|
||||||
|
- **Per-widget callbacks** — piece 6.
|
||||||
|
- **World-space hit-test** — clicking through a 3D panel needs a
|
||||||
|
ray-cast and an inverse-MVP. A follow-up that slots in by adding a
|
||||||
|
`Router::hit_test_world(ray, &UiPanel, &Transform)` helper.
|
||||||
|
|
||||||
|
## What's in piece 6 — events + data binding
|
||||||
|
|
||||||
|
Piece 6 takes the **immediate-mode** stance (same as Bevy UI and egui):
|
||||||
|
no callback storage, no `Rc<RefCell<...>>` for state, no lifetime
|
||||||
|
gymnastics — the host reads the `RouterFrame` each frame and acts
|
||||||
|
directly.
|
||||||
|
|
||||||
|
### Events: immediate-mode queries on `RouterFrame`
|
||||||
|
|
||||||
|
The piece-5 `RouterFrame` already carries the event list. Piece 6 adds
|
||||||
|
typed query methods that game code calls directly:
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
# fn each_frame(frame: oxide_engine::ui::RouterFrame) {
|
||||||
|
use oxide_engine::winit::event::MouseButton;
|
||||||
|
if frame.clicked_left("play") {
|
||||||
|
// start_game();
|
||||||
|
}
|
||||||
|
if frame.clicked("save", MouseButton::Right) {
|
||||||
|
// open_save_menu();
|
||||||
|
}
|
||||||
|
if frame.hovered_in("tooltip-target") {
|
||||||
|
// show_tooltip();
|
||||||
|
}
|
||||||
|
if frame.focus_gained("volume_slider") {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
The seven query methods — `clicked`, `clicked_left`, `pressed`,
|
||||||
|
`released`, `hovered_in`, `hovered_out`, `focus_gained`, `focus_lost`
|
||||||
|
— each take a widget id and (where applicable) a `MouseButton`, and
|
||||||
|
return `bool`. They scan the frame's event list, so the cost is linear
|
||||||
|
in the number of events emitted that frame — typically a handful.
|
||||||
|
|
||||||
|
### Data binding: `Widget::value: Option<WidgetValue>`
|
||||||
|
|
||||||
|
Every widget can carry typed state — a checkbox's bool, a slider's
|
||||||
|
float, a text input's string — independent of its `kind`. The
|
||||||
|
`WidgetValue` enum has variants `Bool(bool)` / `Int(i64)` /
|
||||||
|
`Float(f64)` / `Text(String)`, plus `From`-impls for `bool`, `i32`,
|
||||||
|
`i64`, `f32`, `f64`, `&str`, and `String`.
|
||||||
|
|
||||||
|
Per-widget access uses `Widget::value(&id)` and `Widget::set_value(&id,
|
||||||
|
v)` — both walk the subtree to find the widget by id:
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use oxide_engine::ui::{Widget, WidgetValue};
|
||||||
|
# fn pull_then_push(root: &mut Widget, audio_volume: &mut f32) {
|
||||||
|
// Pull game state into the widget tree (typically at the start of frame).
|
||||||
|
root.set_value(&"volume".into(), *audio_volume);
|
||||||
|
|
||||||
|
// ... user interacts, slider widget updates its own value ...
|
||||||
|
|
||||||
|
// Push the widget tree's value back into game state (at end of frame).
|
||||||
|
if let Some(v) = root.value(&"volume".into()).and_then(|v| v.as_float()) {
|
||||||
|
*audio_volume = v as f32;
|
||||||
|
}
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
For values that don't change between frames (e.g., a label's string),
|
||||||
|
no binding is needed — set it once.
|
||||||
|
|
||||||
|
### Why immediate-mode
|
||||||
|
|
||||||
|
The persistent-callback alternative (each widget owns a
|
||||||
|
`Box<dyn FnMut(...)>`) forces every callback to either:
|
||||||
|
|
||||||
|
- own its game state via `Rc<RefCell<...>>` (verbose, costs every
|
||||||
|
read), or
|
||||||
|
- borrow game state for `'static` (impossible), or
|
||||||
|
- defer to a queue (the same shape as immediate-mode, but indirected).
|
||||||
|
|
||||||
|
Immediate-mode skips all three: the widget tree is **data**, not a
|
||||||
|
network of callbacks. The host's main loop is the dispatcher; the
|
||||||
|
piece-6 queries are just convenient predicates over the event list.
|
||||||
|
|
||||||
|
### What's deliberately not in piece 6
|
||||||
|
|
||||||
|
- **Typed bindings helper** (`Bindings<T>` that registers per-field
|
||||||
|
getter/setter pairs and runs them automatically) — adds a `Box<dyn>`
|
||||||
|
abstraction over what's currently two lines of host code. Will land
|
||||||
|
alongside piece-7's settings example if the boilerplate becomes
|
||||||
|
painful.
|
||||||
|
- **Per-widget keyboard event delivery** (text input handling, hotkey
|
||||||
|
registration) — needs a focused-widget event-routing pass on top of
|
||||||
|
the piece-5 focus state. Either piece-7 or a follow-up.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- 6 lib tests on `WidgetValue` cover accessor matching, `From`
|
||||||
|
conversions for every primitive, and RON round-trip for each variant.
|
||||||
|
- 4 lib tests on `Widget`: `find_by_id` / `find_by_id_mut` walk the
|
||||||
|
subtree, `set_value` updates a descendant by id, `with_value` builder
|
||||||
|
works, the value round-trips through `Widget`'s own RON.
|
||||||
|
- 2 lib tests on `RouterFrame`: query methods return true for matching
|
||||||
|
events, false for non-matching, across every event variant.
|
||||||
|
- 1 integration test (`settings_widget_tree_round_trips_game_state_each_frame`):
|
||||||
|
pulls game state into a settings panel, simulates user interaction +
|
||||||
|
an Apply click, pushes the widget values back into game state, and
|
||||||
|
asserts the round-trip is exact.
|
||||||
|
|
||||||
|
## What's in piece 8 — `examples/ui_hud`
|
||||||
|
|
||||||
|
`examples/src/bin/ui_hud.rs` is the second runnable Stage-8 example and
|
||||||
|
the first to **composite the UI over a 3D scene**. It reuses the
|
||||||
|
`hello_mesh` scene (spinning cube + sphere + ground plane through the
|
||||||
|
Stage-4 `ForwardPass`) and draws a HUD on top with a screen-space
|
||||||
|
`UiOverlayPass`.
|
||||||
|
|
||||||
|
### Compositing two passes on one surface
|
||||||
|
|
||||||
|
The window runner clears the surface to the configured clear color
|
||||||
|
*before* `render`. Both the forward pass and the UI overlay then use
|
||||||
|
`LoadOp::Load` for their color attachment, so each draws over whatever
|
||||||
|
is already there:
|
||||||
|
|
||||||
|
1. `pipeline.render(&mut frame)` runs the forward pass — 3D geometry
|
||||||
|
plus its own depth buffer (cleared each call).
|
||||||
|
2. `ui_pass.run(&mut frame)` runs the overlay — no depth, alpha
|
||||||
|
blending — so the HUD sits on top of the 3D image.
|
||||||
|
|
||||||
|
The host owns the `UiOverlayPass` separately from the `RenderPipeline`
|
||||||
|
(rather than `add_pass`-ing it) because the overlay needs `set_batches`
|
||||||
|
mutated every frame and the pipeline consumes pass ownership. Both
|
||||||
|
passes share the same `FrameContext`, so the example builds the 3D
|
||||||
|
objects and the painted HUD, then calls the two `run`s back to back.
|
||||||
|
|
||||||
|
### Corner anchoring
|
||||||
|
|
||||||
|
Each HUD element is an anchor child of a full-screen anchor root. A
|
||||||
|
corner-pinned, fixed-size widget is expressed as a corner `Anchor`
|
||||||
|
constant plus offsets that define its box — e.g. a top-left chip is
|
||||||
|
`Anchor::TOP_LEFT.with_offsets((M, M), (M + W, M + H))`, and a centred
|
||||||
|
crosshair is `Anchor::between((0.5, 0.5), (0.5, 0.5)).with_offsets(...)`.
|
||||||
|
The crosshair's two bars are themselves anchor children spanning one
|
||||||
|
axis and pinned thin on the other.
|
||||||
|
|
||||||
|
### Demonstrating the atlas cache
|
||||||
|
|
||||||
|
The HP and Ammo values animate every frame (HP oscillates down then up;
|
||||||
|
Ammo counts down as if firing, reloading at 0). The digits change
|
||||||
|
constantly, but the glyph atlas only ever rasterizes each character
|
||||||
|
**once** — after the digits `0`–`9` and the static label text have been
|
||||||
|
seen, the atlas stops growing and every later frame is a pure cache hit
|
||||||
|
(no rasterize, no GPU re-upload). The example logs each atlas growth and
|
||||||
|
the moment it reaches steady state, via two accessors added to the pass:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pass.atlas_glyph_count(); // distinct glyphs cached so far
|
||||||
|
pass.atlas_dirty(); // grew-this-run flag (false in steady state)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `atlas_caches_glyphs_and_reaches_steady_state` GPU test in
|
||||||
|
`render::ui_pass` proves this property automatically: it draws the ten
|
||||||
|
digits one per frame (asserting the count grows by one each time), then
|
||||||
|
re-draws a cached digit and asserts the count holds and the dirty flag
|
||||||
|
stays clear.
|
||||||
|
|
||||||
|
Run it: `cargo run -p oxide-examples --bin ui_hud` (Esc quits).
|
||||||
|
|
||||||
|
## What's coming in the rest of Stage 8
|
||||||
|
- **Piece 9 — Editor UI canvas.** A new editor panel for visually
|
||||||
|
authoring `Widget` / `UiPanel` documents: a drag-from widget palette,
|
||||||
|
a canvas showing the document at target size with drag-resize handles,
|
||||||
|
a property inspector for `LayoutStyle` / `VisualStyle` / `text` /
|
||||||
|
`value`, RON save/load round-tripping the same format the runtime
|
||||||
|
loads, and a live preview rendered through the actual `UiOverlayPass`
|
||||||
|
(not egui). Likely splits into 9a (canvas + palette + inspector) and
|
||||||
|
9b (live preview + drag/resize handles).
|
||||||
|
|
||||||
|
The piece-1 data structures already accommodate the editor canvas: a
|
||||||
|
document is just a `Widget` tree, the inspector edits the same reflected
|
||||||
|
style structs the runtime uses, and the preview reuses the exact paint +
|
||||||
|
overlay pipeline the game ships with.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Windowing & the Application Loop
|
||||||
|
|
||||||
|
Stage 2 reference for `oxide_engine::window` — opening a window, running the
|
||||||
|
event loop, and receiving raw input. For what happens *inside* a frame (GPU
|
||||||
|
setup, clearing, resize handling) see [render-context.md](render-context.md).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The window module wraps [`winit`](https://docs.rs/winit) so applications never
|
||||||
|
talk to the event loop directly. You implement the `WindowApp` trait, hand it
|
||||||
|
to `run()` together with a `WindowConfig`, and the engine:
|
||||||
|
|
||||||
|
1. creates the window and the GPU [`RenderContext`](render-context.md),
|
||||||
|
2. calls `WindowApp::init` once,
|
||||||
|
3. then loops: forwards every raw window event to `WindowApp::event`, calls
|
||||||
|
`WindowApp::update` once per frame, and clears + presents the surface.
|
||||||
|
|
||||||
|
The loop runs in `Poll` mode (continuous rendering, as a game expects), not
|
||||||
|
event-driven `Wait` mode (as a desktop utility would use).
|
||||||
|
|
||||||
|
> **Stage 6 rename.** This trait was originally `App`. Stage 6 renamed it to
|
||||||
|
> `WindowApp` so the engine's [`oxide_engine::app::App`](modules.md) container
|
||||||
|
> (scene, assets, scheduled systems) could live in the prelude unambiguously.
|
||||||
|
> The two cover different roles: this trait is the per-frame window/event
|
||||||
|
> handler; the container is engine state your handler typically wraps around.
|
||||||
|
|
||||||
|
## Minimal application
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::window::event::{Key, NamedKey, ElementState, WindowEvent};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct MyApp;
|
||||||
|
|
||||||
|
impl WindowApp for MyApp {
|
||||||
|
fn init(&mut self, ctx: &mut AppCtx<'_>) {
|
||||||
|
ctx.set_clear_color(Color::rgb(0.39, 0.58, 0.93));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
|
||||||
|
if let WindowEvent::KeyboardInput { event: key, .. } = event {
|
||||||
|
if key.state == ElementState::Pressed
|
||||||
|
&& key.logical_key == Key::Named(NamedKey::Escape)
|
||||||
|
{
|
||||||
|
ctx.request_exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &mut AppCtx<'_>) {
|
||||||
|
let _seconds_since_last_frame = ctx.dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
run(WindowConfig::default(), MyApp)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`run()` blocks the calling thread until the app exits — an OS requirement (the
|
||||||
|
event loop must own the main thread), not an engine choice.
|
||||||
|
|
||||||
|
## `WindowConfig`
|
||||||
|
|
||||||
|
Initial window settings. All fields are plain data:
|
||||||
|
|
||||||
|
| Field | Default | Meaning |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `title` | `"Oxide"` | Window title |
|
||||||
|
| `width`, `height` | 1280 × 720 | Initial inner size, logical pixels |
|
||||||
|
| `resizable` | `true` | Whether the user can resize |
|
||||||
|
| `clear_color` | `Color::BLACK` | Initial per-frame clear color |
|
||||||
|
|
||||||
|
## The `WindowApp` trait
|
||||||
|
|
||||||
|
Three callbacks, all optional (empty default bodies):
|
||||||
|
|
||||||
|
- **`init(ctx)`** — once, after the window and GPU exist, before the first
|
||||||
|
frame. Set the title, clear color, load resources.
|
||||||
|
- **`event(ctx, event)`** — for *every* raw `WindowEvent`, including ones the
|
||||||
|
engine also reacts to (close request, resize), so apps can observe
|
||||||
|
everything. Stage 2 exposes events untranslated; the
|
||||||
|
[Stage-7 input system](input.md) layers per-key edge detection and
|
||||||
|
remappable named actions on top, surfaced through `ctx.input()`.
|
||||||
|
- **`update(ctx)`** — once per frame, before the frame is cleared and
|
||||||
|
presented. `ctx.dt` is the seconds elapsed since the previous frame (`0.0`
|
||||||
|
on the first).
|
||||||
|
|
||||||
|
Per frame the order is: pending `event` calls → `update` → render.
|
||||||
|
|
||||||
|
## `AppCtx`
|
||||||
|
|
||||||
|
Every callback receives `&mut AppCtx`, the engine state an app may touch:
|
||||||
|
|
||||||
|
| Member | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `dt` | Frame delta time in seconds (field) |
|
||||||
|
| `set_clear_color(color)` / `clear_color()` | Per-frame clear color; changes apply on the next frame |
|
||||||
|
| `size()` | Current surface size in physical pixels |
|
||||||
|
| `set_title(title)` | Change the window title |
|
||||||
|
| `request_exit()` | Leave the event loop after the current callback |
|
||||||
|
| `render()` | Direct access to the [`RenderContext`](render-context.md) |
|
||||||
|
| `input()` | The per-frame [`InputState`](input.md) snapshot |
|
||||||
|
|
||||||
|
## Raw event types
|
||||||
|
|
||||||
|
`oxide_engine::window::event` re-exports the `winit` event vocabulary
|
||||||
|
(`WindowEvent`, `KeyEvent`, `MouseButton`, `ElementState`, `KeyCode`,
|
||||||
|
`PhysicalKey`, `Key`, `NamedKey`, `ModifiersState`, …) so applications don't
|
||||||
|
need their own `winit` dependency. The whole crates are also available as
|
||||||
|
`oxide_engine::winit` and `oxide_engine::wgpu` for anything not curated.
|
||||||
|
|
||||||
|
Two keyboard representations matter:
|
||||||
|
|
||||||
|
- `KeyEvent::physical_key` (`PhysicalKey::Code(KeyCode::KeyW)`) — the physical
|
||||||
|
key position, layout-independent. Use for game-style controls.
|
||||||
|
- `KeyEvent::logical_key` (`Key::Named(NamedKey::Escape)` or
|
||||||
|
`Key::Character(…)`) — what the key means under the user's layout. Use for
|
||||||
|
shortcuts and text.
|
||||||
|
|
||||||
|
## Engine-handled events
|
||||||
|
|
||||||
|
The runner reacts to these before forwarding them:
|
||||||
|
|
||||||
|
| Event | Engine behavior |
|
||||||
|
|-------|-----------------|
|
||||||
|
| `CloseRequested` | Exits the loop (apps can't veto it in Stage 2) |
|
||||||
|
| `Resized` | Reconfigures the surface (see [render-context.md](render-context.md)) |
|
||||||
|
| `RedrawRequested` | Computes `dt`, calls `update`, renders the frame |
|
||||||
|
|
||||||
|
Errors during window/GPU creation or rendering are returned from `run()`;
|
||||||
|
winit callbacks can't propagate `Result`, so the runner stashes the first
|
||||||
|
error and exits the loop.
|
||||||
|
|
||||||
|
## Trying it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-examples --bin hello_window
|
||||||
|
```
|
||||||
|
|
||||||
|
Keys `1`–`5` switch clear-color presets, `Space` cycles, `Esc` quits; average
|
||||||
|
FPS is logged once per second. The editor (`cargo run -p oxide-editor`) uses
|
||||||
|
the same infrastructure and quits with `Ctrl+Q`.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
[package]
|
||||||
|
name = "oxide-editor"
|
||||||
|
description = "Oxide Engine — in-engine editor"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "oxide-editor"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
oxide-engine = { path = "../engine" }
|
||||||
|
oxide-physics = { path = "../physics" }
|
||||||
|
oxide-script = { path = "../script" }
|
||||||
|
log.workspace = true
|
||||||
|
env_logger.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
egui.workspace = true
|
||||||
|
egui-wgpu.workspace = true
|
||||||
|
egui-winit.workspace = true
|
||||||
|
egui_dock.workspace = true
|
||||||
|
# Native folder picker for New/Open Project, run on a helper thread so the
|
||||||
|
# UI keeps redrawing while the dialog is up (see Shell::poll_folder_pick).
|
||||||
|
rfd.workspace = true
|
||||||
|
# Editor preferences file I/O reads/writes the same RON shape `Settings`
|
||||||
|
# exports; the engine already pulls `ron` in, the editor now does too.
|
||||||
|
ron.workspace = true
|
||||||
|
# Editor-owned settings sections (e.g. the External Editor preference) derive
|
||||||
|
# their own Serialize/Deserialize for the Settings store.
|
||||||
|
serde.workspace = true
|
||||||
|
|
||||||
|
# PTY-backed terminal panel (Stage 10): run interactive/TUI programs — shells,
|
||||||
|
# REPLs, and AI-agent CLIs like `claude` — inside the editor. `portable-pty`
|
||||||
|
# opens a real pseudo-terminal (cross-platform: Linux now, Windows later);
|
||||||
|
# `vt100` parses the program's byte stream into a screen grid the panel renders.
|
||||||
|
portable-pty = "0.9"
|
||||||
|
vt100 = "0.16"
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
//! Bundled editor assets — locating the shared `assets/` tree, seeding a new
|
||||||
|
//! project's default content (currently the default UI font), and creating new
|
||||||
|
//! script files from the built-in template (the inspector's "New Script"
|
||||||
|
//! button).
|
||||||
|
//!
|
||||||
|
//! The editor ships a small set of shared assets (icons, the default UI font, …)
|
||||||
|
//! installed by `install.sh` to `$PREFIX/share/oxide/assets`. At runtime we have
|
||||||
|
//! to find that tree whether the editor is *installed* or run from a *dev*
|
||||||
|
//! checkout, so [`bundled_assets_dir`] resolves it in priority order:
|
||||||
|
//!
|
||||||
|
//! 1. the `OXIDE_ASSETS_DIR` environment variable, if set (explicit override);
|
||||||
|
//! 2. `<exe>/../share/oxide/assets` — the install layout (`bin/` next to
|
||||||
|
//! `share/`);
|
||||||
|
//! 3. `<crate>/../assets` — the repo's top-level `assets/` for `cargo run`.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// The default UI font's path, relative to the bundled `assets/` directory and
|
||||||
|
/// to a project's `assets/` directory (they share the typed-folder layout).
|
||||||
|
///
|
||||||
|
/// Inter (SIL Open Font License) — the variable font's default instance is the
|
||||||
|
/// Regular weight. The license travels next to it as `fonts/OFL.txt`.
|
||||||
|
pub const DEFAULT_UI_FONT_REL: &str = "fonts/InterVariable.ttf";
|
||||||
|
|
||||||
|
/// The default UI font's license file, copied alongside the font so a project
|
||||||
|
/// (and any game exported from it) carries the attribution the OFL requires.
|
||||||
|
pub const DEFAULT_UI_FONT_LICENSE_REL: &str = "fonts/OFL.txt";
|
||||||
|
|
||||||
|
/// Locates the editor's bundled `assets/` directory, or `None` if no candidate
|
||||||
|
/// exists (e.g. a stripped install missing its share tree).
|
||||||
|
pub fn bundled_assets_dir() -> Option<PathBuf> {
|
||||||
|
// 1. Explicit override.
|
||||||
|
if let Some(dir) = std::env::var_os("OXIDE_ASSETS_DIR") {
|
||||||
|
let dir = PathBuf::from(dir);
|
||||||
|
if dir.is_dir() {
|
||||||
|
return Some(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Installed layout: <prefix>/bin/oxide-editor + <prefix>/share/oxide/assets.
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(bin_dir) = exe.parent() {
|
||||||
|
if let Some(prefix) = bin_dir.parent() {
|
||||||
|
let installed = prefix.join("share/oxide/assets");
|
||||||
|
if installed.is_dir() {
|
||||||
|
return Some(installed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. Dev checkout: the repo's top-level `assets/` sits one level above this
|
||||||
|
// crate (`editor/`).
|
||||||
|
let dev = Path::new(env!("CARGO_MANIFEST_DIR")).join("../assets");
|
||||||
|
dev.is_dir().then_some(dev)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The absolute path of the bundled default UI font, if the assets tree was
|
||||||
|
/// found and the font is present.
|
||||||
|
pub fn default_ui_font_source() -> Option<PathBuf> {
|
||||||
|
let path = bundled_assets_dir()?.join(DEFAULT_UI_FONT_REL);
|
||||||
|
path.is_file().then_some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies the bundled default UI font (and its license) into `project_assets_dir`
|
||||||
|
/// under the same relative path, unless a file is already there. Returns whether
|
||||||
|
/// the font was newly copied. A missing bundle is a no-op (returns `false`).
|
||||||
|
///
|
||||||
|
/// Called when a project is created so the asset browser has a usable font to
|
||||||
|
/// pick from immediately, referenced by the project-relative path the
|
||||||
|
/// [`AssetDatabase`](oxide_engine::asset::AssetDatabase) records.
|
||||||
|
pub fn seed_default_font(project_assets_dir: &Path) -> std::io::Result<bool> {
|
||||||
|
let Some(src) = default_ui_font_source() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let dst = project_assets_dir.join(DEFAULT_UI_FONT_REL);
|
||||||
|
if dst.exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
if let Some(parent) = dst.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::copy(&src, &dst)?;
|
||||||
|
// Best-effort: carry the license next to the font (don't fail the seed if
|
||||||
|
// only the license is missing from the bundle).
|
||||||
|
if let Some(bundle) = bundled_assets_dir() {
|
||||||
|
let lic_src = bundle.join(DEFAULT_UI_FONT_LICENSE_REL);
|
||||||
|
if lic_src.is_file() {
|
||||||
|
let _ = std::fs::copy(
|
||||||
|
lic_src,
|
||||||
|
project_assets_dir.join(DEFAULT_UI_FONT_LICENSE_REL),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `.rhai` source the "New Script" button writes, personalised with the
|
||||||
|
/// script's file stem so the Console output identifies which script speaks.
|
||||||
|
///
|
||||||
|
/// Kept to the two lifecycle hooks `docs/scripting.md` teaches first; the
|
||||||
|
/// `update` body ships commented out so a freshly created script visibly runs
|
||||||
|
/// (the `init` print) without moving anything until the author opts in.
|
||||||
|
pub fn script_template(stem: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"// {stem}.rhai — attached via a Script component.
|
||||||
|
//
|
||||||
|
// Top-level statements run once when the script (re)starts. `init()` runs
|
||||||
|
// once after them; `update(dt)` runs every frame (dt = seconds).
|
||||||
|
|
||||||
|
fn init() {{
|
||||||
|
print("{stem}: init");
|
||||||
|
}}
|
||||||
|
|
||||||
|
fn update(dt) {{
|
||||||
|
// e.g. rotate_y(dt * 1.5);
|
||||||
|
}}
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reduces a typed script name to a safe file stem: keeps ASCII alphanumerics,
|
||||||
|
/// `-` and `_`, folds anything else (spaces, punctuation, Unicode) to `_`,
|
||||||
|
/// collapses runs, trims the ends, and drops a trailing `.rhai` the user may
|
||||||
|
/// have typed. An unusable input yields `"new_script"`.
|
||||||
|
pub fn sanitize_script_stem(name: &str) -> String {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
let trimmed = trimmed.strip_suffix(".rhai").unwrap_or(trimmed);
|
||||||
|
let mut stem = String::with_capacity(trimmed.len());
|
||||||
|
for c in trimmed.chars() {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||||
|
stem.push(c);
|
||||||
|
} else if !stem.ends_with('_') {
|
||||||
|
stem.push('_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stem = stem.trim_matches('_');
|
||||||
|
if stem.is_empty() {
|
||||||
|
"new_script".to_owned()
|
||||||
|
} else {
|
||||||
|
stem.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new script file under `<assets_dir>/scripts/` from the template,
|
||||||
|
/// returning its assets-relative path (e.g. `"scripts/my_script.rhai"`) for
|
||||||
|
/// registration in the [`AssetDatabase`](oxide_engine::asset::AssetDatabase).
|
||||||
|
///
|
||||||
|
/// The desired name is [sanitized](sanitize_script_stem); a taken name gets a
|
||||||
|
/// numeric suffix (`stem_2`, `stem_3`, …) instead of failing or overwriting,
|
||||||
|
/// so the button always succeeds on a writable project.
|
||||||
|
pub fn create_script_file(assets_dir: &Path, desired_name: &str) -> std::io::Result<String> {
|
||||||
|
use oxide_engine::asset::AssetKind;
|
||||||
|
|
||||||
|
let stem = sanitize_script_stem(desired_name);
|
||||||
|
let dir = assets_dir.join(AssetKind::Script.folder());
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
let mut candidate = stem.clone();
|
||||||
|
let mut n = 1;
|
||||||
|
while dir.join(format!("{candidate}.rhai")).exists() {
|
||||||
|
n += 1;
|
||||||
|
candidate = format!("{stem}_{n}");
|
||||||
|
}
|
||||||
|
std::fs::write(
|
||||||
|
dir.join(format!("{candidate}.rhai")),
|
||||||
|
script_template(&candidate),
|
||||||
|
)?;
|
||||||
|
Ok(format!("{}/{candidate}.rhai", AssetKind::Script.folder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundle_resolves_in_dev_checkout() {
|
||||||
|
// Running tests from the workspace, the dev-checkout fallback (3) finds
|
||||||
|
// the repo's top-level assets/ with the bundled font.
|
||||||
|
let dir = bundled_assets_dir().expect("bundled assets dir should resolve in dev");
|
||||||
|
assert!(
|
||||||
|
dir.join(DEFAULT_UI_FONT_REL).is_file(),
|
||||||
|
"default font present"
|
||||||
|
);
|
||||||
|
assert!(default_ui_font_source().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seed_copies_font_once() {
|
||||||
|
let mut tmp = std::env::temp_dir();
|
||||||
|
tmp.push(format!("oxide_seedfont_{}", std::process::id()));
|
||||||
|
let assets = tmp.join("assets");
|
||||||
|
std::fs::create_dir_all(&assets).unwrap();
|
||||||
|
|
||||||
|
assert!(seed_default_font(&assets).unwrap(), "first seed copies");
|
||||||
|
assert!(assets.join(DEFAULT_UI_FONT_REL).is_file());
|
||||||
|
// Idempotent: a second seed finds the file already present.
|
||||||
|
assert!(
|
||||||
|
!seed_default_font(&assets).unwrap(),
|
||||||
|
"second seed is a no-op"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(tmp).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_covers_typed_names() {
|
||||||
|
assert_eq!(sanitize_script_stem("spin"), "spin");
|
||||||
|
assert_eq!(sanitize_script_stem(" My Cool Script! "), "My_Cool_Script");
|
||||||
|
assert_eq!(sanitize_script_stem("door.rhai"), "door");
|
||||||
|
assert_eq!(sanitize_script_stem("a//b\\c"), "a_b_c");
|
||||||
|
assert_eq!(sanitize_script_stem("čárka"), "rka");
|
||||||
|
// Unusable inputs fall back rather than producing "" or "_".
|
||||||
|
assert_eq!(sanitize_script_stem(""), "new_script");
|
||||||
|
assert_eq!(sanitize_script_stem("!!!"), "new_script");
|
||||||
|
assert_eq!(sanitize_script_stem(".rhai"), "new_script");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_script_writes_template_and_dodges_collisions() {
|
||||||
|
let mut tmp = std::env::temp_dir();
|
||||||
|
tmp.push(format!("oxide_newscript_{}", std::process::id()));
|
||||||
|
let assets = tmp.join("assets");
|
||||||
|
std::fs::create_dir_all(&assets).unwrap();
|
||||||
|
|
||||||
|
let rel = create_script_file(&assets, "door opener").unwrap();
|
||||||
|
assert_eq!(rel, "scripts/door_opener.rhai");
|
||||||
|
let text = std::fs::read_to_string(assets.join(&rel)).unwrap();
|
||||||
|
assert!(text.contains("fn update(dt)"));
|
||||||
|
|
||||||
|
// Same name again: suffixed, nothing overwritten.
|
||||||
|
let rel2 = create_script_file(&assets, "door opener").unwrap();
|
||||||
|
assert_eq!(rel2, "scripts/door_opener_2.rhai");
|
||||||
|
let rel3 = create_script_file(&assets, "door opener").unwrap();
|
||||||
|
assert_eq!(rel3, "scripts/door_opener_3.rhai");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(tmp).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_compiles_in_the_script_engine() {
|
||||||
|
// The template must never ship a syntax error: compile it exactly as
|
||||||
|
// the runtime would.
|
||||||
|
let asset = oxide_script::ScriptAsset::from_source(
|
||||||
|
"new_script.rhai",
|
||||||
|
script_template("new_script"),
|
||||||
|
);
|
||||||
|
let engine = oxide_script::ScriptEngine::new();
|
||||||
|
engine.compile(&asset).expect("template compiles");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! Default editor input bindings + the action-name constants the bindings
|
||||||
|
//! UI and any debug overlay address.
|
||||||
|
//!
|
||||||
|
//! Living in the editor library (not the binary) so the Stage-7
|
||||||
|
//! [`InputBindings`](crate::shell::Shell) preferences page and any future
|
||||||
|
//! editor module can re-register or remap the same actions without
|
||||||
|
//! depending on the binary's private module.
|
||||||
|
|
||||||
|
use oxide_engine::input::{ActionMap, AxisBinding, Binding};
|
||||||
|
use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
|
||||||
|
/// The settings-section name under which the editor's
|
||||||
|
/// [`ActionOverrides`](oxide_engine::input::ActionOverrides) are persisted.
|
||||||
|
///
|
||||||
|
/// The shell registers this section automatically in
|
||||||
|
/// [`EditorState::new`](crate::state::EditorState::new); UI code that wants
|
||||||
|
/// to refresh the section after a binding change addresses it by this name.
|
||||||
|
pub const SETTINGS_SECTION: &str = "input.bindings";
|
||||||
|
|
||||||
|
/// Stable action names addressed throughout the editor — the bindings
|
||||||
|
/// preferences page, the camera input poll in the runner, and any future
|
||||||
|
/// debug overlay all reference these strings.
|
||||||
|
pub mod action {
|
||||||
|
/// Button: toggle between orbit and flythrough viewport cameras.
|
||||||
|
pub const TOGGLE_FLYTHROUGH: &str = "editor.camera.toggle_flythrough";
|
||||||
|
/// Button (held): accelerate flythrough translation while engaged.
|
||||||
|
pub const SPRINT: &str = "editor.camera.sprint";
|
||||||
|
/// 1D axis: strafe right (+) / strafe left (−) in flythrough mode.
|
||||||
|
pub const MOVE_RIGHT: &str = "editor.camera.move_right";
|
||||||
|
/// 1D axis: forward (+) / back (−) in flythrough mode.
|
||||||
|
pub const MOVE_FORWARD: &str = "editor.camera.move_forward";
|
||||||
|
/// 1D axis: ascend (+) / descend (−) in flythrough mode.
|
||||||
|
pub const MOVE_UP: &str = "editor.camera.move_up";
|
||||||
|
|
||||||
|
/// Button: switch the transform gizmo to Translate mode (orbit camera only).
|
||||||
|
pub const GIZMO_TRANSLATE: &str = "editor.gizmo.translate";
|
||||||
|
/// Button: switch the transform gizmo to Rotate mode (orbit camera only).
|
||||||
|
pub const GIZMO_ROTATE: &str = "editor.gizmo.rotate";
|
||||||
|
/// Button: switch the transform gizmo to Scale mode (orbit camera only).
|
||||||
|
pub const GIZMO_SCALE: &str = "editor.gizmo.scale";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers the editor's default action set on `actions`. Defaults follow
|
||||||
|
/// the DCC-tools convention (WASD + QE, Shift sprint, F toggles the
|
||||||
|
/// flythrough camera) so users coming from Blender / Maya / Unity feel at
|
||||||
|
/// home.
|
||||||
|
///
|
||||||
|
/// Idempotent on the action names — re-registering preserves any user-
|
||||||
|
/// remapped current bindings while refreshing the defaults that the
|
||||||
|
/// "Restore defaults" button reverts to.
|
||||||
|
pub fn register_defaults(actions: &mut ActionMap) {
|
||||||
|
actions
|
||||||
|
.register(action::TOGGLE_FLYTHROUGH, [Binding::Key(KeyCode::KeyF)])
|
||||||
|
.register(
|
||||||
|
action::SPRINT,
|
||||||
|
[
|
||||||
|
Binding::Key(KeyCode::ShiftLeft),
|
||||||
|
Binding::Key(KeyCode::ShiftRight),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.register_axis(
|
||||||
|
action::MOVE_RIGHT,
|
||||||
|
AxisBinding::new([Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)]),
|
||||||
|
)
|
||||||
|
.register_axis(
|
||||||
|
action::MOVE_FORWARD,
|
||||||
|
AxisBinding::new([Binding::Key(KeyCode::KeyW)], [Binding::Key(KeyCode::KeyS)]),
|
||||||
|
)
|
||||||
|
.register_axis(
|
||||||
|
action::MOVE_UP,
|
||||||
|
AxisBinding::new([Binding::Key(KeyCode::KeyE)], [Binding::Key(KeyCode::KeyQ)]),
|
||||||
|
)
|
||||||
|
// Gizmo tool hotkeys (W/E/R). These share physical keys with
|
||||||
|
// flythrough movement, so the host gates them on the camera being
|
||||||
|
// in orbit mode — in flythrough W/E move the camera, in orbit
|
||||||
|
// they switch the gizmo tool.
|
||||||
|
.register(action::GIZMO_TRANSLATE, [Binding::Key(KeyCode::KeyW)])
|
||||||
|
.register(action::GIZMO_ROTATE, [Binding::Key(KeyCode::KeyE)])
|
||||||
|
.register(action::GIZMO_SCALE, [Binding::Key(KeyCode::KeyR)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_register_every_advertised_action() {
|
||||||
|
let mut actions = ActionMap::new();
|
||||||
|
register_defaults(&mut actions);
|
||||||
|
|
||||||
|
assert!(actions.has(action::TOGGLE_FLYTHROUGH));
|
||||||
|
assert!(actions.has(action::SPRINT));
|
||||||
|
assert!(actions.has_axis(action::MOVE_RIGHT));
|
||||||
|
assert!(actions.has_axis(action::MOVE_FORWARD));
|
||||||
|
assert!(actions.has_axis(action::MOVE_UP));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_are_idempotent_and_preserve_remaps() {
|
||||||
|
let mut actions = ActionMap::new();
|
||||||
|
register_defaults(&mut actions);
|
||||||
|
|
||||||
|
// User remaps Toggle to Tab.
|
||||||
|
actions.set_bindings(action::TOGGLE_FLYTHROUGH, vec![Binding::Key(KeyCode::Tab)]);
|
||||||
|
|
||||||
|
// Re-running register_defaults must not stomp the user's remap.
|
||||||
|
register_defaults(&mut actions);
|
||||||
|
assert_eq!(
|
||||||
|
actions.bindings(action::TOGGLE_FLYTHROUGH),
|
||||||
|
&[Binding::Key(KeyCode::Tab)]
|
||||||
|
);
|
||||||
|
// But the defaults — what "Restore defaults" reverts to — are still F.
|
||||||
|
assert_eq!(
|
||||||
|
actions.defaults(action::TOGGLE_FLYTHROUGH),
|
||||||
|
&[Binding::Key(KeyCode::KeyF)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
//! The editor's central undo/redo command stack.
|
||||||
|
//!
|
||||||
|
//! Every editor mutation that should be undoable — a transform edit, a rename, a
|
||||||
|
//! spawn/despawn, and later sculpt/paint/scatter brush strokes — is expressed as
|
||||||
|
//! a [`Command`] and pushed onto a [`CommandStack`]. Routing *all* edits through
|
||||||
|
//! one stack is what makes undo/redo consistent across the whole editor, and it
|
||||||
|
//! is why the Stage-7 gizmos and every later tool get undo "for free".
|
||||||
|
//!
|
||||||
|
//! The stack is generic over the context `C` a command mutates (in the editor
|
||||||
|
//! that is the scene + editor state), which keeps it decoupled and unit-testable
|
||||||
|
//! against a trivial context.
|
||||||
|
|
||||||
|
use std::any::Any;
|
||||||
|
|
||||||
|
/// A reversible editor action over a context `C`.
|
||||||
|
///
|
||||||
|
/// A command must be able to [`apply`](Self::apply) its effect and exactly
|
||||||
|
/// [`undo`](Self::undo) it. Commands are stored boxed on the [`CommandStack`].
|
||||||
|
pub trait Command<C>: 'static {
|
||||||
|
/// Performs the action, mutating `ctx`.
|
||||||
|
fn apply(&mut self, ctx: &mut C);
|
||||||
|
|
||||||
|
/// Reverses the action, restoring `ctx` to its pre-[`apply`](Self::apply) state.
|
||||||
|
fn undo(&mut self, ctx: &mut C);
|
||||||
|
|
||||||
|
/// A short human-readable label (shown in the Edit menu / history).
|
||||||
|
fn label(&self) -> String;
|
||||||
|
|
||||||
|
/// Upcast for [`merge`](Self::merge) to downcast a following command.
|
||||||
|
/// Implement as `self`.
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||||
|
|
||||||
|
/// Tries to fold the immediately-following command `next` into this one so
|
||||||
|
/// they share a single undo entry (e.g. every frame of a gizmo drag becomes
|
||||||
|
/// one undoable move). Return `true` if absorbed; the default never merges.
|
||||||
|
///
|
||||||
|
/// When merging, update `self` so that undoing it reverses *both* effects.
|
||||||
|
fn merge(&mut self, next: &mut dyn Command<C>) -> bool {
|
||||||
|
let _ = next;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A composite command: several commands grouped into one undo entry.
|
||||||
|
///
|
||||||
|
/// Applied front-to-back and undone back-to-front, so a multi-step operation
|
||||||
|
/// (e.g. "duplicate and offset") is a single, atomic undo.
|
||||||
|
pub struct Group<C> {
|
||||||
|
label: String,
|
||||||
|
commands: Vec<Box<dyn Command<C>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: 'static> Group<C> {
|
||||||
|
/// A new, empty group with the given label.
|
||||||
|
pub fn new(label: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
label: label.into(),
|
||||||
|
commands: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a command to the group (not yet applied).
|
||||||
|
pub fn push(&mut self, command: impl Command<C> + 'static) {
|
||||||
|
self.commands.push(Box::new(command));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the group has no commands.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.commands.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: 'static> Command<C> for Group<C> {
|
||||||
|
fn apply(&mut self, ctx: &mut C) {
|
||||||
|
for command in &mut self.commands {
|
||||||
|
command.apply(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn undo(&mut self, ctx: &mut C) {
|
||||||
|
for command in self.commands.iter_mut().rev() {
|
||||||
|
command.undo(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(&self) -> String {
|
||||||
|
self.label.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bounded undo/redo stack of [`Command`]s over a context `C`.
|
||||||
|
///
|
||||||
|
/// Pushing a command applies it and clears the redo history. Capacity caps how
|
||||||
|
/// many undo entries are retained (oldest dropped first) so the history cannot
|
||||||
|
/// grow without bound.
|
||||||
|
pub struct CommandStack<C> {
|
||||||
|
undo: Vec<Box<dyn Command<C>>>,
|
||||||
|
redo: Vec<Box<dyn Command<C>>>,
|
||||||
|
capacity: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: 'static> CommandStack<C> {
|
||||||
|
/// The default maximum number of retained undo entries.
|
||||||
|
pub const DEFAULT_CAPACITY: usize = 256;
|
||||||
|
|
||||||
|
/// A stack with the [default capacity](Self::DEFAULT_CAPACITY).
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::with_capacity(Self::DEFAULT_CAPACITY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stack retaining at most `capacity` undo entries (minimum 1).
|
||||||
|
pub fn with_capacity(capacity: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
undo: Vec::new(),
|
||||||
|
redo: Vec::new(),
|
||||||
|
capacity: capacity.max(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies `command` and records it, clearing the redo history.
|
||||||
|
///
|
||||||
|
/// If the previous top entry [`merge`](Command::merge)s this command, the two
|
||||||
|
/// share one undo entry instead of pushing a new one.
|
||||||
|
pub fn push(&mut self, command: impl Command<C> + 'static, ctx: &mut C) {
|
||||||
|
self.push_boxed(Box::new(command), ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies and records an already-boxed command (e.g. a [`Group`]).
|
||||||
|
pub fn push_boxed(&mut self, mut command: Box<dyn Command<C>>, ctx: &mut C) {
|
||||||
|
command.apply(ctx);
|
||||||
|
self.redo.clear();
|
||||||
|
if let Some(top) = self.undo.last_mut() {
|
||||||
|
if top.merge(command.as_mut()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.undo.push(command);
|
||||||
|
while self.undo.len() > self.capacity {
|
||||||
|
self.undo.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undoes the most recent command, moving it to the redo history. Returns its
|
||||||
|
/// label, or `None` if there was nothing to undo.
|
||||||
|
pub fn undo(&mut self, ctx: &mut C) -> Option<String> {
|
||||||
|
let mut command = self.undo.pop()?;
|
||||||
|
command.undo(ctx);
|
||||||
|
let label = command.label();
|
||||||
|
self.redo.push(command);
|
||||||
|
Some(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redoes the most recently undone command. Returns its label, or `None`.
|
||||||
|
pub fn redo(&mut self, ctx: &mut C) -> Option<String> {
|
||||||
|
let mut command = self.redo.pop()?;
|
||||||
|
command.apply(ctx);
|
||||||
|
let label = command.label();
|
||||||
|
self.undo.push(command);
|
||||||
|
Some(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether there is anything to undo.
|
||||||
|
pub fn can_undo(&self) -> bool {
|
||||||
|
!self.undo.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether there is anything to redo.
|
||||||
|
pub fn can_redo(&self) -> bool {
|
||||||
|
!self.redo.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The label of the next undo, if any (for the Edit menu).
|
||||||
|
pub fn undo_label(&self) -> Option<String> {
|
||||||
|
self.undo.last().map(|c| c.label())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The label of the next redo, if any.
|
||||||
|
pub fn redo_label(&self) -> Option<String> {
|
||||||
|
self.redo.last().map(|c| c.label())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number of retained undo entries.
|
||||||
|
pub fn undo_depth(&self) -> usize {
|
||||||
|
self.undo.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all history (e.g. on project close).
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.undo.clear();
|
||||||
|
self.redo.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: 'static> Default for CommandStack<C> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A trivial context: a single integer the test commands mutate.
|
||||||
|
type Ctx = i32;
|
||||||
|
|
||||||
|
/// Adds `amount` to the context; undo subtracts it. Consecutive `Add`s merge
|
||||||
|
/// into one undo entry (modeling a continuous drag).
|
||||||
|
struct Add {
|
||||||
|
amount: i32,
|
||||||
|
mergeable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Add {
|
||||||
|
fn new(amount: i32) -> Self {
|
||||||
|
Self {
|
||||||
|
amount,
|
||||||
|
mergeable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn standalone(amount: i32) -> Self {
|
||||||
|
Self {
|
||||||
|
amount,
|
||||||
|
mergeable: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command<Ctx> for Add {
|
||||||
|
fn apply(&mut self, ctx: &mut Ctx) {
|
||||||
|
*ctx += self.amount;
|
||||||
|
}
|
||||||
|
fn undo(&mut self, ctx: &mut Ctx) {
|
||||||
|
*ctx -= self.amount;
|
||||||
|
}
|
||||||
|
fn label(&self) -> String {
|
||||||
|
format!("Add {}", self.amount)
|
||||||
|
}
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn merge(&mut self, next: &mut dyn Command<Ctx>) -> bool {
|
||||||
|
if !self.mergeable {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(other) = next.as_any_mut().downcast_mut::<Add>() {
|
||||||
|
if other.mergeable {
|
||||||
|
// Fold next's effect into this entry: undoing reverses both.
|
||||||
|
self.amount += other.amount;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_undo_redo_round_trip() {
|
||||||
|
let mut ctx: Ctx = 0;
|
||||||
|
let mut stack = CommandStack::new();
|
||||||
|
stack.push(Add::standalone(5), &mut ctx);
|
||||||
|
stack.push(Add::standalone(3), &mut ctx);
|
||||||
|
assert_eq!(ctx, 8);
|
||||||
|
assert_eq!(stack.undo_depth(), 2);
|
||||||
|
|
||||||
|
assert_eq!(stack.undo(&mut ctx).as_deref(), Some("Add 3"));
|
||||||
|
assert_eq!(ctx, 5);
|
||||||
|
assert_eq!(stack.undo(&mut ctx).as_deref(), Some("Add 5"));
|
||||||
|
assert_eq!(ctx, 0);
|
||||||
|
assert!(!stack.can_undo());
|
||||||
|
|
||||||
|
assert_eq!(stack.redo(&mut ctx).as_deref(), Some("Add 5"));
|
||||||
|
assert_eq!(ctx, 5);
|
||||||
|
assert!(stack.can_redo());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pushing_clears_redo() {
|
||||||
|
let mut ctx: Ctx = 0;
|
||||||
|
let mut stack = CommandStack::new();
|
||||||
|
stack.push(Add::standalone(1), &mut ctx);
|
||||||
|
stack.undo(&mut ctx);
|
||||||
|
assert!(stack.can_redo());
|
||||||
|
stack.push(Add::standalone(10), &mut ctx); // new edit invalidates redo
|
||||||
|
assert!(!stack.can_redo());
|
||||||
|
assert_eq!(ctx, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consecutive_mergeable_commands_share_one_entry() {
|
||||||
|
let mut ctx: Ctx = 0;
|
||||||
|
let mut stack = CommandStack::new();
|
||||||
|
// Simulate a drag: many small mergeable adds.
|
||||||
|
for _ in 0..5 {
|
||||||
|
stack.push(Add::new(2), &mut ctx);
|
||||||
|
}
|
||||||
|
assert_eq!(ctx, 10);
|
||||||
|
assert_eq!(stack.undo_depth(), 1, "drag should be one undo entry");
|
||||||
|
// A single undo reverses the whole drag.
|
||||||
|
stack.undo(&mut ctx);
|
||||||
|
assert_eq!(ctx, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_is_atomic() {
|
||||||
|
let mut ctx: Ctx = 0;
|
||||||
|
let mut stack = CommandStack::new();
|
||||||
|
let mut group = Group::new("Duplicate+Offset");
|
||||||
|
group.push(Add::standalone(4));
|
||||||
|
group.push(Add::standalone(6));
|
||||||
|
stack.push_boxed(Box::new(group), &mut ctx);
|
||||||
|
assert_eq!(ctx, 10);
|
||||||
|
assert_eq!(stack.undo_depth(), 1);
|
||||||
|
assert_eq!(stack.undo(&mut ctx).as_deref(), Some("Duplicate+Offset"));
|
||||||
|
assert_eq!(ctx, 0, "group undoes as one atomic step");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capacity_drops_oldest_entries() {
|
||||||
|
let mut ctx: Ctx = 0;
|
||||||
|
let mut stack = CommandStack::with_capacity(3);
|
||||||
|
for i in 1..=5 {
|
||||||
|
stack.push(Add::standalone(i), &mut ctx);
|
||||||
|
}
|
||||||
|
// Only the last 3 entries are retained for undo.
|
||||||
|
assert_eq!(stack.undo_depth(), 3);
|
||||||
|
// Undoing all retained entries removes 3+4+5 = 12 from the final 15.
|
||||||
|
while stack.undo(&mut ctx).is_some() {}
|
||||||
|
assert_eq!(ctx, 1 + 2); // the dropped 1 and 2 can't be undone
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
//! Concrete editor commands that mutate the [`EditorState`](crate::state::EditorState).
|
||||||
|
//!
|
||||||
|
//! Routed through the [`CommandStack`](crate::command::CommandStack) so every
|
||||||
|
//! one is undoable through the Edit menu, `Ctrl+Z`/`Ctrl+Y`, and the same path
|
||||||
|
//! that future tools (transform gizmos, sculpt, paint) will use.
|
||||||
|
//!
|
||||||
|
//! Piece 6 ships the **first** commands so the undo plumbing is exercised
|
||||||
|
//! end-to-end:
|
||||||
|
//!
|
||||||
|
//! - [`SetTransformCmd`] — change an entity's local [`Transform`]. Consecutive
|
||||||
|
//! edits to the same entity coalesce via [`Command::merge`] so a slider drag
|
||||||
|
//! or a (future) gizmo drag becomes one undo entry.
|
||||||
|
//! - [`RenameCmd`] — rename an entity.
|
||||||
|
//!
|
||||||
|
//! Spawn/despawn aren't wired yet: round-tripping a despawn would need stable
|
||||||
|
//! entity ids across re-spawn (the scene reuses ids), which is a Stage-7
|
||||||
|
//! design step. The hierarchy panel still offers Add/Delete; they bypass the
|
||||||
|
//! stack today and are clearly labeled as "not undoable" in the shell.
|
||||||
|
|
||||||
|
use std::any::Any;
|
||||||
|
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
use crate::command::Command;
|
||||||
|
use crate::state::EditorState;
|
||||||
|
|
||||||
|
/// Replaces the open UI document's panel wholesale (widget tree + sizes).
|
||||||
|
///
|
||||||
|
/// The UI canvas snapshots the panel before an edit and again after, so any
|
||||||
|
/// structural change (add / remove / move a widget) or property change goes
|
||||||
|
/// through one undoable command without per-operation bookkeeping. A panel is a
|
||||||
|
/// small data tree, so cloning it for the snapshots is cheap.
|
||||||
|
pub struct SetUiPanelCmd {
|
||||||
|
/// The panel before the edit.
|
||||||
|
pub before: UiPanel,
|
||||||
|
/// The panel after the edit.
|
||||||
|
pub after: UiPanel,
|
||||||
|
/// Human-readable description for the Edit menu.
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command<EditorState> for SetUiPanelCmd {
|
||||||
|
fn apply(&mut self, state: &mut EditorState) {
|
||||||
|
if let Some(doc) = &mut state.ui_doc {
|
||||||
|
doc.panel = self.after.clone();
|
||||||
|
doc.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn undo(&mut self, state: &mut EditorState) {
|
||||||
|
if let Some(doc) = &mut state.ui_doc {
|
||||||
|
doc.panel = self.before.clone();
|
||||||
|
doc.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(&self) -> String {
|
||||||
|
self.label.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces an entity's local [`Transform`]. Coalesces consecutive edits to
|
||||||
|
/// the same entity so an interactive drag is one undo entry.
|
||||||
|
pub struct SetTransformCmd {
|
||||||
|
pub entity: Entity,
|
||||||
|
/// The transform before the first apply — preserved through merges so
|
||||||
|
/// undo reverses the whole drag at once.
|
||||||
|
pub before: Transform,
|
||||||
|
/// The transform after the most recent apply.
|
||||||
|
pub after: Transform,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SetTransformCmd {
|
||||||
|
/// Builds the command, snapshotting the entity's current transform as the
|
||||||
|
/// pre-edit state. Returns `None` if the entity has no transform (e.g. it
|
||||||
|
/// was just despawned).
|
||||||
|
pub fn new(state: &EditorState, entity: Entity, after: Transform) -> Option<Self> {
|
||||||
|
let before = state.scene.local_transform(entity)?;
|
||||||
|
Some(Self {
|
||||||
|
entity,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command<EditorState> for SetTransformCmd {
|
||||||
|
fn apply(&mut self, state: &mut EditorState) {
|
||||||
|
state.scene.set_local_transform(self.entity, self.after);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn undo(&mut self, state: &mut EditorState) {
|
||||||
|
state.scene.set_local_transform(self.entity, self.before);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(&self) -> String {
|
||||||
|
"Edit Transform".to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge(&mut self, next: &mut dyn Command<EditorState>) -> bool {
|
||||||
|
let Some(next) = next.as_any_mut().downcast_mut::<SetTransformCmd>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if next.entity != self.entity {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Absorb `next` by extending our `after` while preserving `before`,
|
||||||
|
// so a long drag remains a single undo step.
|
||||||
|
self.after = next.after;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a single **reflected field** of a component on an entity, addressed by
|
||||||
|
/// type name + field name and carried as RON.
|
||||||
|
///
|
||||||
|
/// This is the generic counterpart to [`SetTransformCmd`]: the
|
||||||
|
/// reflection-driven inspector emits one of these for *any* registered
|
||||||
|
/// component's field, so a new component type becomes undoably editable with no
|
||||||
|
/// new command type. Consecutive edits to the same `(entity, type, field)`
|
||||||
|
/// coalesce via [`Command::merge`], so dragging a value slider is one undo
|
||||||
|
/// entry.
|
||||||
|
pub struct SetFieldCmd {
|
||||||
|
pub entity: Entity,
|
||||||
|
/// The registered type name (e.g. `"Transform"`).
|
||||||
|
pub type_name: &'static str,
|
||||||
|
/// The reflected field name (e.g. `"translation"`).
|
||||||
|
pub field: &'static str,
|
||||||
|
/// The field's RON before the first apply — preserved through merges.
|
||||||
|
pub before: String,
|
||||||
|
/// The field's RON after the most recent apply.
|
||||||
|
pub after: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SetFieldCmd {
|
||||||
|
/// Builds the command, snapshotting the field's current RON as the
|
||||||
|
/// pre-edit state. Returns `None` if the field can't be read (unknown
|
||||||
|
/// type/field, or the entity lacks the component).
|
||||||
|
pub fn new(
|
||||||
|
state: &EditorState,
|
||||||
|
entity: Entity,
|
||||||
|
type_name: &'static str,
|
||||||
|
field: &'static str,
|
||||||
|
after: String,
|
||||||
|
) -> Option<Self> {
|
||||||
|
let before = state
|
||||||
|
.registry
|
||||||
|
.get_field(state.scene.world(), entity, type_name, field)
|
||||||
|
.ok()?;
|
||||||
|
Some(Self {
|
||||||
|
entity,
|
||||||
|
type_name,
|
||||||
|
field,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command<EditorState> for SetFieldCmd {
|
||||||
|
fn apply(&mut self, state: &mut EditorState) {
|
||||||
|
// Disjoint borrows of EditorState: ®istry (receiver) + &mut scene
|
||||||
|
// (the world). A write only fails if the entity/component vanished
|
||||||
|
// between snapshot and apply, in which case there's nothing to do.
|
||||||
|
let _ = state.registry.set_field(
|
||||||
|
state.scene.world_mut(),
|
||||||
|
self.entity,
|
||||||
|
self.type_name,
|
||||||
|
self.field,
|
||||||
|
&self.after,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn undo(&mut self, state: &mut EditorState) {
|
||||||
|
let _ = state.registry.set_field(
|
||||||
|
state.scene.world_mut(),
|
||||||
|
self.entity,
|
||||||
|
self.type_name,
|
||||||
|
self.field,
|
||||||
|
&self.before,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(&self) -> String {
|
||||||
|
format!("Edit {}.{}", self.type_name, self.field)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge(&mut self, next: &mut dyn Command<EditorState>) -> bool {
|
||||||
|
let Some(next) = next.as_any_mut().downcast_mut::<SetFieldCmd>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// Only coalesce edits to the *same* field of the same component on the
|
||||||
|
// same entity; preserve `before` so undo reverses the whole drag.
|
||||||
|
if next.entity != self.entity
|
||||||
|
|| next.type_name != self.type_name
|
||||||
|
|| next.field != self.field
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.after = std::mem::take(&mut next.after);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renames an entity.
|
||||||
|
pub struct RenameCmd {
|
||||||
|
pub entity: Entity,
|
||||||
|
pub before: String,
|
||||||
|
pub after: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenameCmd {
|
||||||
|
/// Snapshots the entity's current name as the pre-edit state.
|
||||||
|
pub fn new(state: &EditorState, entity: Entity, after: String) -> Self {
|
||||||
|
let before = state.scene.name(entity).unwrap_or_default();
|
||||||
|
Self {
|
||||||
|
entity,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command<EditorState> for RenameCmd {
|
||||||
|
fn apply(&mut self, state: &mut EditorState) {
|
||||||
|
state.scene.set_name(self.entity, self.after.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn undo(&mut self, state: &mut EditorState) {
|
||||||
|
state.scene.set_name(self.entity, self.before.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(&self) -> String {
|
||||||
|
format!("Rename to '{}'", self.after)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::command::CommandStack;
|
||||||
|
|
||||||
|
fn state_with_entity() -> (EditorState, Entity) {
|
||||||
|
let mut state = EditorState::new();
|
||||||
|
let e = state
|
||||||
|
.scene
|
||||||
|
.spawn("alpha", Transform::from_translation(Vec3::ZERO));
|
||||||
|
(state, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transform_undo_redo_round_trips() {
|
||||||
|
let (mut state, e) = state_with_entity();
|
||||||
|
let target = Transform::from_translation(Vec3::new(1.0, 2.0, 3.0));
|
||||||
|
let cmd = SetTransformCmd::new(&state, e, target).expect("transform present");
|
||||||
|
|
||||||
|
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||||
|
stack.push(cmd, &mut state);
|
||||||
|
assert_eq!(
|
||||||
|
state.scene.local_transform(e).unwrap().translation,
|
||||||
|
target.translation
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(stack.undo(&mut state).is_some());
|
||||||
|
assert_eq!(
|
||||||
|
state.scene.local_transform(e).unwrap().translation,
|
||||||
|
Vec3::ZERO
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(stack.redo(&mut state).is_some());
|
||||||
|
assert_eq!(
|
||||||
|
state.scene.local_transform(e).unwrap().translation,
|
||||||
|
target.translation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consecutive_transform_edits_coalesce_into_one_undo() {
|
||||||
|
// Mirrors the "interactive drag" case: dozens of per-frame edits, one
|
||||||
|
// undo step that returns to the pre-drag state.
|
||||||
|
let (mut state, e) = state_with_entity();
|
||||||
|
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||||
|
|
||||||
|
for step in 1..=5 {
|
||||||
|
let target = Transform::from_translation(Vec3::splat(step as f32));
|
||||||
|
let cmd = SetTransformCmd::new(&state, e, target).unwrap();
|
||||||
|
stack.push(cmd, &mut state);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
state.scene.local_transform(e).unwrap().translation,
|
||||||
|
Vec3::splat(5.0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// A single undo wipes the whole drag — that's the merge contract.
|
||||||
|
assert!(stack.undo(&mut state).is_some());
|
||||||
|
assert_eq!(
|
||||||
|
state.scene.local_transform(e).unwrap().translation,
|
||||||
|
Vec3::ZERO
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_field_undo_redo_round_trips() {
|
||||||
|
let (mut state, e) = state_with_entity();
|
||||||
|
let cmd = SetFieldCmd::new(
|
||||||
|
&state,
|
||||||
|
e,
|
||||||
|
"Transform",
|
||||||
|
"translation",
|
||||||
|
"(1.0,2.0,3.0)".into(),
|
||||||
|
)
|
||||||
|
.expect("transform field readable");
|
||||||
|
|
||||||
|
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||||
|
stack.push(cmd, &mut state);
|
||||||
|
assert_eq!(
|
||||||
|
state.scene.local_transform(e).unwrap().translation,
|
||||||
|
Vec3::new(1.0, 2.0, 3.0)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(stack.undo(&mut state).is_some());
|
||||||
|
assert_eq!(
|
||||||
|
state.scene.local_transform(e).unwrap().translation,
|
||||||
|
Vec3::ZERO
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(stack.redo(&mut state).is_some());
|
||||||
|
assert_eq!(
|
||||||
|
state.scene.local_transform(e).unwrap().translation,
|
||||||
|
Vec3::new(1.0, 2.0, 3.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consecutive_field_edits_to_same_field_coalesce() {
|
||||||
|
// A value-slider drag: many per-frame edits, one undo back to start.
|
||||||
|
let (mut state, e) = state_with_entity();
|
||||||
|
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||||
|
for step in 1..=5 {
|
||||||
|
let ron = format!("({0}.0,{0}.0,{0}.0)", step);
|
||||||
|
let cmd = SetFieldCmd::new(&state, e, "Transform", "translation", ron).unwrap();
|
||||||
|
stack.push(cmd, &mut state);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
state.scene.local_transform(e).unwrap().translation,
|
||||||
|
Vec3::splat(5.0)
|
||||||
|
);
|
||||||
|
assert!(stack.undo(&mut state).is_some());
|
||||||
|
assert_eq!(
|
||||||
|
state.scene.local_transform(e).unwrap().translation,
|
||||||
|
Vec3::ZERO
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_field_edits_to_different_fields_do_not_coalesce() {
|
||||||
|
// Editing translation then scale must be two undo steps, not one.
|
||||||
|
let (mut state, e) = state_with_entity();
|
||||||
|
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||||
|
stack.push(
|
||||||
|
SetFieldCmd::new(
|
||||||
|
&state,
|
||||||
|
e,
|
||||||
|
"Transform",
|
||||||
|
"translation",
|
||||||
|
"(1.0,0.0,0.0)".into(),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
&mut state,
|
||||||
|
);
|
||||||
|
stack.push(
|
||||||
|
SetFieldCmd::new(&state, e, "Transform", "scale", "(2.0,2.0,2.0)".into()).unwrap(),
|
||||||
|
&mut state,
|
||||||
|
);
|
||||||
|
// Undo reverses scale only.
|
||||||
|
assert!(stack.undo(&mut state).is_some());
|
||||||
|
let t = state.scene.local_transform(e).unwrap();
|
||||||
|
assert_eq!(t.scale, Vec3::ONE);
|
||||||
|
assert_eq!(t.translation, Vec3::new(1.0, 0.0, 0.0));
|
||||||
|
// A second undo reverses translation.
|
||||||
|
assert!(stack.undo(&mut state).is_some());
|
||||||
|
assert_eq!(
|
||||||
|
state.scene.local_transform(e).unwrap().translation,
|
||||||
|
Vec3::ZERO
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_undo_restores_previous_name() {
|
||||||
|
let (mut state, e) = state_with_entity();
|
||||||
|
let cmd = RenameCmd::new(&state, e, "beta".into());
|
||||||
|
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||||
|
stack.push(cmd, &mut state);
|
||||||
|
assert_eq!(state.scene.name(e).as_deref(), Some("beta"));
|
||||||
|
|
||||||
|
assert!(stack.undo(&mut state).is_some());
|
||||||
|
assert_eq!(state.scene.name(e).as_deref(), Some("alpha"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
//! Editor console: captures `log` records into a ring buffer the Console panel
|
||||||
|
//! renders.
|
||||||
|
//!
|
||||||
|
//! The engine and modules already speak through the `log` crate — in particular
|
||||||
|
//! the scripting layer routes script `print`/`debug` and "script paused: …"
|
||||||
|
//! errors to `target: "oxide_script"` (see `oxide-script`). This module installs
|
||||||
|
//! a logger that mirrors every record into an in-memory ring buffer *and* still
|
||||||
|
//! forwards it to `env_logger` for the terminal, so the editor's Console panel
|
||||||
|
//! can show script output and errors without the engine knowing about the editor.
|
||||||
|
//!
|
||||||
|
//! The buffer is a process global (the `log` facade allows only one logger, set
|
||||||
|
//! once at startup), reached by the panel through [`log_buffer`] — so wiring it
|
||||||
|
//! in touches neither `Shell::new` nor its many test call sites.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
|
use log::{Level, Log, Metadata, Record};
|
||||||
|
|
||||||
|
/// How many recent log lines the console keeps. Older lines are dropped.
|
||||||
|
const CAPACITY: usize = 2000;
|
||||||
|
|
||||||
|
/// One captured log record, flattened to what the panel renders.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LogLine {
|
||||||
|
/// Severity, used to colour the line.
|
||||||
|
pub level: Level,
|
||||||
|
/// The record's target (e.g. `oxide_script`), shown dimmed before the text.
|
||||||
|
pub target: String,
|
||||||
|
/// The formatted message.
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bounded ring buffer of the most recent [`LogLine`]s.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct LogBuffer {
|
||||||
|
lines: VecDeque<LogLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogBuffer {
|
||||||
|
/// Appends a line, evicting the oldest if at capacity.
|
||||||
|
fn push(&mut self, line: LogLine) {
|
||||||
|
if self.lines.len() == CAPACITY {
|
||||||
|
self.lines.pop_front();
|
||||||
|
}
|
||||||
|
self.lines.push_back(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates the buffered lines, oldest first.
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = &LogLine> {
|
||||||
|
self.lines.iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number of buffered lines.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.lines.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the buffer is empty.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.lines.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops all buffered lines (the panel's Clear button).
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.lines.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process-wide capture buffer, set by [`init`].
|
||||||
|
static LOG_BUFFER: OnceLock<Arc<Mutex<LogBuffer>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// The shared capture buffer, if logging has been initialised.
|
||||||
|
pub fn log_buffer() -> Option<&'static Arc<Mutex<LogBuffer>>> {
|
||||||
|
LOG_BUFFER.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends a line to the console from outside the `log` stream — used by the
|
||||||
|
/// command terminal to echo commands and stream a process's output into the
|
||||||
|
/// same panel. No-op if logging is not initialised.
|
||||||
|
pub fn append(level: Level, target: &str, message: impl Into<String>) {
|
||||||
|
if let Some(buffer) = LOG_BUFFER.get() {
|
||||||
|
if let Ok(mut buffer) = buffer.lock() {
|
||||||
|
buffer.push(LogLine {
|
||||||
|
level,
|
||||||
|
target: target.to_string(),
|
||||||
|
message: message.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A logger that mirrors records into [`LOG_BUFFER`] and forwards them to an
|
||||||
|
/// inner `env_logger` for the terminal.
|
||||||
|
struct CaptureLogger {
|
||||||
|
inner: env_logger::Logger,
|
||||||
|
buffer: Arc<Mutex<LogBuffer>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Log for CaptureLogger {
|
||||||
|
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||||
|
self.inner.enabled(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, record: &Record) {
|
||||||
|
// Honour the env filter for both the terminal and the buffer, so
|
||||||
|
// RUST_LOG controls the console too.
|
||||||
|
if !self.inner.enabled(record.metadata()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(mut buffer) = self.buffer.lock() {
|
||||||
|
buffer.push(LogLine {
|
||||||
|
level: record.level(),
|
||||||
|
target: record.target().to_string(),
|
||||||
|
message: record.args().to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.inner.log(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) {
|
||||||
|
self.inner.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs the capturing logger and returns the shared buffer. Mirrors the old
|
||||||
|
/// `env_logger` setup (honours `RUST_LOG`, default `info`) but also feeds the
|
||||||
|
/// editor Console. Call once at startup, before any logging.
|
||||||
|
pub fn init() {
|
||||||
|
let inner =
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).build();
|
||||||
|
let max = inner.filter();
|
||||||
|
let buffer = Arc::new(Mutex::new(LogBuffer::default()));
|
||||||
|
let _ = LOG_BUFFER.set(buffer.clone());
|
||||||
|
|
||||||
|
if log::set_boxed_logger(Box::new(CaptureLogger { inner, buffer })).is_ok() {
|
||||||
|
log::set_max_level(max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ring_buffer_evicts_oldest_past_capacity() {
|
||||||
|
let mut buf = LogBuffer::default();
|
||||||
|
for i in 0..(CAPACITY + 10) {
|
||||||
|
buf.push(LogLine {
|
||||||
|
level: Level::Info,
|
||||||
|
target: "t".into(),
|
||||||
|
message: format!("line {i}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert_eq!(buf.len(), CAPACITY);
|
||||||
|
// The oldest 10 were evicted, so the first surviving line is "line 10".
|
||||||
|
assert_eq!(buf.iter().next().unwrap().message, "line 10");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_empties_the_buffer() {
|
||||||
|
let mut buf = LogBuffer::default();
|
||||||
|
buf.push(LogLine {
|
||||||
|
level: Level::Warn,
|
||||||
|
target: "t".into(),
|
||||||
|
message: "x".into(),
|
||||||
|
});
|
||||||
|
assert!(!buf.is_empty());
|
||||||
|
buf.clear();
|
||||||
|
assert!(buf.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//! egui ⇄ engine glue for the editor.
|
||||||
|
//!
|
||||||
|
//! The engine core stays UI-agnostic; all egui wiring lives here in the editor.
|
||||||
|
//! [`EguiLayer`] owns the [`egui_winit`] input state and the [`egui_wgpu`]
|
||||||
|
//! renderer, translates window events, and paints a built UI into the frame's
|
||||||
|
//! surface view (recorded with `LoadOp::Load`, so it composites on top of the
|
||||||
|
//! engine's clear).
|
||||||
|
|
||||||
|
use egui_wgpu::{Renderer, RendererOptions, ScreenDescriptor};
|
||||||
|
use egui_winit::State;
|
||||||
|
use oxide_engine::wgpu;
|
||||||
|
use oxide_engine::winit::event::WindowEvent;
|
||||||
|
use oxide_engine::winit::window::Window;
|
||||||
|
|
||||||
|
/// Holds the egui input state and GPU renderer for one window.
|
||||||
|
pub struct EguiLayer {
|
||||||
|
state: State,
|
||||||
|
renderer: Renderer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EguiLayer {
|
||||||
|
/// Creates the layer for `window`, building a renderer that targets the
|
||||||
|
/// given surface format.
|
||||||
|
pub fn new(
|
||||||
|
window: &Window,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
surface_format: wgpu::TextureFormat,
|
||||||
|
) -> Self {
|
||||||
|
let context = egui::Context::default();
|
||||||
|
let state = State::new(
|
||||||
|
context,
|
||||||
|
egui::ViewportId::ROOT,
|
||||||
|
window,
|
||||||
|
Some(window.scale_factor() as f32),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
// Defaults: no MSAA, no depth/stencil, dithering on — matches the
|
||||||
|
// editor's flat clear-color surface.
|
||||||
|
let renderer = Renderer::new(device, surface_format, RendererOptions::default());
|
||||||
|
Self { state, renderer }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feeds a window event to egui. Returns `true` if egui consumed it (e.g.
|
||||||
|
/// a click landed on a panel), so the caller can suppress its own handling.
|
||||||
|
pub fn on_window_event(&mut self, window: &Window, event: &WindowEvent) -> bool {
|
||||||
|
self.state.on_window_event(window, event).consumed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the pointer is currently over a **floating** egui area — a
|
||||||
|
/// `Window` (Preferences, Layer Names, Groups, …) or other non-background
|
||||||
|
/// layer — rather than empty space or the background dock.
|
||||||
|
///
|
||||||
|
/// The viewport is painted under a transparent dock area (background
|
||||||
|
/// order), so a geometric "cursor inside the viewport rect" test can't tell
|
||||||
|
/// that a floating panel is sitting on top of it. The host uses this to
|
||||||
|
/// suppress viewport orbit/pan/zoom (and stray WASD while typing in a panel
|
||||||
|
/// that overlaps the viewport).
|
||||||
|
pub fn pointer_over_floating(&self) -> bool {
|
||||||
|
let ctx = self.state.egui_ctx();
|
||||||
|
let Some(pos) = ctx.pointer_latest_pos() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
ctx.layer_id_at(pos)
|
||||||
|
.map(|layer| layer.order > egui::Order::Background)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the UI via `build_ui` and paints it into `view`.
|
||||||
|
///
|
||||||
|
/// `build_ui` receives the root [`egui::Ui`]; panels are shown inside it
|
||||||
|
/// (egui 0.34's `show_inside` model). It may be called more than once per
|
||||||
|
/// frame if egui needs an extra layout pass, so it must be idempotent.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn paint(
|
||||||
|
&mut self,
|
||||||
|
window: &Window,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
view: &wgpu::TextureView,
|
||||||
|
size: (u32, u32),
|
||||||
|
build_ui: impl FnMut(&mut egui::Ui),
|
||||||
|
) {
|
||||||
|
let raw_input = self.state.take_egui_input(window);
|
||||||
|
let context = self.state.egui_ctx().clone();
|
||||||
|
let output = context.run_ui(raw_input, build_ui);
|
||||||
|
self.state
|
||||||
|
.handle_platform_output(window, output.platform_output);
|
||||||
|
|
||||||
|
let primitives = context.tessellate(output.shapes, output.pixels_per_point);
|
||||||
|
let screen = ScreenDescriptor {
|
||||||
|
size_in_pixels: [size.0.max(1), size.1.max(1)],
|
||||||
|
pixels_per_point: output.pixels_per_point,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (id, delta) in &output.textures_delta.set {
|
||||||
|
self.renderer.update_texture(device, queue, *id, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("oxide.editor.egui.encoder"),
|
||||||
|
});
|
||||||
|
// egui may emit its own command buffers (for paint callbacks); submit
|
||||||
|
// those ahead of our pass.
|
||||||
|
let user_buffers =
|
||||||
|
self.renderer
|
||||||
|
.update_buffers(device, queue, &mut encoder, &primitives, &screen);
|
||||||
|
{
|
||||||
|
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("oxide.editor.egui.pass"),
|
||||||
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
|
view,
|
||||||
|
resolve_target: None,
|
||||||
|
depth_slice: None,
|
||||||
|
ops: wgpu::Operations {
|
||||||
|
// Load: keep the engine's clear; draw the UI over it.
|
||||||
|
load: wgpu::LoadOp::Load,
|
||||||
|
store: wgpu::StoreOp::Store,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
depth_stencil_attachment: None,
|
||||||
|
timestamp_writes: None,
|
||||||
|
occlusion_query_set: None,
|
||||||
|
multiview_mask: None,
|
||||||
|
});
|
||||||
|
// egui-wgpu wants a 'static pass; the encoder outlives it here.
|
||||||
|
let mut pass = pass.forget_lifetime();
|
||||||
|
self.renderer.render(&mut pass, &primitives, &screen);
|
||||||
|
}
|
||||||
|
|
||||||
|
for id in &output.textures_delta.free {
|
||||||
|
self.renderer.free_texture(id);
|
||||||
|
}
|
||||||
|
queue.submit(
|
||||||
|
user_buffers
|
||||||
|
.into_iter()
|
||||||
|
.chain(std::iter::once(encoder.finish())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
//! The Project panel's file explorer — state and file/database operations.
|
||||||
|
//!
|
||||||
|
//! Stage-10 editor-UX: a Unity-style explorer over the project's `assets/`
|
||||||
|
//! tree. This module holds everything that does **not** touch egui — the
|
||||||
|
//! navigation state, directory listing, and the create/rename/move/delete/
|
||||||
|
//! import operations — so the whole behavior layer is unit-testable and the
|
||||||
|
//! shell only renders it (`ShellTabViewer::assets_explorer`).
|
||||||
|
//!
|
||||||
|
//! Every operation goes through the [`AssetDatabase`] file ops
|
||||||
|
//! (`move_asset`/`move_folder`/`delete_asset`) whenever the touched file is
|
||||||
|
//! registered, so an asset keeps its [`AssetUid`] — and every saved
|
||||||
|
//! `AssetRef` keeps resolving — across any reorganisation. Files the
|
||||||
|
//! database does not know (licenses, notes, …) fall back to plain
|
||||||
|
//! filesystem operations.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use oxide_engine::asset::{AssetDatabase, AssetDbError, AssetKind, AssetUid};
|
||||||
|
|
||||||
|
/// The explorer's persistent UI state (lives on the `Shell`, survives frames).
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ExplorerState {
|
||||||
|
/// The folder being viewed, relative to `assets/` (`""` = the root).
|
||||||
|
pub cwd: String,
|
||||||
|
/// An in-progress rename, if any.
|
||||||
|
pub rename: Option<RenameEdit>,
|
||||||
|
/// The in-progress "New Folder" name, `Some` while the inline row shows.
|
||||||
|
pub new_folder: Option<String>,
|
||||||
|
/// One-shot: the next inline text field rendered requests focus (set when
|
||||||
|
/// a rename / new-folder edit starts, taken by the first frame).
|
||||||
|
pub focus_field: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExplorerState {
|
||||||
|
/// Navigates to `cwd`, dropping any in-progress inline edits.
|
||||||
|
pub fn navigate(&mut self, cwd: impl Into<String>) {
|
||||||
|
self.cwd = cwd.into();
|
||||||
|
self.rename = None;
|
||||||
|
self.new_folder = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An in-progress rename of one entry: what is being renamed + the buffer.
|
||||||
|
pub struct RenameEdit {
|
||||||
|
/// The entry's current assets-relative path.
|
||||||
|
pub rel: String,
|
||||||
|
/// Whether it is a folder.
|
||||||
|
pub is_dir: bool,
|
||||||
|
/// The name being typed.
|
||||||
|
pub buf: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the explorer listing.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Entry {
|
||||||
|
/// The leaf name shown in the panel.
|
||||||
|
pub name: String,
|
||||||
|
/// Assets-relative path (forward slashes).
|
||||||
|
pub rel: String,
|
||||||
|
/// Whether this is a folder.
|
||||||
|
pub is_dir: bool,
|
||||||
|
/// The database uid, when the file is registered.
|
||||||
|
pub uid: Option<AssetUid>,
|
||||||
|
/// The registered kind, when the file is registered.
|
||||||
|
pub kind: Option<AssetKind>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the folder `cwd` (relative to `assets/`): folders first, then files,
|
||||||
|
/// each group sorted by name. Files are annotated with their database
|
||||||
|
/// uid/kind when registered. A missing folder yields an empty list (the
|
||||||
|
/// assets root may not exist yet in a fresh project).
|
||||||
|
pub fn list_dir(db: &AssetDatabase, cwd: &str) -> Vec<Entry> {
|
||||||
|
let dir = abs_of(db, cwd);
|
||||||
|
let Ok(read) = std::fs::read_dir(&dir) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut folders: Vec<Entry> = Vec::new();
|
||||||
|
let mut files: Vec<Entry> = Vec::new();
|
||||||
|
for item in read.flatten() {
|
||||||
|
let name = item.file_name().to_string_lossy().into_owned();
|
||||||
|
let rel = join_rel(cwd, &name);
|
||||||
|
if item.path().is_dir() {
|
||||||
|
folders.push(Entry {
|
||||||
|
name,
|
||||||
|
rel,
|
||||||
|
is_dir: true,
|
||||||
|
uid: None,
|
||||||
|
kind: None,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let uid = db.uid_of(&rel);
|
||||||
|
let kind = uid.and_then(|u| db.entry(u)).map(|e| e.kind);
|
||||||
|
files.push(Entry {
|
||||||
|
name,
|
||||||
|
rel,
|
||||||
|
is_dir: false,
|
||||||
|
uid,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
folders.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
files.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
folders.extend(files);
|
||||||
|
folders
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The breadcrumb trail for `cwd`: `(label, cwd-to-navigate-to)` pairs,
|
||||||
|
/// starting at the assets root. `"textures/env"` yields
|
||||||
|
/// `[("assets",""), ("textures","textures"), ("env","textures/env")]`.
|
||||||
|
pub fn breadcrumbs(cwd: &str) -> Vec<(String, String)> {
|
||||||
|
let mut crumbs = vec![("assets".to_owned(), String::new())];
|
||||||
|
let mut path = String::new();
|
||||||
|
for seg in cwd.split('/').filter(|s| !s.is_empty()) {
|
||||||
|
path = join_rel(&path, seg);
|
||||||
|
crumbs.push((seg.to_owned(), path.clone()));
|
||||||
|
}
|
||||||
|
crumbs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Joins a folder path and a leaf name into an assets-relative path.
|
||||||
|
pub fn join_rel(dir: &str, name: &str) -> String {
|
||||||
|
if dir.is_empty() {
|
||||||
|
name.to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{dir}/{name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parent folder of an assets-relative path (`""` at the top).
|
||||||
|
pub fn parent_of(rel: &str) -> String {
|
||||||
|
rel.rsplit_once('/')
|
||||||
|
.map(|(p, _)| p.to_owned())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `name` is usable as a single new file/folder name: non-empty and
|
||||||
|
/// free of path separators / traversal.
|
||||||
|
pub fn valid_name(name: &str) -> bool {
|
||||||
|
!name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name that does not exist in `dir` yet, derived from `wanted` by
|
||||||
|
/// suffixing `_2`, `_3`, … before the extension (`wall.png` → `wall_2.png`).
|
||||||
|
pub fn unique_name(dir: &Path, wanted: &str) -> String {
|
||||||
|
if !dir.join(wanted).exists() {
|
||||||
|
return wanted.to_owned();
|
||||||
|
}
|
||||||
|
let (stem, ext) = match wanted.rsplit_once('.') {
|
||||||
|
// A leading dot (".gitignore") is a hidden name, not an extension.
|
||||||
|
Some((s, e)) if !s.is_empty() => (s, Some(e)),
|
||||||
|
_ => (wanted, None),
|
||||||
|
};
|
||||||
|
let mut n = 2;
|
||||||
|
loop {
|
||||||
|
let candidate = match ext {
|
||||||
|
Some(ext) => format!("{stem}_{n}.{ext}"),
|
||||||
|
None => format!("{stem}_{n}"),
|
||||||
|
};
|
||||||
|
if !dir.join(&candidate).exists() {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new folder in `cwd` named `wanted` (unique-ified), returning its
|
||||||
|
/// assets-relative path.
|
||||||
|
pub fn create_folder(db: &AssetDatabase, cwd: &str, wanted: &str) -> std::io::Result<String> {
|
||||||
|
if !valid_name(wanted) {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
format!("invalid folder name: {wanted:?}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let dir = abs_of(db, cwd);
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
let name = unique_name(&dir, wanted);
|
||||||
|
std::fs::create_dir(dir.join(&name))?;
|
||||||
|
Ok(join_rel(cwd, &name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renames the entry at `rel` to `new_name` (same folder), returning the new
|
||||||
|
/// relative path. Registered files keep their uid via
|
||||||
|
/// [`AssetDatabase::move_asset`]; folders move every registered entry under
|
||||||
|
/// them via [`AssetDatabase::move_folder`]; unregistered files fall back to a
|
||||||
|
/// plain `fs::rename` (refusing to overwrite).
|
||||||
|
pub fn rename_entry(
|
||||||
|
db: &mut AssetDatabase,
|
||||||
|
rel: &str,
|
||||||
|
is_dir: bool,
|
||||||
|
new_name: &str,
|
||||||
|
) -> Result<String, AssetDbError> {
|
||||||
|
if !valid_name(new_name) {
|
||||||
|
return Err(AssetDbError::InvalidPath(new_name.to_owned()));
|
||||||
|
}
|
||||||
|
let new_rel = join_rel(&parent_of(rel), new_name);
|
||||||
|
if new_rel == rel {
|
||||||
|
return Ok(new_rel);
|
||||||
|
}
|
||||||
|
move_to(db, rel, is_dir, &new_rel)?;
|
||||||
|
Ok(new_rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the entry at `rel` into the folder `dest_dir`, returning the new
|
||||||
|
/// relative path. Same uid-preserving rules as [`rename_entry`].
|
||||||
|
pub fn move_entry(
|
||||||
|
db: &mut AssetDatabase,
|
||||||
|
rel: &str,
|
||||||
|
is_dir: bool,
|
||||||
|
dest_dir: &str,
|
||||||
|
) -> Result<String, AssetDbError> {
|
||||||
|
let name = rel.rsplit('/').next().unwrap_or(rel);
|
||||||
|
let new_rel = join_rel(dest_dir, name);
|
||||||
|
if new_rel == rel {
|
||||||
|
return Ok(new_rel);
|
||||||
|
}
|
||||||
|
move_to(db, rel, is_dir, &new_rel)?;
|
||||||
|
Ok(new_rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the entry: registered files through the database (entry dropped,
|
||||||
|
/// uid retired), unregistered files from disk, and folders **only when
|
||||||
|
/// empty** — recursive delete of assets is deliberately not offered.
|
||||||
|
pub fn delete_entry(db: &mut AssetDatabase, entry: &Entry) -> Result<(), AssetDbError> {
|
||||||
|
if entry.is_dir {
|
||||||
|
let dir = abs_of(db, &entry.rel);
|
||||||
|
if std::fs::read_dir(&dir)?.next().is_some() {
|
||||||
|
// (`ErrorKind::DirectoryNotEmpty` needs Rust 1.83; the workspace
|
||||||
|
// MSRV is older, so this stays a generic I/O error.)
|
||||||
|
return Err(AssetDbError::Io(std::io::Error::other(format!(
|
||||||
|
"folder not empty: {} (delete its contents first)",
|
||||||
|
entry.rel
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
std::fs::remove_dir(&dir)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match entry.uid {
|
||||||
|
Some(uid) => delete_and_save(db, uid),
|
||||||
|
None => Ok(std::fs::remove_file(abs_of(db, &entry.rel))?),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Imports files dropped from the OS into `cwd`: each is copied in under a
|
||||||
|
/// collision-free name and registered (kind from the folder, else extension).
|
||||||
|
/// Directories and unreadable sources are skipped with a log line. Returns
|
||||||
|
/// how many files were imported.
|
||||||
|
pub fn import_files(db: &mut AssetDatabase, cwd: &str, sources: &[PathBuf]) -> usize {
|
||||||
|
let dir = abs_of(db, cwd);
|
||||||
|
if std::fs::create_dir_all(&dir).is_err() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut imported = 0;
|
||||||
|
for src in sources {
|
||||||
|
if src.is_dir() {
|
||||||
|
log::warn!("skipping folder drop {} (import files)", src.display());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(file_name) = src.file_name().map(|n| n.to_string_lossy().into_owned()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name = unique_name(&dir, &file_name);
|
||||||
|
match std::fs::copy(src, dir.join(&name)) {
|
||||||
|
Ok(_) => {
|
||||||
|
let rel = join_rel(cwd, &name);
|
||||||
|
db.register(&rel);
|
||||||
|
log::info!("imported {rel}");
|
||||||
|
imported += 1;
|
||||||
|
}
|
||||||
|
Err(err) => log::warn!("could not import {}: {err}", src.display()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if imported > 0 {
|
||||||
|
if let Err(err) = db.save() {
|
||||||
|
log::warn!("could not write asset manifest: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imported
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internals -----------------------------------------------------------
|
||||||
|
|
||||||
|
/// The absolute path of an assets-relative path under the database's root.
|
||||||
|
fn abs_of(db: &AssetDatabase, rel: &str) -> PathBuf {
|
||||||
|
let assets = db.assets_dir();
|
||||||
|
if rel.is_empty() {
|
||||||
|
assets
|
||||||
|
} else {
|
||||||
|
assets.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes a rename/move to the right primitive: `move_folder` for folders,
|
||||||
|
/// `move_asset` for registered files, `fs::rename` (no overwrite) for
|
||||||
|
/// unregistered ones. Saves the manifest after a database change.
|
||||||
|
fn move_to(
|
||||||
|
db: &mut AssetDatabase,
|
||||||
|
rel: &str,
|
||||||
|
is_dir: bool,
|
||||||
|
new_rel: &str,
|
||||||
|
) -> Result<(), AssetDbError> {
|
||||||
|
if is_dir {
|
||||||
|
db.move_folder(rel, new_rel)?;
|
||||||
|
save_manifest(db);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match db.uid_of(rel) {
|
||||||
|
Some(uid) => {
|
||||||
|
db.move_asset(uid, new_rel)?;
|
||||||
|
save_manifest(db);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let to = abs_of(db, new_rel);
|
||||||
|
if to.exists() {
|
||||||
|
return Err(AssetDbError::DestinationExists(new_rel.to_owned()));
|
||||||
|
}
|
||||||
|
if let Some(parent) = to.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
Ok(std::fs::rename(abs_of(db, rel), to)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a registered asset and persists the manifest.
|
||||||
|
fn delete_and_save(db: &mut AssetDatabase, uid: AssetUid) -> Result<(), AssetDbError> {
|
||||||
|
db.delete_asset(uid)?;
|
||||||
|
save_manifest(db);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort manifest save after a mutation (failure → Console, not fatal).
|
||||||
|
fn save_manifest(db: &AssetDatabase) {
|
||||||
|
if let Err(err) = db.save() {
|
||||||
|
log::warn!("could not write asset manifest: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
|
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||||
|
|
||||||
|
/// A fresh project root with an `assets/` tree and an open database.
|
||||||
|
fn scratch_db(files: &[&str]) -> (PathBuf, AssetDatabase) {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"oxide_explorer_test_{}_{}",
|
||||||
|
std::process::id(),
|
||||||
|
COUNTER.fetch_add(1, Ordering::SeqCst),
|
||||||
|
));
|
||||||
|
let assets = root.join("assets");
|
||||||
|
std::fs::create_dir_all(&assets).unwrap();
|
||||||
|
for rel in files {
|
||||||
|
let full = assets.join(rel);
|
||||||
|
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(full, b"x").unwrap();
|
||||||
|
}
|
||||||
|
let mut db = AssetDatabase::new(&root);
|
||||||
|
db.scan();
|
||||||
|
(root, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn breadcrumbs_and_path_helpers() {
|
||||||
|
assert_eq!(breadcrumbs(""), vec![("assets".to_owned(), String::new())]);
|
||||||
|
assert_eq!(
|
||||||
|
breadcrumbs("textures/env"),
|
||||||
|
vec![
|
||||||
|
("assets".to_owned(), String::new()),
|
||||||
|
("textures".to_owned(), "textures".to_owned()),
|
||||||
|
("env".to_owned(), "textures/env".to_owned()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(join_rel("", "a"), "a");
|
||||||
|
assert_eq!(join_rel("a/b", "c"), "a/b/c");
|
||||||
|
assert_eq!(parent_of("a/b/c"), "a/b");
|
||||||
|
assert_eq!(parent_of("a"), "");
|
||||||
|
assert!(valid_name("wall.png"));
|
||||||
|
assert!(!valid_name(""));
|
||||||
|
assert!(!valid_name("a/b"));
|
||||||
|
assert!(!valid_name(".."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_dir_sorts_folders_first_and_annotates_registered_files() {
|
||||||
|
let (root, db) = scratch_db(&["textures/wall.png", "textures/env/sky.png", "notes.md"]);
|
||||||
|
|
||||||
|
let top = list_dir(&db, "");
|
||||||
|
let names: Vec<&str> = top.iter().map(|e| e.name.as_str()).collect();
|
||||||
|
assert_eq!(names, ["textures", "notes.md"]);
|
||||||
|
assert!(top[0].is_dir && top[0].uid.is_none());
|
||||||
|
assert_eq!(top[1].kind, Some(AssetKind::Other));
|
||||||
|
|
||||||
|
let textures = list_dir(&db, "textures");
|
||||||
|
let names: Vec<&str> = textures.iter().map(|e| e.name.as_str()).collect();
|
||||||
|
assert_eq!(names, ["env", "wall.png"]);
|
||||||
|
assert_eq!(textures[1].kind, Some(AssetKind::Texture));
|
||||||
|
assert_eq!(textures[1].uid, db.uid_of("textures/wall.png"));
|
||||||
|
|
||||||
|
// A folder that does not exist lists as empty, not an error.
|
||||||
|
assert!(list_dir(&db, "nope").is_empty());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unique_name_suffixes_before_the_extension() {
|
||||||
|
let (root, db) = scratch_db(&["textures/wall.png"]);
|
||||||
|
let dir = db.assets_dir().join("textures");
|
||||||
|
assert_eq!(unique_name(&dir, "new.png"), "new.png");
|
||||||
|
assert_eq!(unique_name(&dir, "wall.png"), "wall_2.png");
|
||||||
|
std::fs::write(dir.join("wall_2.png"), b"x").unwrap();
|
||||||
|
assert_eq!(unique_name(&dir, "wall.png"), "wall_3.png");
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_folder_is_unique_and_validated() {
|
||||||
|
let (root, db) = scratch_db(&[]);
|
||||||
|
assert_eq!(create_folder(&db, "", "props").unwrap(), "props");
|
||||||
|
assert_eq!(create_folder(&db, "", "props").unwrap(), "props_2");
|
||||||
|
assert_eq!(create_folder(&db, "props", "env").unwrap(), "props/env");
|
||||||
|
assert!(create_folder(&db, "", "a/b").is_err());
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_and_move_preserve_uids() {
|
||||||
|
let (root, mut db) = scratch_db(&["textures/wall.png", "textures/env/sky.png"]);
|
||||||
|
let wall = db.uid_of("textures/wall.png").unwrap();
|
||||||
|
let sky = db.uid_of("textures/env/sky.png").unwrap();
|
||||||
|
|
||||||
|
// Rename a file in place.
|
||||||
|
let new_rel = rename_entry(&mut db, "textures/wall.png", false, "brick.png").unwrap();
|
||||||
|
assert_eq!(new_rel, "textures/brick.png");
|
||||||
|
assert_eq!(db.relative_path(wall), Some("textures/brick.png"));
|
||||||
|
|
||||||
|
// Move it into a sibling folder.
|
||||||
|
let new_rel = move_entry(&mut db, "textures/brick.png", false, "textures/env").unwrap();
|
||||||
|
assert_eq!(new_rel, "textures/env/brick.png");
|
||||||
|
assert_eq!(db.relative_path(wall), Some("textures/env/brick.png"));
|
||||||
|
|
||||||
|
// Rename the folder: both entries follow, uids intact.
|
||||||
|
let new_rel = rename_entry(&mut db, "textures/env", true, "world").unwrap();
|
||||||
|
assert_eq!(new_rel, "textures/world");
|
||||||
|
assert_eq!(db.relative_path(sky), Some("textures/world/sky.png"));
|
||||||
|
assert_eq!(db.relative_path(wall), Some("textures/world/brick.png"));
|
||||||
|
|
||||||
|
// Invalid target name is refused.
|
||||||
|
assert!(rename_entry(&mut db, "textures/world", true, "a/b").is_err());
|
||||||
|
|
||||||
|
// The manifest was persisted along the way.
|
||||||
|
let reloaded = AssetDatabase::open(&root);
|
||||||
|
assert_eq!(reloaded.relative_path(sky), Some("textures/world/sky.png"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unregistered_files_rename_through_the_filesystem() {
|
||||||
|
let (root, mut db) = scratch_db(&[]);
|
||||||
|
// A file the database does not track (e.g. a license dropped next to
|
||||||
|
// a font). Note scratch_db scans, so create it *after*.
|
||||||
|
let assets = db.assets_dir();
|
||||||
|
std::fs::write(assets.join("OFL.txt"), b"x").unwrap();
|
||||||
|
assert!(db.uid_of("OFL.txt").is_none());
|
||||||
|
|
||||||
|
let new_rel = rename_entry(&mut db, "OFL.txt", false, "LICENSE.txt").unwrap();
|
||||||
|
assert_eq!(new_rel, "LICENSE.txt");
|
||||||
|
assert!(assets.join("LICENSE.txt").is_file());
|
||||||
|
assert!(!assets.join("OFL.txt").exists());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_rules_files_yes_folders_only_when_empty() {
|
||||||
|
let (root, mut db) = scratch_db(&["textures/wall.png"]);
|
||||||
|
let wall_entry = list_dir(&db, "textures")
|
||||||
|
.into_iter()
|
||||||
|
.find(|e| e.name == "wall.png")
|
||||||
|
.unwrap();
|
||||||
|
let folder_entry = list_dir(&db, "")
|
||||||
|
.into_iter()
|
||||||
|
.find(|e| e.name == "textures")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Non-empty folder refused; file deletes (entry + disk); empty folder ok.
|
||||||
|
assert!(delete_entry(&mut db, &folder_entry).is_err());
|
||||||
|
delete_entry(&mut db, &wall_entry).unwrap();
|
||||||
|
assert!(db.uid_of("textures/wall.png").is_none());
|
||||||
|
delete_entry(&mut db, &folder_entry).unwrap();
|
||||||
|
assert!(list_dir(&db, "").is_empty());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_copies_registers_and_dodges_collisions() {
|
||||||
|
let (root, mut db) = scratch_db(&["textures/wall.png"]);
|
||||||
|
// Two outside files, one colliding with an existing asset name.
|
||||||
|
let outside = root.join("outside");
|
||||||
|
std::fs::create_dir_all(&outside).unwrap();
|
||||||
|
std::fs::write(outside.join("wall.png"), b"new").unwrap();
|
||||||
|
std::fs::write(outside.join("tree.glb"), b"tree").unwrap();
|
||||||
|
|
||||||
|
let n = import_files(
|
||||||
|
&mut db,
|
||||||
|
"textures",
|
||||||
|
&[outside.join("wall.png"), outside.join("tree.glb")],
|
||||||
|
);
|
||||||
|
assert_eq!(n, 2);
|
||||||
|
assert!(db.uid_of("textures/wall_2.png").is_some());
|
||||||
|
// Kind follows the *folder* it was dropped into.
|
||||||
|
let tree = db.uid_of("textures/tree.glb").unwrap();
|
||||||
|
assert_eq!(db.entry(tree).unwrap().kind, AssetKind::Texture);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,655 @@
|
|||||||
|
//! Module → editor extension API.
|
||||||
|
//!
|
||||||
|
//! The engine's [`Module`](oxide_engine::app::Module) trait registers systems,
|
||||||
|
//! component types, asset loaders, and resources on an
|
||||||
|
//! [`App`](oxide_engine::app::App). This module is its **editor-side companion**:
|
||||||
|
//! one trait — [`EditorModule`] — through which a module contributes the UI it
|
||||||
|
//! needs the editor to host on its behalf.
|
||||||
|
//!
|
||||||
|
//! Specifically, a module can add:
|
||||||
|
//!
|
||||||
|
//! - **Menu items** in the top menu bar (e.g. `"File/Open Recent"`),
|
||||||
|
//! - **Dockable panels** in the docking shell (e.g. an "Audio Mixer"),
|
||||||
|
//! - **Viewport tools** that take over input on the 3D viewport (gizmos,
|
||||||
|
//! measurement, paint),
|
||||||
|
//! - **Component inspectors** that render rich editors for the module's
|
||||||
|
//! component types (keyed by their
|
||||||
|
//! [`TypeRegistry`](oxide_engine::reflect::TypeRegistry) name), and
|
||||||
|
//! - **Settings pages** that drive the module's
|
||||||
|
//! [`Settings`](oxide_engine::settings::Settings) section in the Preferences
|
||||||
|
//! window.
|
||||||
|
//!
|
||||||
|
//! All five plug into the editor through one registry — [`EditorExtensions`] —
|
||||||
|
//! consumed by the docking shell. The shell never edits its own source to host
|
||||||
|
//! a new module's UI; this is *the* mechanism by which "anyone can write a
|
||||||
|
//! module" that extends both engine logic and the editor.
|
||||||
|
//!
|
||||||
|
//! ## Why a separate trait
|
||||||
|
//!
|
||||||
|
//! The engine has no egui dependency, so the editor hook can't live on the
|
||||||
|
//! engine's `Module` trait without dragging UI types into the engine. Two
|
||||||
|
//! traits implemented on the same struct keeps the engine GUI-free and lets the
|
||||||
|
//! editor binary register the same module on both sides:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! struct MyModule;
|
||||||
|
//! impl oxide_engine::app::Module for MyModule { /* … systems, types */ }
|
||||||
|
//! impl oxide_editor::extension::EditorModule for MyModule { /* … panels */ }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Attribution
|
||||||
|
//!
|
||||||
|
//! Every contribution remembers which module added it. Removing a module
|
||||||
|
//! ([`EditorExtensions::remove_module`]) removes all of its contributions in
|
||||||
|
//! one shot — the same lifecycle the engine's
|
||||||
|
//! [`App::remove_module`](oxide_engine::app::App::remove_module) gives systems,
|
||||||
|
//! types, and loaders. Disabling a module
|
||||||
|
//! ([`set_module_enabled`](EditorExtensions::set_module_enabled)) keeps the
|
||||||
|
//! contributions registered but hides them from the shell, so toggling a
|
||||||
|
//! module in Preferences is reversible without rebuilding the registry.
|
||||||
|
//!
|
||||||
|
//! ## Render closures
|
||||||
|
//!
|
||||||
|
//! Panel / inspector / settings-page closures take only `&mut egui::Ui` in
|
||||||
|
//! Stage 6 piece 5 (registration). Piece 6 — the docking shell — refines the
|
||||||
|
//! signatures to pass through the editor's runtime context. Modules that need
|
||||||
|
//! shared state today should capture it through interior mutability
|
||||||
|
//! (`Rc<RefCell<...>>`).
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// Where a panel prefers to be docked the first time the user opens it.
|
||||||
|
///
|
||||||
|
/// The shell may override this when restoring a saved layout; it is only a
|
||||||
|
/// hint, not a guarantee.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum DockLocation {
|
||||||
|
/// Pinned to the left side of the main area (hierarchies, project browser).
|
||||||
|
Left,
|
||||||
|
/// Pinned to the right side (properties / inspector).
|
||||||
|
Right,
|
||||||
|
/// Pinned to the bottom (console, logs, timeline).
|
||||||
|
Bottom,
|
||||||
|
/// The main central tab area (viewport, code, asset preview).
|
||||||
|
Center,
|
||||||
|
/// A floating window outside the dock layout.
|
||||||
|
Floating,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One top-menu-bar item contributed by a module.
|
||||||
|
///
|
||||||
|
/// `path` uses `/` as a separator and identifies the menu tree, e.g.
|
||||||
|
/// `"File/New Project"` or `"View/Layout/Default"`. The shell groups items by
|
||||||
|
/// their leading segments.
|
||||||
|
pub struct MenuItem {
|
||||||
|
/// Slash-separated path through the menu tree.
|
||||||
|
pub path: String,
|
||||||
|
/// Optional human-readable shortcut hint (e.g. `"Ctrl+N"`). Not bound by
|
||||||
|
/// this API — the actual key binding lives in the Stage-7 input map.
|
||||||
|
pub shortcut: Option<String>,
|
||||||
|
/// Invoked when the item is clicked. The shell decides when to call it.
|
||||||
|
pub action: Box<dyn FnMut()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dockable panel contributed by a module.
|
||||||
|
pub struct Panel {
|
||||||
|
/// Stable name; doubles as the tab title and the lookup key.
|
||||||
|
pub name: String,
|
||||||
|
/// Where the panel prefers to dock initially.
|
||||||
|
pub default_dock: DockLocation,
|
||||||
|
/// Renders the panel's contents into `ui` each frame the panel is visible.
|
||||||
|
pub render: Box<dyn FnMut(&mut egui::Ui)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A viewport tool — usually a gizmo or a brush — that takes over the 3D
|
||||||
|
/// viewport's input while active.
|
||||||
|
pub struct ViewportTool {
|
||||||
|
/// Stable name (e.g. `"Translate"`, `"Sculpt"`); identifies the tool in
|
||||||
|
/// menus, toolbars, and shortcut tables.
|
||||||
|
pub name: String,
|
||||||
|
/// Called once when the tool becomes the active viewport tool. Use it to
|
||||||
|
/// reset transient state or hook into the editor's command stack.
|
||||||
|
pub on_activate: Box<dyn FnMut()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An editor for one reflected component type, keyed by the same name the
|
||||||
|
/// component is registered under in the
|
||||||
|
/// [`TypeRegistry`](oxide_engine::reflect::TypeRegistry). The shell calls
|
||||||
|
/// `render` from the Inspector panel when a selected entity has the component.
|
||||||
|
pub struct ComponentInspector {
|
||||||
|
/// Matches the `name` passed to
|
||||||
|
/// [`App::register_type`](oxide_engine::app::App::register_type).
|
||||||
|
pub type_name: String,
|
||||||
|
/// Renders an editor for the component into `ui`.
|
||||||
|
pub render: Box<dyn FnMut(&mut egui::Ui)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page in the Preferences window driving one
|
||||||
|
/// [`Settings`](oxide_engine::settings::Settings) section.
|
||||||
|
pub struct SettingsPage {
|
||||||
|
/// Matches the `name` passed to
|
||||||
|
/// [`Settings::register`](oxide_engine::settings::Settings::register).
|
||||||
|
pub section_name: String,
|
||||||
|
/// Title shown in the Preferences sidebar (defaults to `section_name` when
|
||||||
|
/// the contributor leaves it empty).
|
||||||
|
pub title: String,
|
||||||
|
/// Renders the page's controls into `ui`.
|
||||||
|
pub render: Box<dyn FnMut(&mut egui::Ui)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Editor-side companion to the engine's
|
||||||
|
/// [`Module`](oxide_engine::app::Module) trait.
|
||||||
|
///
|
||||||
|
/// Implement on the same type that implements `Module` (or on a separate
|
||||||
|
/// editor-only struct) and pass it to
|
||||||
|
/// [`EditorExtensions::add_module`]. Everything `build_editor` registers is
|
||||||
|
/// attributed to this module and can be removed atomically with
|
||||||
|
/// [`EditorExtensions::remove_module`].
|
||||||
|
pub trait EditorModule: 'static {
|
||||||
|
/// A stable, unique name — should match the paired engine `Module::name`
|
||||||
|
/// when both halves describe the same module, so the editor and engine
|
||||||
|
/// agree on enable/disable.
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// Registers UI contributions on `ext`.
|
||||||
|
fn build_editor(&self, ext: &mut EditorExtensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal record tying any contribution to its source module and an
|
||||||
|
/// enabled/disabled flag inherited from the module.
|
||||||
|
struct Entry<T> {
|
||||||
|
module: &'static str,
|
||||||
|
value: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registry of every UI contribution made by every editor module. The docking
|
||||||
|
/// shell reads this in Piece 6 to assemble the menu bar, dock layout, viewport
|
||||||
|
/// toolbox, inspector, and Preferences window.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct EditorExtensions {
|
||||||
|
menu_items: Vec<Entry<MenuItem>>,
|
||||||
|
panels: Vec<Entry<Panel>>,
|
||||||
|
viewport_tools: Vec<Entry<ViewportTool>>,
|
||||||
|
inspectors: BTreeMap<String, Entry<ComponentInspector>>,
|
||||||
|
settings_pages: BTreeMap<String, Entry<SettingsPage>>,
|
||||||
|
modules: Vec<&'static str>,
|
||||||
|
enabled: BTreeMap<&'static str, bool>,
|
||||||
|
/// Set only while a module's `build_editor` is running, so individual
|
||||||
|
/// `add_*` helpers can attribute the contribution without taking the
|
||||||
|
/// module name as an argument.
|
||||||
|
current_module: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EditorExtensions {
|
||||||
|
/// A fresh, empty registry.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers `module` and runs its
|
||||||
|
/// [`build_editor`](EditorModule::build_editor). Re-adding a module with
|
||||||
|
/// the same name first removes the old one, so callers don't have to dance
|
||||||
|
/// around stale contributions when reloading.
|
||||||
|
pub fn add_module<M: EditorModule>(&mut self, module: M) {
|
||||||
|
let name = module.name();
|
||||||
|
if self.modules.contains(&name) {
|
||||||
|
self.remove_module(name);
|
||||||
|
}
|
||||||
|
self.modules.push(name);
|
||||||
|
self.enabled.insert(name, true);
|
||||||
|
self.current_module = Some(name);
|
||||||
|
module.build_editor(self);
|
||||||
|
self.current_module = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes every contribution registered by the named module. Returns
|
||||||
|
/// whether the module was present.
|
||||||
|
pub fn remove_module(&mut self, name: &str) -> bool {
|
||||||
|
if !self.modules.contains(&name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.menu_items.retain(|e| e.module != name);
|
||||||
|
self.panels.retain(|e| e.module != name);
|
||||||
|
self.viewport_tools.retain(|e| e.module != name);
|
||||||
|
self.inspectors.retain(|_, e| e.module != name);
|
||||||
|
self.settings_pages.retain(|_, e| e.module != name);
|
||||||
|
self.modules.retain(|m| *m != name);
|
||||||
|
self.enabled.remove(name);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the named module is currently registered (independent of
|
||||||
|
/// enabled-state).
|
||||||
|
pub fn has_module(&self, name: &str) -> bool {
|
||||||
|
self.modules.contains(&name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles whether contributions from the named module are visible to the
|
||||||
|
/// shell. The contributions stay registered so re-enabling is instant.
|
||||||
|
/// Returns whether the module was present.
|
||||||
|
pub fn set_module_enabled(&mut self, name: &str, enabled: bool) -> bool {
|
||||||
|
if let Some(slot) = self.enabled.get_mut(name) {
|
||||||
|
*slot = enabled;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the named module's contributions are currently enabled. Returns
|
||||||
|
/// `false` for unknown modules.
|
||||||
|
pub fn is_module_enabled(&self, name: &str) -> bool {
|
||||||
|
self.enabled.get(name).copied().unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registered module names, in insertion order.
|
||||||
|
pub fn modules(&self) -> impl Iterator<Item = &'static str> + '_ {
|
||||||
|
self.modules.iter().copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- contribution helpers (called from `build_editor`) -----------------
|
||||||
|
|
||||||
|
/// Adds a menu item. Panics if called outside a module's `build_editor` —
|
||||||
|
/// every contribution must be attributable to some module.
|
||||||
|
pub fn add_menu_item(
|
||||||
|
&mut self,
|
||||||
|
path: impl Into<String>,
|
||||||
|
action: impl FnMut() + 'static,
|
||||||
|
) -> &mut Self {
|
||||||
|
self.add_menu_item_full(MenuItem {
|
||||||
|
path: path.into(),
|
||||||
|
shortcut: None,
|
||||||
|
action: Box::new(action),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a menu item with a fully-specified [`MenuItem`] (lets the caller
|
||||||
|
/// set a shortcut hint).
|
||||||
|
pub fn add_menu_item_full(&mut self, item: MenuItem) -> &mut Self {
|
||||||
|
let module = self.expect_module("add_menu_item");
|
||||||
|
self.menu_items.push(Entry {
|
||||||
|
module,
|
||||||
|
value: item,
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a dockable panel. `default_dock` is a placement hint; the shell
|
||||||
|
/// may override when restoring a saved layout.
|
||||||
|
pub fn add_panel(
|
||||||
|
&mut self,
|
||||||
|
name: impl Into<String>,
|
||||||
|
default_dock: DockLocation,
|
||||||
|
render: impl FnMut(&mut egui::Ui) + 'static,
|
||||||
|
) -> &mut Self {
|
||||||
|
let module = self.expect_module("add_panel");
|
||||||
|
self.panels.push(Entry {
|
||||||
|
module,
|
||||||
|
value: Panel {
|
||||||
|
name: name.into(),
|
||||||
|
default_dock,
|
||||||
|
render: Box::new(render),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a viewport tool (gizmo, brush, …).
|
||||||
|
pub fn add_viewport_tool(
|
||||||
|
&mut self,
|
||||||
|
name: impl Into<String>,
|
||||||
|
on_activate: impl FnMut() + 'static,
|
||||||
|
) -> &mut Self {
|
||||||
|
let module = self.expect_module("add_viewport_tool");
|
||||||
|
self.viewport_tools.push(Entry {
|
||||||
|
module,
|
||||||
|
value: ViewportTool {
|
||||||
|
name: name.into(),
|
||||||
|
on_activate: Box::new(on_activate),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a component inspector keyed by the type's reflection name.
|
||||||
|
/// Re-registering a name overwrites the previous inspector (most-recently-
|
||||||
|
/// added module wins; this lets a project override a base module's
|
||||||
|
/// inspector if it has reason to).
|
||||||
|
pub fn add_inspector(
|
||||||
|
&mut self,
|
||||||
|
type_name: impl Into<String>,
|
||||||
|
render: impl FnMut(&mut egui::Ui) + 'static,
|
||||||
|
) -> &mut Self {
|
||||||
|
let module = self.expect_module("add_inspector");
|
||||||
|
let type_name = type_name.into();
|
||||||
|
self.inspectors.insert(
|
||||||
|
type_name.clone(),
|
||||||
|
Entry {
|
||||||
|
module,
|
||||||
|
value: ComponentInspector {
|
||||||
|
type_name,
|
||||||
|
render: Box::new(render),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a Preferences page driving the named settings section.
|
||||||
|
pub fn add_settings_page(
|
||||||
|
&mut self,
|
||||||
|
section_name: impl Into<String>,
|
||||||
|
title: impl Into<String>,
|
||||||
|
render: impl FnMut(&mut egui::Ui) + 'static,
|
||||||
|
) -> &mut Self {
|
||||||
|
let module = self.expect_module("add_settings_page");
|
||||||
|
let section_name = section_name.into();
|
||||||
|
let title = title.into();
|
||||||
|
let title = if title.is_empty() {
|
||||||
|
section_name.clone()
|
||||||
|
} else {
|
||||||
|
title
|
||||||
|
};
|
||||||
|
self.settings_pages.insert(
|
||||||
|
section_name.clone(),
|
||||||
|
Entry {
|
||||||
|
module,
|
||||||
|
value: SettingsPage {
|
||||||
|
section_name,
|
||||||
|
title,
|
||||||
|
render: Box::new(render),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shell-facing lookups ---------------------------------------------
|
||||||
|
|
||||||
|
/// Slash-separated paths of every currently-enabled menu item, in the
|
||||||
|
/// order they were contributed.
|
||||||
|
pub fn menu_item_paths(&self) -> impl Iterator<Item = &str> {
|
||||||
|
self.iter_menu_items().map(|i| i.path.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Names of every currently-enabled panel.
|
||||||
|
pub fn panel_names(&self) -> impl Iterator<Item = &str> {
|
||||||
|
self.iter_panels().map(|p| p.name.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Names of every currently-enabled viewport tool.
|
||||||
|
pub fn viewport_tool_names(&self) -> impl Iterator<Item = &str> {
|
||||||
|
self.iter_viewport_tools().map(|t| t.name.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reflection-keyed type names that currently have an inspector
|
||||||
|
/// registered.
|
||||||
|
pub fn inspector_type_names(&self) -> impl Iterator<Item = &str> {
|
||||||
|
self.iter_inspectors().map(|i| i.type_name.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settings-section names that currently have a Preferences page
|
||||||
|
/// registered.
|
||||||
|
pub fn settings_page_names(&self) -> impl Iterator<Item = &str> {
|
||||||
|
self.iter_settings_pages().map(|p| p.section_name.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an inspector is registered for the given reflection name and
|
||||||
|
/// the contributing module is enabled.
|
||||||
|
pub fn has_inspector_for(&self, type_name: &str) -> bool {
|
||||||
|
self.inspectors
|
||||||
|
.get(type_name)
|
||||||
|
.map(|e| self.is_enabled(e.module))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a Preferences page is registered for the given section name
|
||||||
|
/// and the contributing module is enabled.
|
||||||
|
pub fn has_settings_page_for(&self, section_name: &str) -> bool {
|
||||||
|
self.settings_pages
|
||||||
|
.get(section_name)
|
||||||
|
.map(|e| self.is_enabled(e.module))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates the enabled menu items themselves (gives the shell direct
|
||||||
|
/// access to actions/shortcuts when rendering).
|
||||||
|
pub fn iter_menu_items(&self) -> impl Iterator<Item = &MenuItem> {
|
||||||
|
self.menu_items
|
||||||
|
.iter()
|
||||||
|
.filter(|e| self.is_enabled(e.module))
|
||||||
|
.map(|e| &e.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutably iterates the enabled menu items so the shell can invoke each
|
||||||
|
/// item's `FnMut` action when the user clicks it.
|
||||||
|
pub fn iter_menu_items_mut(&mut self) -> impl Iterator<Item = &mut MenuItem> {
|
||||||
|
let enabled = &self.enabled;
|
||||||
|
self.menu_items
|
||||||
|
.iter_mut()
|
||||||
|
.filter(move |e| enabled.get(e.module).copied().unwrap_or(false))
|
||||||
|
.map(|e| &mut e.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates the enabled panels.
|
||||||
|
pub fn iter_panels(&self) -> impl Iterator<Item = &Panel> {
|
||||||
|
self.panels
|
||||||
|
.iter()
|
||||||
|
.filter(|e| self.is_enabled(e.module))
|
||||||
|
.map(|e| &e.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutably iterates the enabled panels so the shell can call each panel's
|
||||||
|
/// `FnMut` render closure each frame.
|
||||||
|
pub fn iter_panels_mut(&mut self) -> impl Iterator<Item = &mut Panel> {
|
||||||
|
let enabled = &self.enabled;
|
||||||
|
self.panels
|
||||||
|
.iter_mut()
|
||||||
|
.filter(move |e| enabled.get(e.module).copied().unwrap_or(false))
|
||||||
|
.map(|e| &mut e.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates the enabled viewport tools.
|
||||||
|
pub fn iter_viewport_tools(&self) -> impl Iterator<Item = &ViewportTool> {
|
||||||
|
self.viewport_tools
|
||||||
|
.iter()
|
||||||
|
.filter(|e| self.is_enabled(e.module))
|
||||||
|
.map(|e| &e.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates the enabled component inspectors (in stable name order).
|
||||||
|
pub fn iter_inspectors(&self) -> impl Iterator<Item = &ComponentInspector> {
|
||||||
|
self.inspectors
|
||||||
|
.values()
|
||||||
|
.filter(|e| self.is_enabled(e.module))
|
||||||
|
.map(|e| &e.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates the enabled settings pages (in stable section-name order).
|
||||||
|
pub fn iter_settings_pages(&self) -> impl Iterator<Item = &SettingsPage> {
|
||||||
|
self.settings_pages
|
||||||
|
.values()
|
||||||
|
.filter(|e| self.is_enabled(e.module))
|
||||||
|
.map(|e| &e.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The total number of contributions of every kind, across every
|
||||||
|
/// registered module. Mostly for tests and diagnostics.
|
||||||
|
pub fn contribution_count(&self) -> usize {
|
||||||
|
self.menu_items.len()
|
||||||
|
+ self.panels.len()
|
||||||
|
+ self.viewport_tools.len()
|
||||||
|
+ self.inspectors.len()
|
||||||
|
+ self.settings_pages.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_enabled(&self, module: &str) -> bool {
|
||||||
|
self.enabled.get(module).copied().unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expect_module(&self, helper: &str) -> &'static str {
|
||||||
|
self.current_module.unwrap_or_else(|| {
|
||||||
|
panic!("EditorExtensions::{helper} called outside a module's build_editor")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A minimal module that exercises every contribution kind. Used both by
|
||||||
|
/// the unit tests here and by the integration test in `tests/src/lib.rs`
|
||||||
|
/// (where it proves the Stage-6 criterion: a module adds a menu item, a
|
||||||
|
/// panel, and a settings page through the public API with no editor-core
|
||||||
|
/// edits).
|
||||||
|
struct DemoModule;
|
||||||
|
impl EditorModule for DemoModule {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"demo"
|
||||||
|
}
|
||||||
|
fn build_editor(&self, ext: &mut EditorExtensions) {
|
||||||
|
ext.add_menu_item("Demo/Hello", || {});
|
||||||
|
ext.add_panel("Demo Panel", DockLocation::Right, |_ui| {});
|
||||||
|
ext.add_viewport_tool("Demo Tool", || {});
|
||||||
|
ext.add_inspector("DemoComponent", |_ui| {});
|
||||||
|
ext.add_settings_page("demo", "Demo", |_ui| {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OverlapModule;
|
||||||
|
impl EditorModule for OverlapModule {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"overlap"
|
||||||
|
}
|
||||||
|
fn build_editor(&self, ext: &mut EditorExtensions) {
|
||||||
|
ext.add_menu_item("File/Quit", || {});
|
||||||
|
ext.add_inspector("DemoComponent", |_ui| {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_module_registers_each_contribution_kind() {
|
||||||
|
let mut ext = EditorExtensions::new();
|
||||||
|
ext.add_module(DemoModule);
|
||||||
|
|
||||||
|
assert!(ext.has_module("demo"));
|
||||||
|
assert!(ext.is_module_enabled("demo"));
|
||||||
|
assert_eq!(ext.modules().collect::<Vec<_>>(), vec!["demo"]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ext.menu_item_paths().collect::<Vec<_>>(),
|
||||||
|
vec!["Demo/Hello"]
|
||||||
|
);
|
||||||
|
assert_eq!(ext.panel_names().collect::<Vec<_>>(), vec!["Demo Panel"]);
|
||||||
|
assert_eq!(
|
||||||
|
ext.viewport_tool_names().collect::<Vec<_>>(),
|
||||||
|
vec!["Demo Tool"]
|
||||||
|
);
|
||||||
|
assert!(ext.has_inspector_for("DemoComponent"));
|
||||||
|
assert!(ext.has_settings_page_for("demo"));
|
||||||
|
assert_eq!(ext.contribution_count(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_module_drops_every_contribution() {
|
||||||
|
let mut ext = EditorExtensions::new();
|
||||||
|
ext.add_module(DemoModule);
|
||||||
|
assert_eq!(ext.contribution_count(), 5);
|
||||||
|
|
||||||
|
assert!(ext.remove_module("demo"));
|
||||||
|
assert!(!ext.has_module("demo"));
|
||||||
|
assert_eq!(ext.contribution_count(), 0);
|
||||||
|
assert!(!ext.has_inspector_for("DemoComponent"));
|
||||||
|
assert!(!ext.has_settings_page_for("demo"));
|
||||||
|
|
||||||
|
// Removing twice is a no-op.
|
||||||
|
assert!(!ext.remove_module("demo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabling_a_module_hides_its_contributions_without_removing() {
|
||||||
|
let mut ext = EditorExtensions::new();
|
||||||
|
ext.add_module(DemoModule);
|
||||||
|
assert!(ext.set_module_enabled("demo", false));
|
||||||
|
assert!(!ext.is_module_enabled("demo"));
|
||||||
|
|
||||||
|
// Hidden from every shell-facing lookup…
|
||||||
|
assert_eq!(ext.menu_item_paths().count(), 0);
|
||||||
|
assert_eq!(ext.panel_names().count(), 0);
|
||||||
|
assert!(!ext.has_inspector_for("DemoComponent"));
|
||||||
|
assert!(!ext.has_settings_page_for("demo"));
|
||||||
|
// …but still registered, so re-enabling is instant.
|
||||||
|
assert_eq!(ext.contribution_count(), 5);
|
||||||
|
|
||||||
|
assert!(ext.set_module_enabled("demo", true));
|
||||||
|
assert_eq!(ext.panel_names().count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn re_adding_a_module_replaces_its_contributions() {
|
||||||
|
let mut ext = EditorExtensions::new();
|
||||||
|
ext.add_module(DemoModule);
|
||||||
|
ext.add_module(DemoModule);
|
||||||
|
// Still one module, contributions are not duplicated.
|
||||||
|
assert_eq!(ext.modules().collect::<Vec<_>>(), vec!["demo"]);
|
||||||
|
assert_eq!(ext.contribution_count(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn later_module_overrides_inspector_for_same_type() {
|
||||||
|
// Both modules register an inspector for "DemoComponent". The
|
||||||
|
// last-registered wins, but attribution remains correct: removing the
|
||||||
|
// override exposes nothing (the original was overwritten, not
|
||||||
|
// stacked), which is the simple-and-predictable behavior to ship for
|
||||||
|
// piece 5. Stacking would let a project layer multiple inspectors on
|
||||||
|
// one type — possible future refinement, not needed now.
|
||||||
|
let mut ext = EditorExtensions::new();
|
||||||
|
ext.add_module(DemoModule);
|
||||||
|
ext.add_module(OverlapModule);
|
||||||
|
|
||||||
|
assert!(ext.has_inspector_for("DemoComponent"));
|
||||||
|
let owners: Vec<&'static str> = ext.inspectors.values().map(|e| e.module).collect();
|
||||||
|
assert_eq!(owners, vec!["overlap"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modules_dont_see_each_others_contributions_when_disabled() {
|
||||||
|
let mut ext = EditorExtensions::new();
|
||||||
|
ext.add_module(DemoModule);
|
||||||
|
ext.add_module(OverlapModule);
|
||||||
|
|
||||||
|
// Two menu items total; disabling overlap hides only its item.
|
||||||
|
assert_eq!(ext.menu_item_paths().count(), 2);
|
||||||
|
ext.set_module_enabled("overlap", false);
|
||||||
|
let visible: Vec<&str> = ext.menu_item_paths().collect();
|
||||||
|
assert_eq!(visible, vec!["Demo/Hello"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "outside a module's build_editor")]
|
||||||
|
fn contributing_outside_build_editor_panics() {
|
||||||
|
// Catches the easy mistake of calling add_panel on a bare
|
||||||
|
// EditorExtensions — every contribution must be attributable to a
|
||||||
|
// module, otherwise remove_module would leave orphans behind.
|
||||||
|
let mut ext = EditorExtensions::new();
|
||||||
|
ext.add_panel("Orphan", DockLocation::Center, |_ui| {});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_page_defaults_title_to_section_name() {
|
||||||
|
struct M;
|
||||||
|
impl EditorModule for M {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"m"
|
||||||
|
}
|
||||||
|
fn build_editor(&self, ext: &mut EditorExtensions) {
|
||||||
|
ext.add_settings_page("audio", "", |_ui| {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut ext = EditorExtensions::new();
|
||||||
|
ext.add_module(M);
|
||||||
|
let page = ext.iter_settings_pages().next().unwrap();
|
||||||
|
assert_eq!(page.title, "audio");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,886 @@
|
|||||||
|
//! Transform gizmo math: hit testing, drag projection, and snap rounding.
|
||||||
|
//!
|
||||||
|
//! Stage 7 piece 6 (a): rays in, transforms out. The viewport piece
|
||||||
|
//! renders the handles and feeds rays into [`hit_test`] and
|
||||||
|
//! [`apply_drag`]; this module owns the geometry so all of it can be
|
||||||
|
//! unit-tested without a window.
|
||||||
|
//!
|
||||||
|
//! Three modes ([`GizmoMode`]) each expose a small set of [`GizmoHandle`]s:
|
||||||
|
//!
|
||||||
|
//! - **Translate** — one axis arrow per world axis, plus three "plane
|
||||||
|
//! quads" (XY/XZ/YZ) that drag along two axes at once.
|
||||||
|
//! - **Rotate** — one circle per world axis, dragged around its normal.
|
||||||
|
//! - **Scale** — one axis cube per world axis (non-uniform along that
|
||||||
|
//! axis) plus one center handle for uniform scale.
|
||||||
|
//!
|
||||||
|
//! Holding the snap modifier rounds the drag result to a configurable
|
||||||
|
//! step ([`SnapSettings`]): grid distance for translate, angle for
|
||||||
|
//! rotate, factor step for scale. Snap is applied to the *delta* from
|
||||||
|
//! the drag's starting transform, never to the starting transform
|
||||||
|
//! itself, so the result lines up with a fresh selection that already
|
||||||
|
//! sits between grid points.
|
||||||
|
//!
|
||||||
|
//! The gizmo lives at the entity's translation (its rotation and scale
|
||||||
|
//! do not transform the handles — they always point along world axes).
|
||||||
|
//! The shipped viewport renders this "world-space" gizmo; a future
|
||||||
|
//! "local-space" toggle would orient the handles by the entity rotation
|
||||||
|
//! before hit testing, which is a small change in [`world_axis`] /
|
||||||
|
//! [`world_plane`].
|
||||||
|
|
||||||
|
use oxide_engine::math::{Plane, Quat, Ray, Transform, Vec3};
|
||||||
|
|
||||||
|
/// Which transform tool the gizmo is showing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GizmoMode {
|
||||||
|
/// Axis arrows + plane quads. Hotkey **W**.
|
||||||
|
Translate,
|
||||||
|
/// Axis circles. Hotkey **E**.
|
||||||
|
Rotate,
|
||||||
|
/// Axis cubes + center uniform. Hotkey **R**.
|
||||||
|
Scale,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GizmoMode {
|
||||||
|
/// The label shown in the status bar / toolbar.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
GizmoMode::Translate => "Translate",
|
||||||
|
GizmoMode::Rotate => "Rotate",
|
||||||
|
GizmoMode::Scale => "Scale",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One of the three world axes.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Axis3 {
|
||||||
|
X,
|
||||||
|
Y,
|
||||||
|
Z,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Axis3 {
|
||||||
|
/// All three axes in stable order.
|
||||||
|
pub const ALL: [Axis3; 3] = [Axis3::X, Axis3::Y, Axis3::Z];
|
||||||
|
|
||||||
|
/// Unit vector along this axis.
|
||||||
|
pub fn unit(self) -> Vec3 {
|
||||||
|
match self {
|
||||||
|
Axis3::X => Vec3::X,
|
||||||
|
Axis3::Y => Vec3::Y,
|
||||||
|
Axis3::Z => Vec3::Z,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zero-based index for indexing into per-component arrays.
|
||||||
|
pub fn index(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Axis3::X => 0,
|
||||||
|
Axis3::Y => 1,
|
||||||
|
Axis3::Z => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One of the three world-aligned planes (XY = plane whose normal is Z, …).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PlaneAxis {
|
||||||
|
XY,
|
||||||
|
XZ,
|
||||||
|
YZ,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaneAxis {
|
||||||
|
/// All three planes in stable order.
|
||||||
|
pub const ALL: [PlaneAxis; 3] = [PlaneAxis::XY, PlaneAxis::XZ, PlaneAxis::YZ];
|
||||||
|
|
||||||
|
/// Unit normal to the plane.
|
||||||
|
pub fn normal(self) -> Vec3 {
|
||||||
|
match self {
|
||||||
|
PlaneAxis::XY => Vec3::Z,
|
||||||
|
PlaneAxis::XZ => Vec3::Y,
|
||||||
|
PlaneAxis::YZ => Vec3::X,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two axes that lie in this plane (in stable order).
|
||||||
|
pub fn axes(self) -> (Vec3, Vec3) {
|
||||||
|
match self {
|
||||||
|
PlaneAxis::XY => (Vec3::X, Vec3::Y),
|
||||||
|
PlaneAxis::XZ => (Vec3::X, Vec3::Z),
|
||||||
|
PlaneAxis::YZ => (Vec3::Y, Vec3::Z),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One interactive gizmo handle.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GizmoHandle {
|
||||||
|
TranslateAxis(Axis3),
|
||||||
|
TranslatePlane(PlaneAxis),
|
||||||
|
RotateAxis(Axis3),
|
||||||
|
ScaleAxis(Axis3),
|
||||||
|
/// The center "uniform scale" cube.
|
||||||
|
ScaleUniform,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GizmoHandle {
|
||||||
|
/// The mode this handle belongs to.
|
||||||
|
pub fn mode(self) -> GizmoMode {
|
||||||
|
match self {
|
||||||
|
GizmoHandle::TranslateAxis(_) | GizmoHandle::TranslatePlane(_) => GizmoMode::Translate,
|
||||||
|
GizmoHandle::RotateAxis(_) => GizmoMode::Rotate,
|
||||||
|
GizmoHandle::ScaleAxis(_) | GizmoHandle::ScaleUniform => GizmoMode::Scale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snap step sizes applied during a drag while the snap modifier is held.
|
||||||
|
///
|
||||||
|
/// Each step is applied to the **delta** the drag has accumulated — never
|
||||||
|
/// to the starting transform — so a selection that already sits between
|
||||||
|
/// grid points keeps its starting offset.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct SnapSettings {
|
||||||
|
/// Translation grid in world units (default `0.25`).
|
||||||
|
pub distance: f32,
|
||||||
|
/// Rotation step in degrees (default `15`).
|
||||||
|
pub angle_deg: f32,
|
||||||
|
/// Scale step (default `0.1` — factors round to the nearest `0.1`).
|
||||||
|
pub scale: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SnapSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
distance: 0.25,
|
||||||
|
angle_deg: 15.0,
|
||||||
|
scale: 0.1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One in-progress gizmo drag.
|
||||||
|
///
|
||||||
|
/// Created by the viewport when the user clicks a handle, kept alive while
|
||||||
|
/// the button is held, and dropped on release. Each frame the viewport
|
||||||
|
/// calls [`apply_drag`] with the new pointer ray to compute the new
|
||||||
|
/// transform.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct GizmoDrag {
|
||||||
|
/// The handle the user grabbed.
|
||||||
|
pub handle: GizmoHandle,
|
||||||
|
/// The entity's transform when the drag started — never mutated; the
|
||||||
|
/// drag computes a delta from this and applies it fresh each frame.
|
||||||
|
pub start_transform: Transform,
|
||||||
|
/// The world-space point where the drag began. For an axis handle
|
||||||
|
/// this is the closest point on the axis to the click ray; for a
|
||||||
|
/// plane handle, the ray-plane intersection; for a circle handle,
|
||||||
|
/// the projection of the ray hit onto the rotation plane.
|
||||||
|
pub start_anchor: Vec3,
|
||||||
|
/// Handle-specific reference scalar set at drag start. For
|
||||||
|
/// [`GizmoHandle::ScaleUniform`] it is the world-space distance that
|
||||||
|
/// corresponds to one *factor* of change — the gizmo size — so a
|
||||||
|
/// drag away from the entity by that much grows the scale by ~1.0.
|
||||||
|
/// Unused (set to `1.0`) for every other handle.
|
||||||
|
pub reference: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Hit testing
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// Tries every handle the given mode exposes and returns the one closest
|
||||||
|
/// to `ray`, or `None` if none are within `pixel_tolerance_world` of any
|
||||||
|
/// handle. `gizmo_size` is the per-axis world length of the arrow / cube
|
||||||
|
/// handles; both inputs are computed by the viewport based on the
|
||||||
|
/// camera's distance to the gizmo origin (so the gizmo stays the same
|
||||||
|
/// pixel size at any zoom).
|
||||||
|
pub fn hit_test(
|
||||||
|
ray: &Ray,
|
||||||
|
transform: &Transform,
|
||||||
|
mode: GizmoMode,
|
||||||
|
gizmo_size: f32,
|
||||||
|
pixel_tolerance_world: f32,
|
||||||
|
) -> Option<GizmoHandle> {
|
||||||
|
let origin = transform.translation;
|
||||||
|
let mut best: Option<(f32, GizmoHandle)> = None;
|
||||||
|
let mut consider = |dist_sq: f32, handle: GizmoHandle| {
|
||||||
|
if dist_sq.is_finite() && dist_sq < pixel_tolerance_world * pixel_tolerance_world {
|
||||||
|
match best {
|
||||||
|
Some((b, _)) if b <= dist_sq => {}
|
||||||
|
_ => best = Some((dist_sq, handle)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match mode {
|
||||||
|
GizmoMode::Translate => {
|
||||||
|
for axis in Axis3::ALL {
|
||||||
|
let d = axis_segment_distance_sq(origin, axis.unit(), gizmo_size, ray);
|
||||||
|
consider(d, GizmoHandle::TranslateAxis(axis));
|
||||||
|
}
|
||||||
|
for plane in PlaneAxis::ALL {
|
||||||
|
if let Some(d) = plane_quad_distance_sq(origin, plane, gizmo_size, ray) {
|
||||||
|
consider(d, GizmoHandle::TranslatePlane(plane));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GizmoMode::Rotate => {
|
||||||
|
for axis in Axis3::ALL {
|
||||||
|
if let Some(d) = circle_distance_sq(origin, axis.unit(), gizmo_size, ray) {
|
||||||
|
consider(d, GizmoHandle::RotateAxis(axis));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GizmoMode::Scale => {
|
||||||
|
for axis in Axis3::ALL {
|
||||||
|
let d = axis_segment_distance_sq(origin, axis.unit(), gizmo_size, ray);
|
||||||
|
consider(d, GizmoHandle::ScaleAxis(axis));
|
||||||
|
}
|
||||||
|
// Uniform handle: the center cube.
|
||||||
|
let d = ray.distance_to_point(origin).powi(2);
|
||||||
|
consider(d, GizmoHandle::ScaleUniform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
best.map(|(_, h)| h)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Squared distance from `ray` to the segment from `origin + axis * inner`
|
||||||
|
/// to `origin + axis * length`, with the closest point clamped to the
|
||||||
|
/// segment. Used for axis arrows.
|
||||||
|
///
|
||||||
|
/// The leading `inner` offset (~20% of length) keeps the segment clear of
|
||||||
|
/// the central cube area, so a ray that pierces the gizmo's center is
|
||||||
|
/// claimed by the uniform / center handle rather than by every axis at
|
||||||
|
/// once.
|
||||||
|
fn axis_segment_distance_sq(origin: Vec3, axis: Vec3, length: f32, ray: &Ray) -> f32 {
|
||||||
|
let inner = length * 0.2;
|
||||||
|
let pt_on_axis = closest_point_on_line(origin, axis, ray);
|
||||||
|
let along = (pt_on_axis - origin).dot(axis).clamp(inner, length);
|
||||||
|
let clamped = origin + axis * along;
|
||||||
|
ray.distance_to_point(clamped).powi(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distance² from `ray` to a square plane quad at `origin` (size × size),
|
||||||
|
/// or `None` when the ray is parallel to the plane. Used for translate
|
||||||
|
/// plane handles.
|
||||||
|
fn plane_quad_distance_sq(origin: Vec3, plane: PlaneAxis, size: f32, ray: &Ray) -> Option<f32> {
|
||||||
|
let p = Plane::from_point_normal(origin, plane.normal());
|
||||||
|
let t = p.ray_intersection(ray)?;
|
||||||
|
let hit = ray.at(t);
|
||||||
|
let (a, b) = plane.axes();
|
||||||
|
// The plane quad spans roughly the *outer* part of the gizmo: from
|
||||||
|
// ~0.3*size to ~0.7*size on each axis, away from the central cube
|
||||||
|
// and clear of the axis arrows.
|
||||||
|
let inner = size * 0.3;
|
||||||
|
let outer = size * 0.7;
|
||||||
|
let da = (hit - origin).dot(a);
|
||||||
|
let db = (hit - origin).dot(b);
|
||||||
|
if da >= inner && da <= outer && db >= inner && db <= outer {
|
||||||
|
// Inside the quad — perfect hit, no distance penalty.
|
||||||
|
Some(0.0)
|
||||||
|
} else {
|
||||||
|
// Outside — penalize by distance from the nearest edge so handle
|
||||||
|
// priority degrades smoothly with miss distance.
|
||||||
|
let clamped = origin + a * da.clamp(inner, outer) + b * db.clamp(inner, outer);
|
||||||
|
Some(ray.distance_to_point(clamped).powi(2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distance² from `ray` to the circle of radius `r` lying in the plane
|
||||||
|
/// through `origin` with the given `axis` as normal, or `None` when the
|
||||||
|
/// ray is parallel to the plane. Used for rotate circles.
|
||||||
|
fn circle_distance_sq(origin: Vec3, axis: Vec3, r: f32, ray: &Ray) -> Option<f32> {
|
||||||
|
let p = Plane::from_point_normal(origin, axis);
|
||||||
|
let t = p.ray_intersection(ray)?;
|
||||||
|
let hit = ray.at(t);
|
||||||
|
// Project onto the plane and find the closest circle point.
|
||||||
|
let v = hit - origin;
|
||||||
|
let in_plane = v - axis * v.dot(axis);
|
||||||
|
let len = in_plane.length();
|
||||||
|
if len < 1e-6 {
|
||||||
|
// Right at the center — distance to circle is `r` itself.
|
||||||
|
return Some(r * r);
|
||||||
|
}
|
||||||
|
let on_circle = origin + in_plane * (r / len);
|
||||||
|
Some(ray.distance_to_point(on_circle).powi(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closest point on the line through `origin` along the unit `dir`
|
||||||
|
/// vector to `ray`. Result is unconstrained — clamping to a segment is
|
||||||
|
/// the caller's job.
|
||||||
|
pub fn closest_point_on_line(origin: Vec3, dir: Vec3, ray: &Ray) -> Vec3 {
|
||||||
|
let r = ray.direction;
|
||||||
|
let w = origin - ray.origin;
|
||||||
|
let d = dir.dot(r);
|
||||||
|
let denom = 1.0 - d * d;
|
||||||
|
if denom.abs() < 1e-6 {
|
||||||
|
// Ray parallel to line — closest point on the line is the origin.
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
let s = (dir.dot(-w) - r.dot(-w) * d) / denom;
|
||||||
|
origin + dir * s
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Drag application
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// Applies the in-progress `drag` to its starting transform using the
|
||||||
|
/// pointer's current ray, returning the new transform. Pure: same inputs
|
||||||
|
/// always yield the same output.
|
||||||
|
///
|
||||||
|
/// When `snap` is `Some`, the per-mode delta is rounded to the appropriate
|
||||||
|
/// step before being applied (so the snap modifier can be toggled mid-
|
||||||
|
/// drag and the result lines up to the grid regardless of how the user
|
||||||
|
/// got there).
|
||||||
|
pub fn apply_drag(drag: &GizmoDrag, current_ray: &Ray, snap: Option<&SnapSettings>) -> Transform {
|
||||||
|
match drag.handle {
|
||||||
|
GizmoHandle::TranslateAxis(axis) => translate_along_axis(drag, current_ray, axis, snap),
|
||||||
|
GizmoHandle::TranslatePlane(plane) => translate_in_plane(drag, current_ray, plane, snap),
|
||||||
|
GizmoHandle::RotateAxis(axis) => rotate_around_axis(drag, current_ray, axis, snap),
|
||||||
|
GizmoHandle::ScaleAxis(axis) => scale_along_axis(drag, current_ray, axis, snap),
|
||||||
|
GizmoHandle::ScaleUniform => scale_uniform(drag, current_ray, snap),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rounds `value` to the nearest integer multiple of `step`. Returns
|
||||||
|
/// `value` unchanged when `step` is non-positive.
|
||||||
|
pub fn snap_round(value: f32, step: f32) -> f32 {
|
||||||
|
if step <= 0.0 {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
(value / step).round() * step
|
||||||
|
}
|
||||||
|
|
||||||
|
fn translate_along_axis(
|
||||||
|
drag: &GizmoDrag,
|
||||||
|
current_ray: &Ray,
|
||||||
|
axis: Axis3,
|
||||||
|
snap: Option<&SnapSettings>,
|
||||||
|
) -> Transform {
|
||||||
|
let dir = axis.unit();
|
||||||
|
let now = closest_point_on_line(drag.start_transform.translation, dir, current_ray);
|
||||||
|
let mut delta = (now - drag.start_anchor).dot(dir);
|
||||||
|
if let Some(s) = snap {
|
||||||
|
delta = snap_round(delta, s.distance);
|
||||||
|
}
|
||||||
|
let mut t = drag.start_transform;
|
||||||
|
t.translation += dir * delta;
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
|
fn translate_in_plane(
|
||||||
|
drag: &GizmoDrag,
|
||||||
|
current_ray: &Ray,
|
||||||
|
plane: PlaneAxis,
|
||||||
|
snap: Option<&SnapSettings>,
|
||||||
|
) -> Transform {
|
||||||
|
let p = Plane::from_point_normal(drag.start_transform.translation, plane.normal());
|
||||||
|
let Some(t) = p.ray_intersection(current_ray) else {
|
||||||
|
return drag.start_transform;
|
||||||
|
};
|
||||||
|
let now = current_ray.at(t);
|
||||||
|
let (a, b) = plane.axes();
|
||||||
|
let mut da = (now - drag.start_anchor).dot(a);
|
||||||
|
let mut db = (now - drag.start_anchor).dot(b);
|
||||||
|
if let Some(s) = snap {
|
||||||
|
da = snap_round(da, s.distance);
|
||||||
|
db = snap_round(db, s.distance);
|
||||||
|
}
|
||||||
|
let mut out = drag.start_transform;
|
||||||
|
out.translation += a * da + b * db;
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate_around_axis(
|
||||||
|
drag: &GizmoDrag,
|
||||||
|
current_ray: &Ray,
|
||||||
|
axis: Axis3,
|
||||||
|
snap: Option<&SnapSettings>,
|
||||||
|
) -> Transform {
|
||||||
|
let axis_dir = axis.unit();
|
||||||
|
let origin = drag.start_transform.translation;
|
||||||
|
let plane = Plane::from_point_normal(origin, axis_dir);
|
||||||
|
let Some(t) = plane.ray_intersection(current_ray) else {
|
||||||
|
return drag.start_transform;
|
||||||
|
};
|
||||||
|
let now = current_ray.at(t);
|
||||||
|
// Vectors from origin to start / current points, both already lying
|
||||||
|
// in the rotation plane.
|
||||||
|
let from = (drag.start_anchor - origin).normalize_or_zero();
|
||||||
|
let to = (now - origin).normalize_or_zero();
|
||||||
|
if from.length_squared() < 1e-6 || to.length_squared() < 1e-6 {
|
||||||
|
return drag.start_transform;
|
||||||
|
}
|
||||||
|
// Signed angle around `axis_dir`.
|
||||||
|
let cross = from.cross(to);
|
||||||
|
let sin = cross.dot(axis_dir);
|
||||||
|
let cos = from.dot(to).clamp(-1.0, 1.0);
|
||||||
|
let mut angle = sin.atan2(cos);
|
||||||
|
if let Some(s) = snap {
|
||||||
|
let step = s.angle_deg.to_radians();
|
||||||
|
angle = snap_round(angle, step);
|
||||||
|
}
|
||||||
|
let rotation = Quat::from_axis_angle(axis_dir, angle);
|
||||||
|
let mut out = drag.start_transform;
|
||||||
|
out.rotation = rotation * drag.start_transform.rotation;
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scale_along_axis(
|
||||||
|
drag: &GizmoDrag,
|
||||||
|
current_ray: &Ray,
|
||||||
|
axis: Axis3,
|
||||||
|
snap: Option<&SnapSettings>,
|
||||||
|
) -> Transform {
|
||||||
|
let dir = axis.unit();
|
||||||
|
let origin = drag.start_transform.translation;
|
||||||
|
let now = closest_point_on_line(origin, dir, current_ray);
|
||||||
|
let start_along = (drag.start_anchor - origin).dot(dir);
|
||||||
|
if start_along.abs() < 1e-4 {
|
||||||
|
return drag.start_transform;
|
||||||
|
}
|
||||||
|
let now_along = (now - origin).dot(dir);
|
||||||
|
let mut factor = now_along / start_along;
|
||||||
|
if let Some(s) = snap {
|
||||||
|
factor = snap_round(factor, s.scale);
|
||||||
|
}
|
||||||
|
// Clamp to a small positive floor so a runaway drag can't flip scale
|
||||||
|
// to zero / negative (which crashes inverse-transform math elsewhere).
|
||||||
|
factor = factor.max(0.001);
|
||||||
|
let mut out = drag.start_transform;
|
||||||
|
let mut s = drag.start_transform.scale.to_array();
|
||||||
|
s[axis.index()] *= factor;
|
||||||
|
out.scale = Vec3::from_array(s);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scale_uniform(drag: &GizmoDrag, current_ray: &Ray, snap: Option<&SnapSettings>) -> Transform {
|
||||||
|
let origin = drag.start_transform.translation;
|
||||||
|
// Perpendicular distance from the current ray to the entity, in world
|
||||||
|
// units. The *delta* from the click's perpendicular distance, divided
|
||||||
|
// by `drag.reference` (the gizmo size), is the additive change in
|
||||||
|
// scale factor. Avoids the previous `now_dist / start_dist` formula's
|
||||||
|
// blow-up when the click landed near the gizmo center (start_dist
|
||||||
|
// ≈ 0) and the divide spiked the factor.
|
||||||
|
let start_perp = (drag.start_anchor - origin).length();
|
||||||
|
let now_perp = current_ray.distance_to_point(origin);
|
||||||
|
let reference = drag.reference.max(1e-4);
|
||||||
|
let mut factor = 1.0 + (now_perp - start_perp) / reference;
|
||||||
|
if let Some(s) = snap {
|
||||||
|
factor = snap_round(factor, s.scale);
|
||||||
|
}
|
||||||
|
// Floor at a small positive value so a runaway drag past the origin
|
||||||
|
// can't flip scale negative (which crashes inverse-transform math).
|
||||||
|
factor = factor.max(0.001);
|
||||||
|
let mut out = drag.start_transform;
|
||||||
|
out.scale = drag.start_transform.scale * factor;
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use oxide_engine::math::Vec3;
|
||||||
|
use std::f32::consts::FRAC_PI_2;
|
||||||
|
|
||||||
|
fn id_transform_at(p: Vec3) -> Transform {
|
||||||
|
Transform {
|
||||||
|
translation: p,
|
||||||
|
rotation: Quat::IDENTITY,
|
||||||
|
scale: Vec3::ONE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hit testing ----------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hit_test_picks_translate_axis_under_cursor() {
|
||||||
|
// Camera looking straight down -Z at origin.
|
||||||
|
let ray = Ray::new(Vec3::new(0.5, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||||
|
let hit = hit_test(
|
||||||
|
&ray,
|
||||||
|
&id_transform_at(Vec3::ZERO),
|
||||||
|
GizmoMode::Translate,
|
||||||
|
1.0,
|
||||||
|
0.1,
|
||||||
|
);
|
||||||
|
assert_eq!(hit, Some(GizmoHandle::TranslateAxis(Axis3::X)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hit_test_picks_translate_plane_inside_quad() {
|
||||||
|
let ray = Ray::new(Vec3::new(0.5, 0.5, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||||
|
let hit = hit_test(
|
||||||
|
&ray,
|
||||||
|
&id_transform_at(Vec3::ZERO),
|
||||||
|
GizmoMode::Translate,
|
||||||
|
1.0,
|
||||||
|
0.1,
|
||||||
|
);
|
||||||
|
assert_eq!(hit, Some(GizmoHandle::TranslatePlane(PlaneAxis::XY)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hit_test_picks_rotate_circle_on_radius() {
|
||||||
|
// Camera looking down +X, so the rotate-X circle is in YZ plane.
|
||||||
|
// Aim at a point on that circle of radius 1.
|
||||||
|
let ray = Ray::new(Vec3::new(5.0, 1.0, 0.0), Vec3::new(-1.0, 0.0, 0.0));
|
||||||
|
let hit = hit_test(
|
||||||
|
&ray,
|
||||||
|
&id_transform_at(Vec3::ZERO),
|
||||||
|
GizmoMode::Rotate,
|
||||||
|
1.0,
|
||||||
|
0.1,
|
||||||
|
);
|
||||||
|
assert_eq!(hit, Some(GizmoHandle::RotateAxis(Axis3::X)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hit_test_misses_when_ray_far_from_handles() {
|
||||||
|
let ray = Ray::new(Vec3::new(50.0, 50.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||||
|
let hit = hit_test(
|
||||||
|
&ray,
|
||||||
|
&id_transform_at(Vec3::ZERO),
|
||||||
|
GizmoMode::Translate,
|
||||||
|
1.0,
|
||||||
|
0.1,
|
||||||
|
);
|
||||||
|
assert!(hit.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hit_test_picks_scale_uniform_at_center() {
|
||||||
|
let ray = Ray::new(Vec3::ZERO + Vec3::Z * 5.0, -Vec3::Z);
|
||||||
|
let hit = hit_test(
|
||||||
|
&ray,
|
||||||
|
&id_transform_at(Vec3::ZERO),
|
||||||
|
GizmoMode::Scale,
|
||||||
|
1.0,
|
||||||
|
0.1,
|
||||||
|
);
|
||||||
|
assert_eq!(hit, Some(GizmoHandle::ScaleUniform));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Translate drag -------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn translate_axis_drag_moves_along_axis_only() {
|
||||||
|
let start = id_transform_at(Vec3::ZERO);
|
||||||
|
let drag = GizmoDrag {
|
||||||
|
handle: GizmoHandle::TranslateAxis(Axis3::X),
|
||||||
|
start_transform: start,
|
||||||
|
start_anchor: Vec3::ZERO,
|
||||||
|
reference: 1.0,
|
||||||
|
};
|
||||||
|
// Ray that closest-approaches X at x = 3.
|
||||||
|
let cur = Ray::new(Vec3::new(3.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||||
|
let out = apply_drag(&drag, &cur, None);
|
||||||
|
assert!((out.translation.x - 3.0).abs() < 1e-4);
|
||||||
|
assert!(out.translation.y.abs() < 1e-4);
|
||||||
|
assert!(out.translation.z.abs() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn translate_axis_snap_rounds_to_distance_step() {
|
||||||
|
let start = id_transform_at(Vec3::ZERO);
|
||||||
|
let drag = GizmoDrag {
|
||||||
|
handle: GizmoHandle::TranslateAxis(Axis3::X),
|
||||||
|
start_transform: start,
|
||||||
|
start_anchor: Vec3::ZERO,
|
||||||
|
reference: 1.0,
|
||||||
|
};
|
||||||
|
let cur = Ray::new(Vec3::new(0.74, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||||
|
let snap = SnapSettings {
|
||||||
|
distance: 0.25,
|
||||||
|
..SnapSettings::default()
|
||||||
|
};
|
||||||
|
let out = apply_drag(&drag, &cur, Some(&snap));
|
||||||
|
// 0.74 rounds to 0.75.
|
||||||
|
assert!((out.translation.x - 0.75).abs() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn translate_plane_drag_moves_in_both_axes() {
|
||||||
|
let start = id_transform_at(Vec3::ZERO);
|
||||||
|
let drag = GizmoDrag {
|
||||||
|
handle: GizmoHandle::TranslatePlane(PlaneAxis::XY),
|
||||||
|
start_transform: start,
|
||||||
|
start_anchor: Vec3::ZERO,
|
||||||
|
reference: 1.0,
|
||||||
|
};
|
||||||
|
let cur = Ray::new(Vec3::new(2.0, 3.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||||
|
let out = apply_drag(&drag, &cur, None);
|
||||||
|
assert!((out.translation.x - 2.0).abs() < 1e-4);
|
||||||
|
assert!((out.translation.y - 3.0).abs() < 1e-4);
|
||||||
|
assert!(out.translation.z.abs() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rotate drag ----------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rotate_around_x_axis_produces_quarter_turn() {
|
||||||
|
let start = id_transform_at(Vec3::ZERO);
|
||||||
|
// Click at the +Y point on the YZ circle.
|
||||||
|
let drag = GizmoDrag {
|
||||||
|
handle: GizmoHandle::RotateAxis(Axis3::X),
|
||||||
|
start_transform: start,
|
||||||
|
start_anchor: Vec3::new(0.0, 1.0, 0.0),
|
||||||
|
reference: 1.0,
|
||||||
|
};
|
||||||
|
// Drag to the +Z point — 90° around +X (right-hand rule from +Y → +Z).
|
||||||
|
let cur = Ray::new(Vec3::new(5.0, 0.0, 1.0), Vec3::new(-1.0, 0.0, 0.0));
|
||||||
|
let out = apply_drag(&drag, &cur, None);
|
||||||
|
// Apply the rotation to Y and confirm it lands on Z.
|
||||||
|
let rotated = out.rotation * Vec3::Y;
|
||||||
|
assert!((rotated - Vec3::Z).length() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rotate_snap_rounds_to_angle_step() {
|
||||||
|
let start = id_transform_at(Vec3::ZERO);
|
||||||
|
let drag = GizmoDrag {
|
||||||
|
handle: GizmoHandle::RotateAxis(Axis3::X),
|
||||||
|
start_transform: start,
|
||||||
|
start_anchor: Vec3::new(0.0, 1.0, 0.0),
|
||||||
|
reference: 1.0,
|
||||||
|
};
|
||||||
|
// Drag to ~89°: should snap to 90° with a 15° step.
|
||||||
|
let cur = Ray::new(Vec3::new(5.0, 0.0175, 0.9998), Vec3::new(-1.0, 0.0, 0.0));
|
||||||
|
let snap = SnapSettings {
|
||||||
|
angle_deg: 15.0,
|
||||||
|
..SnapSettings::default()
|
||||||
|
};
|
||||||
|
let out = apply_drag(&drag, &cur, Some(&snap));
|
||||||
|
let rotated = out.rotation * Vec3::Y;
|
||||||
|
// A 90° rotation around X maps Y → Z exactly.
|
||||||
|
assert!(
|
||||||
|
(rotated - Vec3::Z).length() < 1e-3,
|
||||||
|
"expected snap to 90°, got {rotated:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rotate_no_movement_returns_start_transform() {
|
||||||
|
let start = id_transform_at(Vec3::ZERO);
|
||||||
|
let drag = GizmoDrag {
|
||||||
|
handle: GizmoHandle::RotateAxis(Axis3::Y),
|
||||||
|
start_transform: start,
|
||||||
|
start_anchor: Vec3::new(1.0, 0.0, 0.0),
|
||||||
|
reference: 1.0,
|
||||||
|
};
|
||||||
|
// Ray pointing back at the anchor (no rotation).
|
||||||
|
let cur = Ray::new(Vec3::new(5.0, 0.0, 0.0), Vec3::new(-1.0, 0.0, 0.0));
|
||||||
|
let out = apply_drag(&drag, &cur, None);
|
||||||
|
// Quaternion should be ~identity.
|
||||||
|
let rotated = out.rotation * Vec3::Z;
|
||||||
|
assert!((rotated - Vec3::Z).length() < 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scale drag -----------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scale_axis_doubles_when_pointer_moves_to_2x_anchor() {
|
||||||
|
let start = id_transform_at(Vec3::ZERO);
|
||||||
|
let drag = GizmoDrag {
|
||||||
|
handle: GizmoHandle::ScaleAxis(Axis3::X),
|
||||||
|
start_transform: start,
|
||||||
|
start_anchor: Vec3::new(1.0, 0.0, 0.0),
|
||||||
|
reference: 1.0,
|
||||||
|
};
|
||||||
|
let cur = Ray::new(Vec3::new(2.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||||
|
let out = apply_drag(&drag, &cur, None);
|
||||||
|
assert!((out.scale.x - 2.0).abs() < 1e-4);
|
||||||
|
assert!((out.scale.y - 1.0).abs() < 1e-4);
|
||||||
|
assert!((out.scale.z - 1.0).abs() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scale_axis_floors_at_small_positive_value() {
|
||||||
|
let start = id_transform_at(Vec3::ZERO);
|
||||||
|
let drag = GizmoDrag {
|
||||||
|
handle: GizmoHandle::ScaleAxis(Axis3::Y),
|
||||||
|
start_transform: start,
|
||||||
|
start_anchor: Vec3::new(0.0, 1.0, 0.0),
|
||||||
|
reference: 1.0,
|
||||||
|
};
|
||||||
|
// Drag well past the origin — would naively give factor = -3.
|
||||||
|
let cur = Ray::new(Vec3::new(0.0, -3.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||||
|
let out = apply_drag(&drag, &cur, None);
|
||||||
|
// Clamped to a small positive floor — never negative.
|
||||||
|
assert!(out.scale.y > 0.0);
|
||||||
|
assert!(out.scale.y < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scale_uniform_doubles_along_every_axis() {
|
||||||
|
let mut start = id_transform_at(Vec3::ZERO);
|
||||||
|
start.scale = Vec3::new(1.0, 2.0, 3.0);
|
||||||
|
// With reference = 1.0, dragging the perpendicular distance from
|
||||||
|
// 1.0 (the start anchor) to 2.0 grows the factor by exactly 1.0.
|
||||||
|
let drag = GizmoDrag {
|
||||||
|
handle: GizmoHandle::ScaleUniform,
|
||||||
|
start_transform: start,
|
||||||
|
start_anchor: Vec3::new(1.0, 0.0, 0.0),
|
||||||
|
reference: 1.0,
|
||||||
|
};
|
||||||
|
let cur = Ray::new(Vec3::new(2.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||||
|
let out = apply_drag(&drag, &cur, None);
|
||||||
|
assert!((out.scale.x - 2.0).abs() < 1e-4);
|
||||||
|
assert!((out.scale.y - 4.0).abs() < 1e-4);
|
||||||
|
assert!((out.scale.z - 6.0).abs() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scale_uniform_is_not_supersensitive_when_click_lands_near_center() {
|
||||||
|
// The old `now_dist / start_dist` formula blew up when a click
|
||||||
|
// landed near the gizmo center (start_dist ≈ 0). The new formula
|
||||||
|
// is additive in the perpendicular delta, so a tiny start_dist
|
||||||
|
// does not amplify the factor.
|
||||||
|
let start = id_transform_at(Vec3::ZERO);
|
||||||
|
let drag = GizmoDrag {
|
||||||
|
handle: GizmoHandle::ScaleUniform,
|
||||||
|
start_transform: start,
|
||||||
|
// Click landed near the center (perp distance 0.05).
|
||||||
|
start_anchor: Vec3::new(0.05, 0.0, 0.0),
|
||||||
|
reference: 1.0,
|
||||||
|
};
|
||||||
|
// Drag the pointer to a new perp distance of 0.5 (so delta = 0.45).
|
||||||
|
let cur = Ray::new(Vec3::new(0.5, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||||
|
let out = apply_drag(&drag, &cur, None);
|
||||||
|
// factor = 1.0 + 0.45 / 1.0 = 1.45 — gentle. The old formula would
|
||||||
|
// give 0.5 / 0.05 = 10.0, which is what the maintainer reported.
|
||||||
|
assert!(
|
||||||
|
(out.scale.x - 1.45).abs() < 1e-3,
|
||||||
|
"expected gentle factor 1.45, got scale {:?}",
|
||||||
|
out.scale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scale_uniform_snap_rounds_factor() {
|
||||||
|
// Reported by the maintainer: uniform-scale snap did nothing. The
|
||||||
|
// old formula's runaway factor swamped the snap step; the new
|
||||||
|
// additive formula puts the factor in a sane range so snap_round
|
||||||
|
// can hit a sensible step.
|
||||||
|
let start = id_transform_at(Vec3::ZERO);
|
||||||
|
let drag = GizmoDrag {
|
||||||
|
handle: GizmoHandle::ScaleUniform,
|
||||||
|
start_transform: start,
|
||||||
|
start_anchor: Vec3::new(0.5, 0.0, 0.0),
|
||||||
|
reference: 1.0,
|
||||||
|
};
|
||||||
|
// Pointer at perp distance ~1.32 → factor 1 + (1.32 - 0.5) = 1.82
|
||||||
|
// → snaps to 1.8 (step 0.1).
|
||||||
|
let cur = Ray::new(Vec3::new(1.32, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||||
|
let snap = SnapSettings {
|
||||||
|
scale: 0.1,
|
||||||
|
..SnapSettings::default()
|
||||||
|
};
|
||||||
|
let out = apply_drag(&drag, &cur, Some(&snap));
|
||||||
|
assert!(
|
||||||
|
(out.scale.x - 1.8).abs() < 1e-3,
|
||||||
|
"uniform-scale snap should round 1.82 to 1.8, got {:?}",
|
||||||
|
out.scale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scale_snap_rounds_factor_to_step() {
|
||||||
|
let start = id_transform_at(Vec3::ZERO);
|
||||||
|
let drag = GizmoDrag {
|
||||||
|
handle: GizmoHandle::ScaleAxis(Axis3::X),
|
||||||
|
start_transform: start,
|
||||||
|
start_anchor: Vec3::new(1.0, 0.0, 0.0),
|
||||||
|
reference: 1.0,
|
||||||
|
};
|
||||||
|
// Pointer at 1.83 → factor 1.83 → snaps to 1.8 (step 0.1).
|
||||||
|
let cur = Ray::new(Vec3::new(1.83, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||||
|
let snap = SnapSettings {
|
||||||
|
scale: 0.1,
|
||||||
|
..SnapSettings::default()
|
||||||
|
};
|
||||||
|
let out = apply_drag(&drag, &cur, Some(&snap));
|
||||||
|
assert!((out.scale.x - 1.8).abs() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers --------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closest_point_on_axis_recovers_perpendicular_drop() {
|
||||||
|
let ray = Ray::new(Vec3::new(3.0, 4.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||||
|
let pt = closest_point_on_line(Vec3::ZERO, Vec3::X, &ray);
|
||||||
|
// Drop a perpendicular onto the X axis — should land at (3, 0, 0).
|
||||||
|
assert!((pt - Vec3::new(3.0, 0.0, 0.0)).length() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closest_point_on_axis_handles_parallel_ray() {
|
||||||
|
// Ray along X overlaps the X axis exactly — returns the axis origin.
|
||||||
|
let ray = Ray::new(Vec3::new(0.0, 2.0, 0.0), Vec3::X);
|
||||||
|
let pt = closest_point_on_line(Vec3::ZERO, Vec3::X, &ray);
|
||||||
|
assert_eq!(pt, Vec3::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snap_round_to_step() {
|
||||||
|
assert_eq!(snap_round(0.74, 0.25), 0.75);
|
||||||
|
assert_eq!(snap_round(0.12, 0.25), 0.0);
|
||||||
|
assert_eq!(snap_round(-0.74, 0.25), -0.75);
|
||||||
|
// Zero / negative step disables snapping.
|
||||||
|
assert_eq!(snap_round(0.74, 0.0), 0.74);
|
||||||
|
assert_eq!(snap_round(0.74, -0.5), 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gizmo_handle_maps_to_mode() {
|
||||||
|
assert_eq!(
|
||||||
|
GizmoHandle::TranslateAxis(Axis3::X).mode(),
|
||||||
|
GizmoMode::Translate
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
GizmoHandle::TranslatePlane(PlaneAxis::XY).mode(),
|
||||||
|
GizmoMode::Translate
|
||||||
|
);
|
||||||
|
assert_eq!(GizmoHandle::RotateAxis(Axis3::Z).mode(), GizmoMode::Rotate);
|
||||||
|
assert_eq!(GizmoHandle::ScaleAxis(Axis3::Y).mode(), GizmoMode::Scale);
|
||||||
|
assert_eq!(GizmoHandle::ScaleUniform.mode(), GizmoMode::Scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn axis3_unit_and_index_align() {
|
||||||
|
for axis in Axis3::ALL {
|
||||||
|
let unit = axis.unit();
|
||||||
|
let idx = axis.index();
|
||||||
|
let mut expected = [0.0; 3];
|
||||||
|
expected[idx] = 1.0;
|
||||||
|
assert_eq!(unit.to_array(), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plane_axis_normal_is_orthogonal_to_its_axes() {
|
||||||
|
for plane in PlaneAxis::ALL {
|
||||||
|
let n = plane.normal();
|
||||||
|
let (a, b) = plane.axes();
|
||||||
|
assert!(n.dot(a).abs() < 1e-6);
|
||||||
|
assert!(n.dot(b).abs() < 1e-6);
|
||||||
|
// The two axes within the plane are also orthogonal.
|
||||||
|
assert!(a.dot(b).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rotation around X used FRAC_PI_2 indirectly via 90° axis drag.
|
||||||
|
// This second test just confirms a clean 90° around Y matches the
|
||||||
|
// expected matrix-applied direction.
|
||||||
|
#[test]
|
||||||
|
fn rotate_y_90_maps_x_to_minus_z() {
|
||||||
|
// Manually construct a 90° Y rotation and confirm orientation.
|
||||||
|
let q = Quat::from_axis_angle(Vec3::Y, FRAC_PI_2);
|
||||||
|
let v = q * Vec3::X;
|
||||||
|
assert!((v - Vec3::new(0.0, 0.0, -1.0)).length() < 1e-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//! Oxide Editor — framework library.
|
||||||
|
//!
|
||||||
|
//! The editor is built as a library of reusable, testable framework pieces plus
|
||||||
|
//! a thin binary (`src/main.rs`) that wires them into a window. Stage 6 grows
|
||||||
|
//! this library into the editor *framework*: an undo/redo command stack, a
|
||||||
|
//! project system, a settings/preferences framework, a module→editor extension
|
||||||
|
//! API, and the docking shell.
|
||||||
|
//!
|
||||||
|
//! Keeping the framework here (rather than in the binary) means each piece is
|
||||||
|
//! unit-tested in isolation, and the binary stays a small amount of glue.
|
||||||
|
|
||||||
|
#![deny(warnings)]
|
||||||
|
|
||||||
|
pub mod assets;
|
||||||
|
pub mod bindings;
|
||||||
|
pub mod command;
|
||||||
|
pub mod commands;
|
||||||
|
pub mod console;
|
||||||
|
pub mod explorer;
|
||||||
|
pub mod extension;
|
||||||
|
pub mod gizmo;
|
||||||
|
pub mod play;
|
||||||
|
pub mod preferences;
|
||||||
|
pub mod pty;
|
||||||
|
pub mod shell;
|
||||||
|
pub mod state;
|
||||||
|
pub mod terminal;
|
||||||
@@ -0,0 +1,720 @@
|
|||||||
|
//! Oxide Editor — entry point.
|
||||||
|
//!
|
||||||
|
//! The in-engine editor is built as a first-class part of the Oxide project.
|
||||||
|
//! It grows alongside the engine, gaining new panels and tools at each stage.
|
||||||
|
//!
|
||||||
|
//! Stage 6 wires the framework pieces (command stack, project system, settings
|
||||||
|
//! framework, extension API, file watcher) into a docking
|
||||||
|
//! [`Shell`](oxide_editor::shell::Shell). The shell hosts the hierarchy,
|
||||||
|
//! inspector, viewport, project browser, and console as resizable dockable
|
||||||
|
//! panels under a top menu bar + bottom status bar, with a Preferences window
|
||||||
|
//! driven by `Settings`. This binary is glue: window/event loop, the 3D
|
||||||
|
//! viewport renderer + camera, and the egui paint pump.
|
||||||
|
|
||||||
|
#![deny(warnings)]
|
||||||
|
|
||||||
|
mod egui_layer;
|
||||||
|
mod viewport;
|
||||||
|
|
||||||
|
use egui_layer::EguiLayer;
|
||||||
|
use oxide_editor::bindings::action;
|
||||||
|
use oxide_editor::commands::SetTransformCmd;
|
||||||
|
use oxide_editor::gizmo::{self, GizmoDrag, GizmoMode};
|
||||||
|
use oxide_editor::play::{self, Tick};
|
||||||
|
use oxide_editor::{preferences, shell::Shell};
|
||||||
|
use oxide_engine::app::{App, DefaultModules};
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::window::event::{
|
||||||
|
ElementState, KeyCode, ModifiersState, MouseButton, MouseScrollDelta, PhysicalKey, WindowEvent,
|
||||||
|
};
|
||||||
|
use oxide_engine::window::RenderCtx;
|
||||||
|
use viewport::{CameraMode, Viewport};
|
||||||
|
|
||||||
|
/// World-space length of the gizmo arrows / handles, scaled per-frame by
|
||||||
|
/// camera distance so the gizmo stays roughly the same pixel size at any
|
||||||
|
/// zoom level. The pure-logic gizmo math is agnostic to this scale — it
|
||||||
|
/// just takes whatever value the host passes.
|
||||||
|
const GIZMO_SCREEN_HEIGHT_FRACTION: f32 = 0.13;
|
||||||
|
|
||||||
|
/// Pixel-distance threshold for a gizmo handle to count as "hit" by a
|
||||||
|
/// click. Converted to world units per-frame using the camera distance so
|
||||||
|
/// the same screen tolerance applies at any zoom.
|
||||||
|
const GIZMO_HIT_PIXEL_TOLERANCE: f32 = 10.0;
|
||||||
|
|
||||||
|
/// Background color of the 3D viewport (dark neutral gray).
|
||||||
|
const VIEWPORT_CLEAR: Color = Color::rgb(0.08, 0.08, 0.10);
|
||||||
|
|
||||||
|
struct EditorApp {
|
||||||
|
shell: Shell,
|
||||||
|
egui_layer: Option<EguiLayer>,
|
||||||
|
viewport: Option<Viewport>,
|
||||||
|
/// The play-mode runtime (Stage 8.7). `Some` exactly while the editor is
|
||||||
|
/// playing or paused: built when Play starts (engine `App` + default
|
||||||
|
/// modules), ticked each frame, and dropped when Stop returns to editing.
|
||||||
|
/// The editor's scene is swapped into it for each tick and back out again,
|
||||||
|
/// so `shell.state.scene` stays the single source of truth between frames.
|
||||||
|
play_app: Option<App>,
|
||||||
|
modifiers: ModifiersState,
|
||||||
|
/// Last cursor position (physical px), for computing drag deltas.
|
||||||
|
last_cursor: Option<(f32, f32)>,
|
||||||
|
/// Left mouse held over the viewport — orbit (or pick on release).
|
||||||
|
orbiting: bool,
|
||||||
|
/// Right/middle mouse held over the viewport — pan (orbit mode) or
|
||||||
|
/// look around (flythrough mode); the camera-mode dispatch happens in
|
||||||
|
/// the cursor-moved handler.
|
||||||
|
panning: bool,
|
||||||
|
/// Accumulated cursor travel since the left press, to tell a click (select)
|
||||||
|
/// from a drag (orbit).
|
||||||
|
left_drag_dist: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EditorApp {
|
||||||
|
fn new() -> Self {
|
||||||
|
let mut shell = Shell::new();
|
||||||
|
// Defaults are registered by EditorState::new; layer any saved user
|
||||||
|
// remap from `~/.config/oxide/editor.ron` on top before the first
|
||||||
|
// input poll runs.
|
||||||
|
if let Some(saved) = preferences::load() {
|
||||||
|
shell.state.settings.import(&saved);
|
||||||
|
shell.state.apply_action_overrides_from_settings();
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
shell,
|
||||||
|
egui_layer: None,
|
||||||
|
viewport: None,
|
||||||
|
play_app: None,
|
||||||
|
modifiers: ModifiersState::empty(),
|
||||||
|
last_cursor: None,
|
||||||
|
orbiting: false,
|
||||||
|
panning: false,
|
||||||
|
left_drag_dist: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs the world-space cursor ray using the active viewport
|
||||||
|
/// camera + the Viewport tab's sub-rect. Returns `None` if the viewport
|
||||||
|
/// hasn't been initialized yet or the last cursor is unknown.
|
||||||
|
fn cursor_ray(&self, size: (u32, u32)) -> Option<oxide_engine::math::Ray> {
|
||||||
|
let cursor = self.last_cursor?;
|
||||||
|
let vp = self.viewport.as_ref()?;
|
||||||
|
Some(vp.ray_from_cursor(cursor, size, self.shell.viewport_rect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// World-space gizmo size that the maths uses for both rendering and
|
||||||
|
/// hit testing. Scaled by the camera's distance to the selection so the
|
||||||
|
/// gizmo keeps a stable pixel size at any zoom level.
|
||||||
|
fn gizmo_world_size(&self, target: oxide_engine::math::Vec3) -> f32 {
|
||||||
|
let Some(vp) = self.viewport.as_ref() else {
|
||||||
|
return 1.0;
|
||||||
|
};
|
||||||
|
let eye = match vp.mode {
|
||||||
|
CameraMode::Orbit => vp.orbit.view_transform().translation,
|
||||||
|
CameraMode::Flythrough => vp.flythrough.position,
|
||||||
|
};
|
||||||
|
let d = (target - eye).length().max(0.1);
|
||||||
|
d * GIZMO_SCREEN_HEIGHT_FRACTION
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries to start a gizmo drag at the cursor. Returns `true` if a
|
||||||
|
/// handle was hit (so the caller can skip orbit/look for this click).
|
||||||
|
fn try_begin_gizmo_drag(&mut self, size: (u32, u32)) -> bool {
|
||||||
|
let Some(selected) = self.shell.state.selected else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(transform) = self.shell.state.scene.world_transform(selected) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(ray) = self.cursor_ray(size) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let world_size = self.gizmo_world_size(transform.translation);
|
||||||
|
// Hit tolerance is a fixed pixel size; convert to world units the
|
||||||
|
// same way the gizmo size is scaled (the math is approximate but
|
||||||
|
// good enough for the few-pixel target zone).
|
||||||
|
let tolerance = world_size * (GIZMO_HIT_PIXEL_TOLERANCE / 100.0);
|
||||||
|
let mode = self.shell.state.gizmo.mode;
|
||||||
|
let Some(handle) = gizmo::hit_test(&ray, &transform, mode, world_size, tolerance) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// Compute the drag's start anchor — the point on the engaged
|
||||||
|
// handle the click corresponds to. Mirrors what `apply_drag`
|
||||||
|
// expects on subsequent frames.
|
||||||
|
let start_anchor = match handle {
|
||||||
|
gizmo::GizmoHandle::TranslateAxis(axis) | gizmo::GizmoHandle::ScaleAxis(axis) => {
|
||||||
|
gizmo::closest_point_on_line(transform.translation, axis.unit(), &ray)
|
||||||
|
}
|
||||||
|
gizmo::GizmoHandle::TranslatePlane(plane) => {
|
||||||
|
let p = oxide_engine::math::Plane::from_point_normal(
|
||||||
|
transform.translation,
|
||||||
|
plane.normal(),
|
||||||
|
);
|
||||||
|
p.ray_intersection(&ray)
|
||||||
|
.map(|t| ray.at(t))
|
||||||
|
.unwrap_or(transform.translation)
|
||||||
|
}
|
||||||
|
gizmo::GizmoHandle::RotateAxis(axis) => {
|
||||||
|
let p = oxide_engine::math::Plane::from_point_normal(
|
||||||
|
transform.translation,
|
||||||
|
axis.unit(),
|
||||||
|
);
|
||||||
|
p.ray_intersection(&ray)
|
||||||
|
.map(|t| ray.at(t))
|
||||||
|
.unwrap_or(transform.translation)
|
||||||
|
}
|
||||||
|
gizmo::GizmoHandle::ScaleUniform => ray.closest_point(transform.translation),
|
||||||
|
};
|
||||||
|
// The uniform-scale handle uses `reference` as the world distance
|
||||||
|
// corresponding to one factor of change — match it to the gizmo
|
||||||
|
// size so dragging by ~one arm's length doubles the scale.
|
||||||
|
let reference = if matches!(handle, gizmo::GizmoHandle::ScaleUniform) {
|
||||||
|
world_size
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
self.shell.state.gizmo.drag = Some(GizmoDrag {
|
||||||
|
handle,
|
||||||
|
start_transform: transform,
|
||||||
|
start_anchor,
|
||||||
|
reference,
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the in-progress drag against the current cursor position,
|
||||||
|
/// applying the new transform directly to the selected entity. The
|
||||||
|
/// command stack is only touched on release; intermediate frames just
|
||||||
|
/// mutate the scene so the gizmo follows the pointer fluidly.
|
||||||
|
fn advance_gizmo_drag(&mut self, size: (u32, u32), cursor: (f32, f32)) {
|
||||||
|
let Some(drag) = self.shell.state.gizmo.drag else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(selected) = self.shell.state.selected else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(vp) = self.viewport.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let ray = vp.ray_from_cursor(cursor, size, self.shell.viewport_rect());
|
||||||
|
// Ctrl-held → snap; the snap settings live on the editor state so
|
||||||
|
// a future preferences page can tune the steps.
|
||||||
|
let snap = self
|
||||||
|
.modifiers
|
||||||
|
.control_key()
|
||||||
|
.then_some(&self.shell.state.gizmo.snap);
|
||||||
|
let next = gizmo::apply_drag(&drag, &ray, snap);
|
||||||
|
// Drag math operates in world space (start_transform was the
|
||||||
|
// entity's *world* transform); for an entity with parents the
|
||||||
|
// computed `next` lives in world space too, so writing it as the
|
||||||
|
// local transform is only exact when the entity has no parent.
|
||||||
|
// Hierarchy-aware gizmo math is a refinement for a later piece.
|
||||||
|
self.shell.state.scene.set_local_transform(selected, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commits the in-progress drag (if any) by pushing a `SetTransformCmd`
|
||||||
|
/// onto the command stack and clearing the drag — making the whole
|
||||||
|
/// drag one undo entry.
|
||||||
|
fn end_gizmo_drag(&mut self) {
|
||||||
|
let Some(drag) = self.shell.state.gizmo.drag.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(selected) = self.shell.state.selected else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(after) = self.shell.state.scene.local_transform(selected) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Skip the command when nothing actually changed (the user clicked
|
||||||
|
// a handle but didn't drag).
|
||||||
|
let before = drag.start_transform;
|
||||||
|
if before == after {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.shell.push_command(SetTransformCmd {
|
||||||
|
entity: selected,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the per-frame [`ViewportOverlay`] the Shell's Viewport tab paints
|
||||||
|
/// every world-space overlay with (transform gizmo, collider wireframes, the
|
||||||
|
/// raycast probe). `None` only when the viewport isn't initialized yet — the
|
||||||
|
/// `view_proj` is always available, so colliders/probe show without a
|
||||||
|
/// selection; `gizmo_size` falls back to a unit when nothing is selected
|
||||||
|
/// (the gizmo itself isn't painted then, so the value is unused there).
|
||||||
|
fn build_gizmo_overlay(
|
||||||
|
&self,
|
||||||
|
size: (u32, u32),
|
||||||
|
rect: Option<oxide_engine::math::Rect>,
|
||||||
|
) -> Option<oxide_editor::shell::ViewportOverlay> {
|
||||||
|
let vp = self.viewport.as_ref()?;
|
||||||
|
let gizmo_size = self
|
||||||
|
.shell
|
||||||
|
.state
|
||||||
|
.selected
|
||||||
|
.and_then(|e| self.shell.state.scene.world_transform(e))
|
||||||
|
.map(|t| self.gizmo_world_size(t.translation))
|
||||||
|
.unwrap_or(1.0);
|
||||||
|
Some(oxide_editor::shell::ViewportOverlay {
|
||||||
|
view_proj: vp.view_projection_for(rect, size),
|
||||||
|
gizmo_size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Freezes** a raycast probe: casts the editor camera→cursor ray against
|
||||||
|
/// the *edited* scene's colliders right now and stores the result on the
|
||||||
|
/// Shell so the Viewport tab keeps drawing it in world space (Stage 9 piece
|
||||||
|
/// 8c). Because the ray is frozen into the world, orbiting the camera reveals
|
||||||
|
/// it as a real 3D line — a ray cast from the live camera is otherwise just a
|
||||||
|
/// point in that same camera's view. Builds a transient [`PhysicsWorld`] from
|
||||||
|
/// the scene via [`sync_to_scene`](oxide_physics::PhysicsWorld::sync_to_scene)
|
||||||
|
/// so the probe reflects unsaved edits without requiring Play. No-op if the
|
||||||
|
/// cursor or viewport is unavailable or the ray is degenerate.
|
||||||
|
fn cast_probe_ray(&mut self, size: (u32, u32)) {
|
||||||
|
use oxide_editor::shell::{RaycastProbeHit, RaycastProbeViz};
|
||||||
|
let rect = self.shell.viewport_rect();
|
||||||
|
let Some(cursor) = self.last_cursor else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(vp) = self.viewport.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let ray = vp.ray_from_cursor(cursor, size, rect);
|
||||||
|
if ray.direction == oxide_engine::math::Vec3::ZERO {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROBE_DISTANCE: f32 = 1000.0;
|
||||||
|
let mut world = oxide_physics::PhysicsWorld::new();
|
||||||
|
world.sync_to_scene(&self.shell.state.scene);
|
||||||
|
let hit = world.raycast(
|
||||||
|
ray.origin,
|
||||||
|
ray.direction,
|
||||||
|
PROBE_DISTANCE,
|
||||||
|
oxide_engine::layer::LayerMask::ALL,
|
||||||
|
);
|
||||||
|
// Surface a one-line result so the cast gives feedback even before the
|
||||||
|
// user orbits to look at the frozen ray.
|
||||||
|
match hit {
|
||||||
|
Some(h) => {
|
||||||
|
let name = self
|
||||||
|
.shell
|
||||||
|
.state
|
||||||
|
.scene
|
||||||
|
.name(h.entity)
|
||||||
|
.unwrap_or_else(|| "<entity>".to_string());
|
||||||
|
self.shell
|
||||||
|
.set_status_hint(format!("Raycast probe: hit {name}"));
|
||||||
|
}
|
||||||
|
None => self.shell.set_status_hint("Raycast probe: miss"),
|
||||||
|
}
|
||||||
|
self.shell.set_raycast_probe_viz(Some(RaycastProbeViz {
|
||||||
|
origin: ray.origin,
|
||||||
|
end: hit
|
||||||
|
.map(|h| h.point)
|
||||||
|
.unwrap_or_else(|| ray.at(PROBE_DISTANCE)),
|
||||||
|
hit: hit.map(|h| RaycastProbeHit {
|
||||||
|
point: h.point,
|
||||||
|
normal: h.normal,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the editor's preferences file to disk when the shell flagged
|
||||||
|
/// a binding edit since the last call. Logs (but does not panic on) I/O
|
||||||
|
/// errors — losing one save is recoverable; crashing the editor is not.
|
||||||
|
fn save_preferences_if_dirty(&mut self) {
|
||||||
|
if !self.shell.take_bindings_dirty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let snapshot = self.shell.state.settings.export();
|
||||||
|
if let Err(err) = preferences::save(&snapshot) {
|
||||||
|
log::warn!("failed to save editor preferences: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the play-mode runtime (Stage 8.7). Reconciles the play `App`'s
|
||||||
|
/// existence with the editor's [`PlayState`] (build it on Play, drop it on
|
||||||
|
/// Stop), then advances the simulation as far as
|
||||||
|
/// [`play::tick_for`](oxide_editor::play::tick_for) decides — swapping the
|
||||||
|
/// editor scene into the `App` for the tick and back out so the rest of the
|
||||||
|
/// editor keeps seeing `shell.state.scene`.
|
||||||
|
///
|
||||||
|
/// Called unconditionally each frame, before the cursor-gated editor input,
|
||||||
|
/// so play continues regardless of where the pointer is.
|
||||||
|
fn drive_play(&mut self, dt: f32) {
|
||||||
|
let in_play = self.shell.state.is_in_play();
|
||||||
|
// Build the runtime when play starts; tear it down when it stops. The
|
||||||
|
// engine `App` carries the default modules plus physics (Stage 9) and
|
||||||
|
// scripting (Stage 10); the project's own modules register here too in a
|
||||||
|
// later stage.
|
||||||
|
if in_play && self.play_app.is_none() {
|
||||||
|
let mut app = App::new();
|
||||||
|
// Share the editor's asset server so the play app resolves the same
|
||||||
|
// assets *and* the file watcher's in-place reloads (which target the
|
||||||
|
// editor server) reach a **playing** scene — live-reloading a script
|
||||||
|
// while the scene runs.
|
||||||
|
app.assets = self.shell.state.assets.clone();
|
||||||
|
app.add_modules(DefaultModules);
|
||||||
|
app.add_module(oxide_physics::PhysicsModule);
|
||||||
|
app.add_module(oxide_script::ScriptModule);
|
||||||
|
// Scripts resolve their `AssetRef<ScriptAsset>` through the project's
|
||||||
|
// asset database; hand the play app a snapshot so uids map to files.
|
||||||
|
if let Some(db) = &self.shell.state.asset_db {
|
||||||
|
app.insert_resource(db.clone());
|
||||||
|
}
|
||||||
|
self.play_app = Some(app);
|
||||||
|
} else if !in_play && self.play_app.is_some() {
|
||||||
|
self.play_app = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tick = play::tick_for(self.shell.state.play, self.shell.take_step_request());
|
||||||
|
if matches!(tick, Tick::Idle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(app) = self.play_app.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Run the engine schedule against the editor's live scene, then hand it
|
||||||
|
// back. `swap` is O(1) (two `Scene` moves), so the editor scene is only
|
||||||
|
// "inside" the App for the duration of the tick.
|
||||||
|
std::mem::swap(&mut self.shell.state.scene, &mut app.scene);
|
||||||
|
match tick {
|
||||||
|
Tick::Frame => app.update(dt),
|
||||||
|
Tick::FixedStep => app.step(),
|
||||||
|
Tick::Idle => {}
|
||||||
|
}
|
||||||
|
std::mem::swap(&mut self.shell.state.scene, &mut app.scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ray-picks the entity under the cursor and selects it (or clears the
|
||||||
|
/// selection if the ray misses everything).
|
||||||
|
fn pick_under_cursor(&mut self, size: (u32, u32)) {
|
||||||
|
let Some(cursor) = self.last_cursor else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let rect = self.shell.viewport_rect();
|
||||||
|
let picked = self
|
||||||
|
.viewport
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|vp| vp.pick(&self.shell.state.scene, cursor, size, rect));
|
||||||
|
self.shell.select(picked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowApp for EditorApp {
|
||||||
|
fn init(&mut self, ctx: &mut AppCtx<'_>) {
|
||||||
|
let (w, h) = ctx.size();
|
||||||
|
let device = ctx.render().gpu().device().clone();
|
||||||
|
let format = ctx.render().surface_format();
|
||||||
|
let layer = EguiLayer::new(ctx.window(), &device, format);
|
||||||
|
self.egui_layer = Some(layer);
|
||||||
|
self.viewport = Some(Viewport::new(&device, format));
|
||||||
|
log::info!(
|
||||||
|
"editor window open ({w}x{h}); docking shell active \
|
||||||
|
(L-drag: orbit · R-drag: pan · scroll: zoom · F: toggle flythrough · \
|
||||||
|
Ctrl+Z: undo · Ctrl+Q: quit)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
|
||||||
|
// Let egui handle the event first (text fields, clicks, scrolling).
|
||||||
|
// `consumed` is true when the pointer is over an egui widget, but
|
||||||
|
// the Viewport tab is technically an egui widget too — so egui would
|
||||||
|
// claim every click in the central area. Override that: if the cursor
|
||||||
|
// is over the Viewport tab's rect we treat the event as ours, so
|
||||||
|
// orbit/pan/zoom/pick work inside the dock.
|
||||||
|
let egui_consumed = self
|
||||||
|
.egui_layer
|
||||||
|
.as_mut()
|
||||||
|
.map(|layer| layer.on_window_event(ctx.window(), event))
|
||||||
|
.unwrap_or(false);
|
||||||
|
// A floating panel (egui Window) can overlap the viewport rect; when
|
||||||
|
// the pointer is over one, the click belongs to egui, not the 3D view —
|
||||||
|
// otherwise we'd drag the panel and orbit the camera at the same time.
|
||||||
|
let over_floating = self
|
||||||
|
.egui_layer
|
||||||
|
.as_ref()
|
||||||
|
.map(|layer| layer.pointer_over_floating())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let over_viewport = !over_floating
|
||||||
|
&& self
|
||||||
|
.last_cursor
|
||||||
|
.map(|c| self.shell.cursor_over_viewport(c))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let consumed = egui_consumed && !over_viewport;
|
||||||
|
|
||||||
|
match event {
|
||||||
|
WindowEvent::ModifiersChanged(modifiers) => {
|
||||||
|
self.modifiers = modifiers.state();
|
||||||
|
// If a gizmo drag is in flight, re-apply it with the new
|
||||||
|
// modifier state so toggling Ctrl mid-drag snaps (or
|
||||||
|
// unsnaps) the current position immediately — even when
|
||||||
|
// the mouse hasn't moved since.
|
||||||
|
if self.shell.state.gizmo.drag.is_some() {
|
||||||
|
if let Some(cursor) = self.last_cursor {
|
||||||
|
self.advance_gizmo_drag(ctx.size(), cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WindowEvent::KeyboardInput { event, .. } => {
|
||||||
|
if event.state != ElementState::Pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ctrl = self.modifiers.control_key();
|
||||||
|
let shift = self.modifiers.shift_key();
|
||||||
|
// Engine-global Ctrl+Q is handled here; everything else is
|
||||||
|
// delegated to the shell so the same shortcut routing is
|
||||||
|
// exercised by tests.
|
||||||
|
if ctrl && event.physical_key == PhysicalKey::Code(KeyCode::KeyQ) {
|
||||||
|
log::info!("Ctrl+Q — exiting editor");
|
||||||
|
ctx.request_exit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ctrl {
|
||||||
|
let ch = match event.physical_key {
|
||||||
|
PhysicalKey::Code(KeyCode::KeyZ) => Some('z'),
|
||||||
|
PhysicalKey::Code(KeyCode::KeyY) => Some('y'),
|
||||||
|
PhysicalKey::Code(KeyCode::KeyS) => Some('s'),
|
||||||
|
PhysicalKey::Code(KeyCode::Comma) => Some(','),
|
||||||
|
PhysicalKey::Code(KeyCode::KeyP) => Some('p'),
|
||||||
|
PhysicalKey::Code(KeyCode::Period) => Some('.'),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(ch) = ch {
|
||||||
|
self.shell.try_consume_shortcut(true, shift, Some(ch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WindowEvent::MouseInput { state, button, .. } => {
|
||||||
|
let pressed = *state == ElementState::Pressed;
|
||||||
|
match button {
|
||||||
|
MouseButton::Left => {
|
||||||
|
if pressed {
|
||||||
|
// Try a gizmo handle first — if the click hit
|
||||||
|
// one, we start a drag instead of orbiting.
|
||||||
|
let on_gizmo = !consumed && self.try_begin_gizmo_drag(ctx.size());
|
||||||
|
self.orbiting = !consumed && !on_gizmo;
|
||||||
|
self.left_drag_dist = 0.0;
|
||||||
|
} else {
|
||||||
|
// Release: commit the gizmo drag if any (one
|
||||||
|
// SetTransformCmd per drag = one undo entry).
|
||||||
|
if self.shell.state.gizmo.drag.is_some() {
|
||||||
|
self.end_gizmo_drag();
|
||||||
|
} else if self.orbiting && self.left_drag_dist < 4.0 {
|
||||||
|
// Click without drag → pick, and (if the raycast
|
||||||
|
// probe is on) freeze a debug ray into the world
|
||||||
|
// so it can be inspected by orbiting the camera.
|
||||||
|
self.pick_under_cursor(ctx.size());
|
||||||
|
if self.shell.raycast_probe_enabled() {
|
||||||
|
self.cast_probe_ray(ctx.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.orbiting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseButton::Right | MouseButton::Middle => self.panning = pressed && !consumed,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WindowEvent::CursorMoved { position, .. } => {
|
||||||
|
let pos = (position.x as f32, position.y as f32);
|
||||||
|
if let Some((lx, ly)) = self.last_cursor {
|
||||||
|
let (dx, dy) = (pos.0 - lx, pos.1 - ly);
|
||||||
|
if self.orbiting {
|
||||||
|
self.left_drag_dist += dx.abs() + dy.abs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a gizmo drag is in flight, route the move into the
|
||||||
|
// gizmo math and skip the camera controls entirely.
|
||||||
|
if self.shell.state.gizmo.drag.is_some() {
|
||||||
|
self.advance_gizmo_drag(ctx.size(), pos);
|
||||||
|
} else if let Some(vp) = self.viewport.as_mut() {
|
||||||
|
match vp.mode {
|
||||||
|
CameraMode::Orbit => {
|
||||||
|
if self.orbiting {
|
||||||
|
vp.orbit.orbit(dx, dy);
|
||||||
|
} else if self.panning {
|
||||||
|
vp.orbit.pan(dx, dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CameraMode::Flythrough => {
|
||||||
|
// In flythrough the existing right-drag gesture
|
||||||
|
// becomes mouse-look; left-drag is a no-op for
|
||||||
|
// the camera (click-without-drag still picks).
|
||||||
|
if self.panning {
|
||||||
|
vp.flythrough.look(dx, dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_cursor = Some(pos);
|
||||||
|
}
|
||||||
|
WindowEvent::MouseWheel { delta, .. } if !consumed => {
|
||||||
|
let amount = match delta {
|
||||||
|
MouseScrollDelta::LineDelta(_, y) => *y,
|
||||||
|
MouseScrollDelta::PixelDelta(p) => p.y as f32 / 40.0,
|
||||||
|
};
|
||||||
|
if let Some(vp) = self.viewport.as_mut() {
|
||||||
|
// Scroll has different roles per mode: zoom-in/out for the
|
||||||
|
// orbit subject, faster/slower travel for the flythrough.
|
||||||
|
match vp.mode {
|
||||||
|
CameraMode::Orbit => vp.orbit.zoom(amount),
|
||||||
|
CameraMode::Flythrough => vp.flythrough.adjust_move_speed(amount),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &mut AppCtx<'_>) {
|
||||||
|
// Drain the file watcher into AssetServer::reload_path and age out
|
||||||
|
// the status bar's last hint.
|
||||||
|
self.shell.frame_tick();
|
||||||
|
// Propagate File → Quit (the shell can't reach the runner directly).
|
||||||
|
if self.shell.take_quit_request() {
|
||||||
|
ctx.request_exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance the play-mode simulation (if any) every frame, before the
|
||||||
|
// cursor-gated editor input below — play must not depend on the pointer
|
||||||
|
// being over the viewport.
|
||||||
|
self.drive_play(ctx.dt);
|
||||||
|
|
||||||
|
// Bindings preferences page — when a capture is in progress, consume
|
||||||
|
// the next pressed key/button into the targeted slot. Runs before
|
||||||
|
// any other input poll so the captured press doesn't double-fire
|
||||||
|
// a normal action.
|
||||||
|
let input = ctx.input();
|
||||||
|
if self.shell.capture_active() {
|
||||||
|
self.shell.try_complete_capture(input);
|
||||||
|
// Flush to disk if the capture committed a binding.
|
||||||
|
self.save_preferences_if_dirty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editor input — polled per-frame from the Stage-7 InputState. Only
|
||||||
|
// fires when the cursor is over the Viewport tab so the same keys do
|
||||||
|
// not steal focus from a search box or text field elsewhere.
|
||||||
|
let over_floating = self
|
||||||
|
.egui_layer
|
||||||
|
.as_ref()
|
||||||
|
.map(|layer| layer.pointer_over_floating())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let cursor_over_vp = !over_floating
|
||||||
|
&& self
|
||||||
|
.last_cursor
|
||||||
|
.map(|c| self.shell.cursor_over_viewport(c))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !cursor_over_vp {
|
||||||
|
// Even off the viewport, a "Restore defaults" click from the
|
||||||
|
// preferences UI marks the bindings dirty — flush here.
|
||||||
|
self.save_preferences_if_dirty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let actions = &self.shell.state.actions;
|
||||||
|
if actions.action_pressed(action::TOGGLE_FLYTHROUGH, input) {
|
||||||
|
if let Some(vp) = self.viewport.as_mut() {
|
||||||
|
let new_mode = vp.toggle_camera_mode();
|
||||||
|
let label = match new_mode {
|
||||||
|
CameraMode::Orbit => "Camera: Orbit (L-drag orbit · R-drag pan · scroll zoom)",
|
||||||
|
CameraMode::Flythrough => {
|
||||||
|
"Camera: Flythrough (WASD/QE move · Shift sprint · R-drag look · scroll speed)"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
log::info!("{label}");
|
||||||
|
self.shell.set_status_hint(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(vp) = self.viewport.as_mut() {
|
||||||
|
if vp.mode == CameraMode::Flythrough {
|
||||||
|
let actions = &self.shell.state.actions;
|
||||||
|
let right = actions.axis(action::MOVE_RIGHT, input);
|
||||||
|
let forward = actions.axis(action::MOVE_FORWARD, input);
|
||||||
|
let up = actions.axis(action::MOVE_UP, input);
|
||||||
|
// The camera's translate_local takes (right, up, -forward),
|
||||||
|
// i.e. -Z is camera-forward, mirroring the OrbitCamera's
|
||||||
|
// looking_at convention.
|
||||||
|
let local = oxide_engine::math::Vec3::new(right, up, -forward);
|
||||||
|
let sprint = actions.action_held(action::SPRINT, input);
|
||||||
|
vp.flythrough.translate_local(local, ctx.dt, sprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gizmo tool hotkeys (W/E/R by default). Share keys with flythrough
|
||||||
|
// movement, so they only fire in orbit mode — in flythrough WASD
|
||||||
|
// moves the camera.
|
||||||
|
let orbit_mode = matches!(
|
||||||
|
self.viewport.as_ref().map(|v| v.mode),
|
||||||
|
Some(CameraMode::Orbit)
|
||||||
|
);
|
||||||
|
if orbit_mode {
|
||||||
|
let actions = &self.shell.state.actions;
|
||||||
|
let new_mode = if actions.action_pressed(action::GIZMO_TRANSLATE, input) {
|
||||||
|
Some(GizmoMode::Translate)
|
||||||
|
} else if actions.action_pressed(action::GIZMO_ROTATE, input) {
|
||||||
|
Some(GizmoMode::Rotate)
|
||||||
|
} else if actions.action_pressed(action::GIZMO_SCALE, input) {
|
||||||
|
Some(GizmoMode::Scale)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(mode) = new_mode {
|
||||||
|
self.shell.state.gizmo.mode = mode;
|
||||||
|
log::info!("Gizmo tool: {}", mode.label());
|
||||||
|
self.shell
|
||||||
|
.set_status_hint(format!("Gizmo: {}", mode.label()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save_preferences_if_dirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, ctx: &RenderCtx<'_>) {
|
||||||
|
// Draw the 3D scene first; egui then composites its panels on top
|
||||||
|
// (both record with `LoadOp::Load` over the engine's clear). Taken out
|
||||||
|
// and back so the immutable scene borrow doesn't clash with `&mut self`.
|
||||||
|
let rect = self.shell.viewport_rect();
|
||||||
|
if let Some(mut vp) = self.viewport.take() {
|
||||||
|
vp.render(&self.shell.state.scene, ctx, rect);
|
||||||
|
self.viewport = Some(vp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand the Shell the data its Viewport tab needs to paint the gizmo
|
||||||
|
// overlay using the same projection the scene was drawn with.
|
||||||
|
self.shell
|
||||||
|
.set_viewport_overlay(self.build_gizmo_overlay(ctx.size, rect));
|
||||||
|
|
||||||
|
let Some(mut layer) = self.egui_layer.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let shell = &mut self.shell;
|
||||||
|
layer.paint(
|
||||||
|
ctx.window,
|
||||||
|
ctx.gpu.device(),
|
||||||
|
ctx.gpu.queue(),
|
||||||
|
ctx.view,
|
||||||
|
ctx.size,
|
||||||
|
|ui| shell.build(ui),
|
||||||
|
);
|
||||||
|
self.egui_layer = Some(layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
// Install the capturing logger so script output/errors also reach the
|
||||||
|
// editor Console panel (still prints to the terminal, honours RUST_LOG).
|
||||||
|
oxide_editor::console::init();
|
||||||
|
log::info!("Oxide Editor starting…");
|
||||||
|
|
||||||
|
let config = WindowConfig {
|
||||||
|
title: "Oxide Editor".to_string(),
|
||||||
|
clear_color: VIEWPORT_CLEAR,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
run(config, EditorApp::new())
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
//! The play-mode tick decision (Stage 8.7).
|
||||||
|
//!
|
||||||
|
//! The host runner ([`oxide_editor::main`](crate)) owns the play [`App`] and the
|
||||||
|
//! window loop; this module isolates the one piece of that loop worth testing on
|
||||||
|
//! its own: **how far to advance the simulation this frame** given the current
|
||||||
|
//! [`PlayState`] and whether a single **Step** was requested.
|
||||||
|
//!
|
||||||
|
//! Keeping it a pure function pins the play-mode contract in a unit test instead
|
||||||
|
//! of burying it in the (un-testable) GUI runner:
|
||||||
|
//!
|
||||||
|
//! - [`Playing`](PlayState::Playing) → advance one real frame ([`Tick::Frame`]).
|
||||||
|
//! - [`Paused`](PlayState::Paused) + Step → advance exactly one fixed tick
|
||||||
|
//! ([`Tick::FixedStep`]); a stray Step while *Playing* is ignored (the frame
|
||||||
|
//! already advances).
|
||||||
|
//! - [`Editing`](PlayState::Editing), or Paused with no Step → do nothing
|
||||||
|
//! ([`Tick::Idle`]).
|
||||||
|
//!
|
||||||
|
//! [`App`]: oxide_engine::app::App
|
||||||
|
|
||||||
|
use crate::state::PlayState;
|
||||||
|
|
||||||
|
/// How the host runner should advance the play [`App`](oxide_engine::app::App)
|
||||||
|
/// this frame. The runner maps each variant onto an engine call: `Frame` →
|
||||||
|
/// [`App::update`](oxide_engine::app::App::update), `FixedStep` →
|
||||||
|
/// [`App::step`](oxide_engine::app::App::step), `Idle` → no call.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Tick {
|
||||||
|
/// Do not advance the simulation (editing, or paused with no step queued).
|
||||||
|
Idle,
|
||||||
|
/// Advance one normal frame by the real delta (playing).
|
||||||
|
Frame,
|
||||||
|
/// Advance exactly one fixed timestep (a single step while paused).
|
||||||
|
FixedStep,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decides how to advance the simulation this frame. `step_requested` is whether
|
||||||
|
/// the user asked for a single **Step** since the last frame; it is honoured
|
||||||
|
/// only while [`Paused`](PlayState::Paused). See the [module docs](self).
|
||||||
|
pub fn tick_for(play: PlayState, step_requested: bool) -> Tick {
|
||||||
|
match play {
|
||||||
|
PlayState::Playing => Tick::Frame,
|
||||||
|
PlayState::Paused if step_requested => Tick::FixedStep,
|
||||||
|
PlayState::Paused | PlayState::Editing => Tick::Idle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playing_advances_a_frame_regardless_of_step() {
|
||||||
|
assert_eq!(tick_for(PlayState::Playing, false), Tick::Frame);
|
||||||
|
// A stray step while playing is ignored — the frame already advances.
|
||||||
|
assert_eq!(tick_for(PlayState::Playing, true), Tick::Frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paused_steps_only_when_requested() {
|
||||||
|
assert_eq!(tick_for(PlayState::Paused, false), Tick::Idle);
|
||||||
|
assert_eq!(tick_for(PlayState::Paused, true), Tick::FixedStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editing_never_advances() {
|
||||||
|
assert_eq!(tick_for(PlayState::Editing, false), Tick::Idle);
|
||||||
|
assert_eq!(tick_for(PlayState::Editing, true), Tick::Idle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
//! Editor-wide preferences persistence on disk.
|
||||||
|
//!
|
||||||
|
//! The Stage-6 [`Settings`](oxide_engine::settings::Settings) framework
|
||||||
|
//! defines *what* is persisted (named sections, each owning a typed value).
|
||||||
|
//! This module defines *where* — the user-scoped file the editor reads on
|
||||||
|
//! startup and writes on every change, so a binding remap or theme tweak
|
||||||
|
//! survives a restart.
|
||||||
|
//!
|
||||||
|
//! # Location
|
||||||
|
//!
|
||||||
|
//! Linux: `$XDG_CONFIG_HOME/oxide/editor.ron`, falling back to
|
||||||
|
//! `$HOME/.config/oxide/editor.ron`. The directory is created on demand;
|
||||||
|
//! the path is the same one a Windows port would use once Stage-16 ships
|
||||||
|
//! game export (Windows resolution lands then, not here).
|
||||||
|
//!
|
||||||
|
//! # Format
|
||||||
|
//!
|
||||||
|
//! The file is exactly the RON map [`Settings::export`] produces:
|
||||||
|
//! `{ "section.name": "(field: value, …)", … }`. Each value is itself a
|
||||||
|
//! RON-encoded string of that section's typed value. Loading does no
|
||||||
|
//! schema validation — unknown sections are skipped by `Settings::import`,
|
||||||
|
//! so removing a section in code never breaks an old file.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::io;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Resolves the absolute path to the editor's preferences file, or `None`
|
||||||
|
/// if the OS provides no usable home / config directory (a stripped-down
|
||||||
|
/// container, an unusual launcher environment, …).
|
||||||
|
pub fn config_path() -> Option<PathBuf> {
|
||||||
|
resolve_config_path(|k| std::env::var_os(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolution rules, factored so tests can inject env state without racing
|
||||||
|
/// on the real process environment. Returns the first of:
|
||||||
|
///
|
||||||
|
/// 1. `$XDG_CONFIG_HOME/oxide/editor.ron`
|
||||||
|
/// 2. `$HOME/.config/oxide/editor.ron`
|
||||||
|
/// 3. `None` if neither is set.
|
||||||
|
fn resolve_config_path(env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||||
|
let base = env("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config")))?;
|
||||||
|
Some(base.join("oxide").join("editor.ron"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the preferences file, returning the same `BTreeMap` shape
|
||||||
|
/// [`Settings::import`](oxide_engine::settings::Settings::import) consumes.
|
||||||
|
///
|
||||||
|
/// Returns `None` when no file exists yet (a fresh install) or it can't be
|
||||||
|
/// parsed — both cases are silently treated as "no saved preferences" so
|
||||||
|
/// the editor falls back to the code-defined defaults. A returned `Some`
|
||||||
|
/// is the file's contents verbatim; the caller decides what to import.
|
||||||
|
pub fn load() -> Option<BTreeMap<String, String>> {
|
||||||
|
let path = config_path()?;
|
||||||
|
let text = std::fs::read_to_string(&path).ok()?;
|
||||||
|
ron::from_str(&text).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `map` to the preferences file, creating the parent directory if
|
||||||
|
/// necessary. The map is the output of
|
||||||
|
/// [`Settings::export`](oxide_engine::settings::Settings::export); the
|
||||||
|
/// editor calls this from the host runner whenever a binding edit or
|
||||||
|
/// other settings change flips a dirty flag.
|
||||||
|
pub fn save(map: &BTreeMap<String, String>) -> io::Result<()> {
|
||||||
|
let path = config_path().ok_or_else(|| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
|
"no $XDG_CONFIG_HOME or $HOME — cannot resolve editor preferences path",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let text = ron::ser::to_string_pretty(map, ron::ser::PrettyConfig::default())
|
||||||
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||||
|
std::fs::write(path, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A throw-away env stub built from a closure — keeps each test free of
|
||||||
|
/// process-global env mutation, so the suite can run in parallel.
|
||||||
|
fn env<'a>(map: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
|
||||||
|
move |k| {
|
||||||
|
map.iter()
|
||||||
|
.find(|(kk, _)| *kk == k)
|
||||||
|
.map(|(_, v)| OsString::from(*v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_path_uses_xdg_when_set() {
|
||||||
|
let p = resolve_config_path(env(&[("XDG_CONFIG_HOME", "/tmp/x")])).unwrap();
|
||||||
|
assert_eq!(p, PathBuf::from("/tmp/x/oxide/editor.ron"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_path_prefers_xdg_over_home_when_both_set() {
|
||||||
|
let p =
|
||||||
|
resolve_config_path(env(&[("XDG_CONFIG_HOME", "/tmp/x"), ("HOME", "/tmp/h")])).unwrap();
|
||||||
|
assert_eq!(p, PathBuf::from("/tmp/x/oxide/editor.ron"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_path_falls_back_to_home_dot_config() {
|
||||||
|
let p = resolve_config_path(env(&[("HOME", "/tmp/h")])).unwrap();
|
||||||
|
assert_eq!(p, PathBuf::from("/tmp/h/.config/oxide/editor.ron"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_path_is_none_when_no_env_available() {
|
||||||
|
let p = resolve_config_path(env(&[]));
|
||||||
|
assert!(p.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_then_load_round_trips_the_exported_map() {
|
||||||
|
// Direct file I/O test that doesn't go through config_path — write
|
||||||
|
// to a temp file with a known shape and confirm the RON round-trip
|
||||||
|
// matches what `Settings::export` produces.
|
||||||
|
let scratch = std::env::temp_dir().join(format!(
|
||||||
|
"oxide_editor_prefs_roundtrip_{}.ron",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_file(&scratch);
|
||||||
|
|
||||||
|
let mut map = BTreeMap::new();
|
||||||
|
map.insert(
|
||||||
|
"input.bindings".to_string(),
|
||||||
|
"(bindings: {\"Jump\": [Key(KeyW)]})".to_string(),
|
||||||
|
);
|
||||||
|
let text = ron::ser::to_string_pretty(&map, ron::ser::PrettyConfig::default()).unwrap();
|
||||||
|
std::fs::write(&scratch, &text).unwrap();
|
||||||
|
|
||||||
|
let read_back = std::fs::read_to_string(&scratch).unwrap();
|
||||||
|
let parsed: BTreeMap<String, String> = ron::from_str(&read_back).unwrap();
|
||||||
|
assert_eq!(parsed, map);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&scratch);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
//! A PTY-backed terminal: runs an interactive program (a shell, a REPL, an
|
||||||
|
//! AI-agent CLI like `claude`) inside the editor.
|
||||||
|
//!
|
||||||
|
//! This is the interactive counterpart to the command [console](crate::console).
|
||||||
|
//! The console pipes a one-shot command's output; a real terminal needs a
|
||||||
|
//! **pseudo-terminal**: programs detect a tty and switch to full-screen/TUI mode,
|
||||||
|
//! read raw keystrokes from stdin, and drive the screen with ANSI/VT escape
|
||||||
|
//! sequences. So this module:
|
||||||
|
//!
|
||||||
|
//! - opens a PTY with [`portable-pty`] (cross-platform — Linux now, Windows
|
||||||
|
//! later) and spawns the program attached to it;
|
||||||
|
//! - feeds the program's byte stream into a [`vt100`] parser on a reader thread,
|
||||||
|
//! which maintains the on-screen grid (cells, colours, cursor);
|
||||||
|
//! - exposes the grid for the egui panel to render, and [`send_input`] to write
|
||||||
|
//! keystrokes back to the program.
|
||||||
|
//!
|
||||||
|
//! [`send_input`]: PtyTerminal::send_input
|
||||||
|
//!
|
||||||
|
//! The two pure pieces — encoding an egui key press into the bytes a terminal
|
||||||
|
//! expects ([`encode_key`]) and mapping a [`vt100`] colour to an egui colour
|
||||||
|
//! ([`vt_color`]) — are unit-tested; the rendering/input loop itself is the
|
||||||
|
//! eye-checked part.
|
||||||
|
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use egui::{Key, Modifiers};
|
||||||
|
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize};
|
||||||
|
|
||||||
|
/// A live terminal session: the spawned child, its PTY, and the parsed screen.
|
||||||
|
pub struct PtyTerminal {
|
||||||
|
/// A short label for the session (e.g. the program name) shown on the tab.
|
||||||
|
pub title: String,
|
||||||
|
/// The parsed terminal screen, updated by the reader thread.
|
||||||
|
parser: Arc<Mutex<vt100::Parser>>,
|
||||||
|
/// The PTY master — kept for resizing.
|
||||||
|
master: Box<dyn MasterPty + Send>,
|
||||||
|
/// Writes keystrokes to the program (the PTY input side).
|
||||||
|
writer: Box<dyn Write + Send>,
|
||||||
|
/// The spawned child — killed on drop so closing the panel ends the program.
|
||||||
|
child: Box<dyn Child + Send + Sync>,
|
||||||
|
/// Current grid size, so we only resize on an actual change.
|
||||||
|
rows: u16,
|
||||||
|
cols: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PtyTerminal {
|
||||||
|
/// Spawns `program` (with `args`) attached to a fresh PTY of `rows`×`cols`,
|
||||||
|
/// running in `cwd`. `title` labels the session.
|
||||||
|
pub fn spawn(
|
||||||
|
title: impl Into<String>,
|
||||||
|
program: &str,
|
||||||
|
args: &[&str],
|
||||||
|
cwd: &std::path::Path,
|
||||||
|
rows: u16,
|
||||||
|
cols: u16,
|
||||||
|
) -> std::io::Result<Self> {
|
||||||
|
let pty_system = portable_pty::native_pty_system();
|
||||||
|
let pair = pty_system
|
||||||
|
.openpty(PtySize {
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
})
|
||||||
|
.map_err(to_io)?;
|
||||||
|
|
||||||
|
let mut cmd = CommandBuilder::new(program);
|
||||||
|
cmd.args(args);
|
||||||
|
cmd.cwd(cwd);
|
||||||
|
// Advertise a capable terminal so programs emit colour + use full-screen
|
||||||
|
// mode; without this many tools fall back to dumb output.
|
||||||
|
cmd.env("TERM", "xterm-256color");
|
||||||
|
|
||||||
|
let child = pair.slave.spawn_command(cmd).map_err(to_io)?;
|
||||||
|
// Drop the slave handle so the master sees EOF when the child exits.
|
||||||
|
drop(pair.slave);
|
||||||
|
|
||||||
|
let reader = pair.master.try_clone_reader().map_err(to_io)?;
|
||||||
|
let writer = pair.master.take_writer().map_err(to_io)?;
|
||||||
|
|
||||||
|
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
|
||||||
|
spawn_reader(reader, parser.clone());
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
title: title.into(),
|
||||||
|
parser,
|
||||||
|
master: pair.master,
|
||||||
|
writer,
|
||||||
|
child,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrows the parsed screen state for rendering (locks the parser).
|
||||||
|
pub fn with_screen<R>(&self, f: impl FnOnce(&vt100::Screen) -> R) -> R {
|
||||||
|
let parser = self.parser.lock().unwrap();
|
||||||
|
f(parser.screen())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current grid size in (rows, cols).
|
||||||
|
pub fn size(&self) -> (u16, u16) {
|
||||||
|
(self.rows, self.cols)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes raw bytes (already terminal-encoded) to the program's input.
|
||||||
|
pub fn send_input(&mut self, bytes: &[u8]) {
|
||||||
|
let _ = self.writer.write_all(bytes);
|
||||||
|
let _ = self.writer.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resizes the PTY and parser to `rows`×`cols` (no-op if unchanged). Programs
|
||||||
|
/// receive `SIGWINCH` and redraw to the new size.
|
||||||
|
pub fn resize(&mut self, rows: u16, cols: u16) {
|
||||||
|
if rows == 0 || cols == 0 || (rows == self.rows && cols == self.cols) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.rows = rows;
|
||||||
|
self.cols = cols;
|
||||||
|
let _ = self.master.resize(PtySize {
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
});
|
||||||
|
self.parser
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.screen_mut()
|
||||||
|
.set_size(rows, cols);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the child program has exited.
|
||||||
|
pub fn has_exited(&mut self) -> bool {
|
||||||
|
matches!(self.child.try_wait(), Ok(Some(_)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PtyTerminal {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// End the program when the panel/session goes away.
|
||||||
|
let _ = self.child.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the reader thread: pumps the PTY's output into the `vt100` parser until
|
||||||
|
/// EOF (the child exited / the master closed).
|
||||||
|
fn spawn_reader(mut reader: Box<dyn Read + Send>, parser: Arc<Mutex<vt100::Parser>>) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut buf = [0u8; 8192];
|
||||||
|
loop {
|
||||||
|
match reader.read(&mut buf) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => parser.lock().unwrap().process(&buf[..n]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adapts a `portable_pty` error into `std::io::Error`.
|
||||||
|
fn to_io(err: impl std::fmt::Display) -> std::io::Error {
|
||||||
|
std::io::Error::other(err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes an egui [`Key`] press (with modifiers) into the byte sequence a
|
||||||
|
/// terminal program expects on stdin, or `None` for keys we don't translate
|
||||||
|
/// (printable characters arrive separately as text input events).
|
||||||
|
///
|
||||||
|
/// Covers the control keys a TUI needs: Enter, Backspace, Tab, Esc, the arrows
|
||||||
|
/// and navigation keys (as ANSI CSI sequences), and `Ctrl`+letter (which maps to
|
||||||
|
/// control codes 0x01–0x1A — e.g. `Ctrl+C` → `0x03`).
|
||||||
|
pub fn encode_key(key: Key, mods: Modifiers) -> Option<Vec<u8>> {
|
||||||
|
// Ctrl + A..Z -> 0x01..0x1A (Ctrl+C = ETX = 0x03, etc.).
|
||||||
|
if mods.ctrl && !mods.alt {
|
||||||
|
if let Some(letter) = letter_index(key) {
|
||||||
|
return Some(vec![letter + 1]); // 'a' -> 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bytes: &[u8] = match key {
|
||||||
|
Key::Enter => b"\r",
|
||||||
|
Key::Backspace => b"\x7f",
|
||||||
|
Key::Tab => b"\t",
|
||||||
|
Key::Escape => b"\x1b",
|
||||||
|
Key::ArrowUp => b"\x1b[A",
|
||||||
|
Key::ArrowDown => b"\x1b[B",
|
||||||
|
Key::ArrowRight => b"\x1b[C",
|
||||||
|
Key::ArrowLeft => b"\x1b[D",
|
||||||
|
Key::Home => b"\x1b[H",
|
||||||
|
Key::End => b"\x1b[F",
|
||||||
|
Key::PageUp => b"\x1b[5~",
|
||||||
|
Key::PageDown => b"\x1b[6~",
|
||||||
|
Key::Delete => b"\x1b[3~",
|
||||||
|
Key::Insert => b"\x1b[2~",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(bytes.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 0-based index (`a`=0 … `z`=25) of an alphabetic [`Key`], else `None`.
|
||||||
|
/// Used to map `Ctrl`+letter to its control code.
|
||||||
|
fn letter_index(key: Key) -> Option<u8> {
|
||||||
|
let name = key.name(); // "A".."Z" for letter keys
|
||||||
|
let bytes = name.as_bytes();
|
||||||
|
if bytes.len() == 1 && bytes[0].is_ascii_uppercase() {
|
||||||
|
Some(bytes[0] - b'A')
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a [`vt100`] colour to an egui colour, given the default foreground to use
|
||||||
|
/// for [`vt100::Color::Default`].
|
||||||
|
pub fn vt_color(color: vt100::Color, default: egui::Color32) -> egui::Color32 {
|
||||||
|
match color {
|
||||||
|
vt100::Color::Default => default,
|
||||||
|
vt100::Color::Rgb(r, g, b) => egui::Color32::from_rgb(r, g, b),
|
||||||
|
vt100::Color::Idx(i) => ansi_indexed(i),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The RGB for an ANSI 256-colour palette index: the 16 base colours, the
|
||||||
|
/// 6×6×6 colour cube, and the 24-step grey ramp.
|
||||||
|
fn ansi_indexed(i: u8) -> egui::Color32 {
|
||||||
|
match i {
|
||||||
|
// Standard + bright 16-colour palette.
|
||||||
|
0 => egui::Color32::from_rgb(0x00, 0x00, 0x00),
|
||||||
|
1 => egui::Color32::from_rgb(0xCD, 0x00, 0x00),
|
||||||
|
2 => egui::Color32::from_rgb(0x00, 0xCD, 0x00),
|
||||||
|
3 => egui::Color32::from_rgb(0xCD, 0xCD, 0x00),
|
||||||
|
4 => egui::Color32::from_rgb(0x00, 0x00, 0xEE),
|
||||||
|
5 => egui::Color32::from_rgb(0xCD, 0x00, 0xCD),
|
||||||
|
6 => egui::Color32::from_rgb(0x00, 0xCD, 0xCD),
|
||||||
|
7 => egui::Color32::from_rgb(0xE5, 0xE5, 0xE5),
|
||||||
|
8 => egui::Color32::from_rgb(0x7F, 0x7F, 0x7F),
|
||||||
|
9 => egui::Color32::from_rgb(0xFF, 0x00, 0x00),
|
||||||
|
10 => egui::Color32::from_rgb(0x00, 0xFF, 0x00),
|
||||||
|
11 => egui::Color32::from_rgb(0xFF, 0xFF, 0x00),
|
||||||
|
12 => egui::Color32::from_rgb(0x5C, 0x5C, 0xFF),
|
||||||
|
13 => egui::Color32::from_rgb(0xFF, 0x00, 0xFF),
|
||||||
|
14 => egui::Color32::from_rgb(0x00, 0xFF, 0xFF),
|
||||||
|
15 => egui::Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
||||||
|
// 6×6×6 colour cube (indices 16..=231).
|
||||||
|
16..=231 => {
|
||||||
|
let i = i - 16;
|
||||||
|
let steps = [0u8, 95, 135, 175, 215, 255];
|
||||||
|
let r = steps[(i / 36) as usize];
|
||||||
|
let g = steps[((i / 6) % 6) as usize];
|
||||||
|
let b = steps[(i % 6) as usize];
|
||||||
|
egui::Color32::from_rgb(r, g, b)
|
||||||
|
}
|
||||||
|
// 24-step grey ramp (indices 232..=255).
|
||||||
|
_ => {
|
||||||
|
let level = 8 + (i - 232) * 10;
|
||||||
|
egui::Color32::from_gray(level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_c_encodes_to_etx() {
|
||||||
|
assert_eq!(encode_key(Key::C, Modifiers::CTRL), Some(vec![0x03]));
|
||||||
|
assert_eq!(encode_key(Key::A, Modifiers::CTRL), Some(vec![0x01]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn control_keys_encode_to_their_sequences() {
|
||||||
|
assert_eq!(
|
||||||
|
encode_key(Key::Enter, Modifiers::NONE),
|
||||||
|
Some(b"\r".to_vec())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encode_key(Key::Backspace, Modifiers::NONE),
|
||||||
|
Some(b"\x7f".to_vec())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encode_key(Key::ArrowUp, Modifiers::NONE),
|
||||||
|
Some(b"\x1b[A".to_vec())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_letters_are_not_encoded_here() {
|
||||||
|
// Printable text comes through egui text-input events, not key encoding.
|
||||||
|
assert_eq!(encode_key(Key::A, Modifiers::NONE), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vt_default_color_uses_the_supplied_default() {
|
||||||
|
let dflt = egui::Color32::from_rgb(1, 2, 3);
|
||||||
|
assert_eq!(vt_color(vt100::Color::Default, dflt), dflt);
|
||||||
|
assert_eq!(
|
||||||
|
vt_color(vt100::Color::Rgb(10, 20, 30), dflt),
|
||||||
|
egui::Color32::from_rgb(10, 20, 30)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ansi_cube_and_grey_indices_map_in_range() {
|
||||||
|
// Index 16 is the bottom of the cube = black; 231 is white.
|
||||||
|
assert_eq!(ansi_indexed(16), egui::Color32::from_rgb(0, 0, 0));
|
||||||
|
assert_eq!(ansi_indexed(231), egui::Color32::from_rgb(255, 255, 255));
|
||||||
|
// Greyscale ramp stays grey (r == g == b).
|
||||||
|
let g = ansi_indexed(240);
|
||||||
|
assert_eq!(g.r(), g.g());
|
||||||
|
assert_eq!(g.g(), g.b());
|
||||||
|
}
|
||||||
|
}
|
||||||
+6663
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,621 @@
|
|||||||
|
//! The editor's mutable runtime state.
|
||||||
|
//!
|
||||||
|
//! Split out from the shell so [commands](crate::commands) can mutate exactly
|
||||||
|
//! the data that participates in undo/redo without taking a borrow of the
|
||||||
|
//! whole shell (which also owns dock layout, dialog flags, and UI buffers).
|
||||||
|
//!
|
||||||
|
//! `EditorState` is the `C` parameter every editor `Command<C>` uses.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use oxide_engine::asset::{AssetDatabase, AssetServer};
|
||||||
|
use oxide_engine::input::{ActionMap, ActionOverrides};
|
||||||
|
use oxide_engine::layer::{GroupRegistry, LayerRegistry};
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::project::{Project, RecentProjects};
|
||||||
|
use oxide_engine::reflect::TypeRegistry;
|
||||||
|
use oxide_engine::settings::Settings;
|
||||||
|
|
||||||
|
use crate::bindings;
|
||||||
|
use crate::gizmo::{GizmoDrag, GizmoMode, SnapSettings};
|
||||||
|
|
||||||
|
/// The data the editor mutates over a session: the scene the user is editing,
|
||||||
|
/// the current selection, the asset server, the open project (if any), the
|
||||||
|
/// typed settings store, and the editor's input action bindings.
|
||||||
|
///
|
||||||
|
/// Held by the shell; commands operate on `&mut EditorState` so the change is
|
||||||
|
/// guaranteed to flow through the same pipeline whether the user clicks a
|
||||||
|
/// menu, drags a gizmo, or runs a script (Stage 10).
|
||||||
|
pub struct EditorState {
|
||||||
|
/// The scene currently open in the viewport / hierarchy.
|
||||||
|
pub scene: Scene,
|
||||||
|
/// The entity the inspector is bound to, if any.
|
||||||
|
pub selected: Option<Entity>,
|
||||||
|
/// The asset server shared by every loader (gltf, future texture/audio).
|
||||||
|
/// Cloneable [`Arc`-backed handle](oxide_engine::asset::AssetServer) — cheap
|
||||||
|
/// to hand to the file watcher.
|
||||||
|
pub assets: AssetServer,
|
||||||
|
/// The typed settings store. The shell registers core sections at startup
|
||||||
|
/// (including the [`SETTINGS_SECTION`](crate::bindings::SETTINGS_SECTION)
|
||||||
|
/// for [`actions`](Self::actions)) and modules add their own through the
|
||||||
|
/// [extension API](crate::extension).
|
||||||
|
pub settings: Settings,
|
||||||
|
/// The editor's input action bindings (camera, future gizmo hotkeys, …).
|
||||||
|
/// Default bindings are registered by
|
||||||
|
/// [`bindings::register_defaults`](crate::bindings::register_defaults);
|
||||||
|
/// the preferences UI reads / mutates this map directly, and the
|
||||||
|
/// [`ActionOverrides`](oxide_engine::input::ActionOverrides) settings
|
||||||
|
/// section stays in sync so a write-back through
|
||||||
|
/// [`Settings::export`](oxide_engine::settings::Settings::export)
|
||||||
|
/// captures the user's remap.
|
||||||
|
pub actions: ActionMap,
|
||||||
|
/// The open project, if any. `None` means the user is working in an
|
||||||
|
/// unsaved scratch scene (handy for quick tinkering before saving).
|
||||||
|
pub project: Option<Project>,
|
||||||
|
/// The open project's asset database — the bridge between stable asset
|
||||||
|
/// references (`AssetUid`/[`AssetRef<T>`](oxide_engine::asset::AssetRef)) and
|
||||||
|
/// files under `assets/`. `Some` exactly when a [`project`](Self::project)
|
||||||
|
/// is open; the shell scans it on open and rescans when the file watcher
|
||||||
|
/// reports asset changes. The asset browser lists from it and the inspector
|
||||||
|
/// asset-picker resolves through it.
|
||||||
|
pub asset_db: Option<AssetDatabase>,
|
||||||
|
/// The cross-session most-recently-used project list shown in the
|
||||||
|
/// `File / Open Recent` submenu.
|
||||||
|
pub recent: RecentProjects,
|
||||||
|
/// Transform-gizmo UI state: active tool (translate / rotate / scale),
|
||||||
|
/// snap settings, and the in-progress drag if any. The viewport reads
|
||||||
|
/// this each frame to paint handles and dispatch drags; the inspector
|
||||||
|
/// reads it to highlight the active axis. Default is
|
||||||
|
/// [`GizmoMode::Translate`] with the default [`SnapSettings`].
|
||||||
|
pub gizmo: GizmoState,
|
||||||
|
/// The reflection registry that lets the inspector edit any registered
|
||||||
|
/// component generically — list an entity's components, enumerate each
|
||||||
|
/// one's fields, and get/set a single field by name. Seeded with the
|
||||||
|
/// built-in reflected types (`Transform`, `Node`); modules add their own
|
||||||
|
/// through the extension API. This is what makes the inspector
|
||||||
|
/// reflection-driven instead of hand-coded per type.
|
||||||
|
pub registry: TypeRegistry,
|
||||||
|
/// Per-entity inspector order for **modular** components (the ones the
|
||||||
|
/// user adds and reorders). Entries persist across re-selection. Anything
|
||||||
|
/// currently on the entity that isn't in the map is appended in whatever
|
||||||
|
/// order the registry reports it, so components inserted outside the
|
||||||
|
/// inspector (e.g. by a script or `set_ron`) still show up.
|
||||||
|
///
|
||||||
|
/// *Node-baked* components — `Node`, `Transform`, `Layer` — render in a
|
||||||
|
/// fixed canonical order above this list and are not tracked here.
|
||||||
|
pub component_order: HashMap<Entity, Vec<&'static str>>,
|
||||||
|
/// Project-wide layer names (which single layer each entity's [`Layer`]
|
||||||
|
/// index means). Seeded with a small common set (`Default`, `UI`, `Player`,
|
||||||
|
/// `World`); later work persists this to the open project's settings so a
|
||||||
|
/// team can name layers like Unity's Layer Inspector. Layers are the
|
||||||
|
/// *single-valued* membership concept — one per entity.
|
||||||
|
pub layer_registry: LayerRegistry,
|
||||||
|
/// Project-wide gameplay group names — the *multi-valued* counterpart to
|
||||||
|
/// [`layer_registry`](Self::layer_registry). An entity is on one layer but
|
||||||
|
/// in any number of groups (stored in its
|
||||||
|
/// [`Tags`](oxide_engine::layer::Tags) component). The registry is the
|
||||||
|
/// project's fixed vocabulary, so the inspector offers groups to pick from
|
||||||
|
/// rather than free-typed strings. Empty until the user defines groups in
|
||||||
|
/// the Groups editor.
|
||||||
|
pub group_registry: GroupRegistry,
|
||||||
|
/// The UI document currently open in the **UI Canvas** panel, if any. The
|
||||||
|
/// canvas edits this `UiPanel`'s widget tree (via the
|
||||||
|
/// [`WidgetPath`](oxide_engine::ui::WidgetPath) authoring primitives) and
|
||||||
|
/// saves it as a `ui/` asset. `None` means the canvas shows its empty state.
|
||||||
|
pub ui_doc: Option<UiDoc>,
|
||||||
|
/// Named spawn templates backing the hierarchy's add-menu. Seeded with the
|
||||||
|
/// built-in prefabs (`Empty`, `Cube`, `Sphere`, `Plane`, `Camera`,
|
||||||
|
/// `Directional Light`); each spawns an entity already carrying the
|
||||||
|
/// matching components via the reflection [`registry`](Self::registry).
|
||||||
|
pub prefab_registry: PrefabRegistry,
|
||||||
|
/// Whether the editor is editing, playing, or paused (Stage 8.7). Drives
|
||||||
|
/// whether the host runner ticks the engine [`Schedule`] and gates the
|
||||||
|
/// play toolbar. Always [`PlayState::Editing`] at startup.
|
||||||
|
pub play: PlayState,
|
||||||
|
/// The scene as it was the instant **Play** was pressed, used to restore it
|
||||||
|
/// bit-for-bit on **Stop** so play-mode mutations never corrupt the authored
|
||||||
|
/// scene. `Some` exactly while [`play`](Self::play) is not
|
||||||
|
/// [`Editing`](PlayState::Editing). See [`enter_play`](Self::enter_play) /
|
||||||
|
/// [`stop`](Self::stop).
|
||||||
|
pub play_snapshot: Option<SceneSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the editor is authoring the scene or running it (Stage 8.7).
|
||||||
|
///
|
||||||
|
/// In [`Playing`](Self::Playing) the host runner ticks the engine
|
||||||
|
/// [`Schedule`](oxide_engine::app::Schedule) each frame; [`Paused`](Self::Paused)
|
||||||
|
/// freezes ticking but keeps the scene live so a single **Step** can advance one
|
||||||
|
/// fixed tick and the inspector can still edit fields; [`Editing`](Self::Editing)
|
||||||
|
/// is the normal authoring state where no systems run. Pressing **Play**
|
||||||
|
/// snapshots the scene and pressing **Stop** restores it (see
|
||||||
|
/// [`EditorState::enter_play`] / [`EditorState::stop`]).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum PlayState {
|
||||||
|
/// Authoring; the engine schedule is not ticked.
|
||||||
|
#[default]
|
||||||
|
Editing,
|
||||||
|
/// Running; the schedule is ticked every frame.
|
||||||
|
Playing,
|
||||||
|
/// Running but frozen; the schedule is ticked only one fixed step per Step.
|
||||||
|
Paused,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Editor-only transform-gizmo state held on [`EditorState`].
|
||||||
|
///
|
||||||
|
/// Decoupled from the gizmo *math* in [`crate::gizmo`] so the math stays
|
||||||
|
/// pure-logic (rays in, transforms out) and this struct carries only the
|
||||||
|
/// per-session UI choices.
|
||||||
|
pub struct GizmoState {
|
||||||
|
/// Which tool is active (toggle with W / E / R while the cursor is
|
||||||
|
/// over the Viewport tab and the camera is in orbit mode).
|
||||||
|
pub mode: GizmoMode,
|
||||||
|
/// The snap step sizes applied during a drag while the snap modifier
|
||||||
|
/// (Ctrl by default) is held.
|
||||||
|
pub snap: SnapSettings,
|
||||||
|
/// `Some` while the user is mid-drag on a handle; the runner
|
||||||
|
/// recomputes the target's transform each frame via
|
||||||
|
/// [`crate::gizmo::apply_drag`].
|
||||||
|
pub drag: Option<GizmoDrag>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GizmoState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: GizmoMode::Translate,
|
||||||
|
snap: SnapSettings::default(),
|
||||||
|
drag: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An open UI document in the editor's **UI Canvas**.
|
||||||
|
///
|
||||||
|
/// Holds the [`UiPanel`] being authored, the asset it loads from / saves to (if
|
||||||
|
/// it has been saved), the currently selected widget (by
|
||||||
|
/// [`WidgetPath`](oxide_engine::ui::WidgetPath)), and whether there are unsaved
|
||||||
|
/// edits. The same `UiPanel` RON the canvas writes is what the runtime loads.
|
||||||
|
pub struct UiDoc {
|
||||||
|
/// The panel (widget tree + pixel/world size) being edited.
|
||||||
|
pub panel: UiPanel,
|
||||||
|
/// The `ui/` asset this document is saved as, once saved.
|
||||||
|
pub asset: Option<AssetUid>,
|
||||||
|
/// The widget the property panel is bound to (root by default).
|
||||||
|
pub selected: WidgetPath,
|
||||||
|
/// Whether the document has edits not yet written to disk.
|
||||||
|
pub dirty: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiDoc {
|
||||||
|
/// A new, empty document: a single full-bleed column root at a 1280×720
|
||||||
|
/// authoring resolution. Not yet associated with an asset.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let root = Widget::column().with_id("root").with_style(UiLayoutStyle {
|
||||||
|
width: UiSizing::Grow(1.0),
|
||||||
|
height: UiSizing::Grow(1.0),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
panel: UiPanel::new(root, Vec2::new(1280.0, 720.0), Vec2::new(2.0, 1.125)),
|
||||||
|
asset: None,
|
||||||
|
selected: WidgetPath::root(),
|
||||||
|
dirty: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UiDoc {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The settings section holding [`ExternalEditorPrefs`].
|
||||||
|
pub const EXTERNAL_EDITOR_SECTION: &str = "editor.external_editor";
|
||||||
|
|
||||||
|
/// Preferences for opening a script (or other text asset) in an editor —
|
||||||
|
/// registered as the [`EXTERNAL_EDITOR_SECTION`] settings section, editable in
|
||||||
|
/// Preferences, persisted to `~/.config/oxide/editor.ron` like the bindings.
|
||||||
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct ExternalEditorPrefs {
|
||||||
|
/// Command to launch, invoked as `<command> <file>` (whitespace-split; may
|
||||||
|
/// carry its own flags, e.g. `"code -g"`). **Empty (the default) = auto**:
|
||||||
|
/// run `$VISUAL`/`$EDITOR` in an editor Terminal tab, else `xdg-open`.
|
||||||
|
pub command: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EditorState {
|
||||||
|
/// A blank state with an empty scene, no open project, and the editor's
|
||||||
|
/// default action bindings registered (`F` toggle, WASD/QE move, Shift
|
||||||
|
/// sprint — see [`bindings`](crate::bindings)).
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::with_scene(Scene::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`new`](Self::new) but starting from a populated scene — used by
|
||||||
|
/// the shell so the editor has something visible on launch.
|
||||||
|
pub fn with_scene(scene: Scene) -> Self {
|
||||||
|
let mut actions = ActionMap::new();
|
||||||
|
bindings::register_defaults(&mut actions);
|
||||||
|
let mut settings = Settings::new();
|
||||||
|
settings.register::<ActionOverrides>(bindings::SETTINGS_SECTION);
|
||||||
|
settings.register::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION);
|
||||||
|
let mut registry = TypeRegistry::new();
|
||||||
|
register_builtin_types(&mut registry);
|
||||||
|
// Seed a small, generally-useful set of named layers (besides the
|
||||||
|
// built-in "Default" at index 0). These are common filter slots, not
|
||||||
|
// generic "Layer 1 / Layer 2" filler; the user renames or extends them
|
||||||
|
// in the Layer Names editor.
|
||||||
|
let mut layer_registry = LayerRegistry::new();
|
||||||
|
layer_registry.set(1, "UI");
|
||||||
|
layer_registry.set(2, "Player");
|
||||||
|
layer_registry.set(3, "World");
|
||||||
|
Self {
|
||||||
|
scene,
|
||||||
|
selected: None,
|
||||||
|
assets: AssetServer::new(),
|
||||||
|
settings,
|
||||||
|
actions,
|
||||||
|
project: None,
|
||||||
|
asset_db: None,
|
||||||
|
recent: RecentProjects::new(8),
|
||||||
|
gizmo: GizmoState::default(),
|
||||||
|
registry,
|
||||||
|
component_order: HashMap::new(),
|
||||||
|
layer_registry,
|
||||||
|
group_registry: GroupRegistry::new(),
|
||||||
|
ui_doc: None,
|
||||||
|
prefab_registry: builtin_prefabs(),
|
||||||
|
play: PlayState::Editing,
|
||||||
|
play_snapshot: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the editor is currently running the scene
|
||||||
|
/// ([`Playing`](PlayState::Playing) or [`Paused`](PlayState::Paused)) — the
|
||||||
|
/// states in which the authored scene is "live" and will be restored on Stop.
|
||||||
|
pub fn is_in_play(&self) -> bool {
|
||||||
|
self.play != PlayState::Editing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enters **Play**: snapshots the current scene (so Stop can restore it) and
|
||||||
|
/// transitions to [`Playing`](PlayState::Playing). No-op if already playing
|
||||||
|
/// or paused — re-entering must not overwrite the original snapshot.
|
||||||
|
pub fn enter_play(&mut self) {
|
||||||
|
if self.is_in_play() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.play_snapshot = Some(self.scene.snapshot(&self.registry));
|
||||||
|
self.play = PlayState::Playing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles between [`Playing`](PlayState::Playing) and
|
||||||
|
/// [`Paused`](PlayState::Paused). No-op while [`Editing`](PlayState::Editing)
|
||||||
|
/// (there is nothing to pause).
|
||||||
|
pub fn toggle_pause(&mut self) {
|
||||||
|
self.play = match self.play {
|
||||||
|
PlayState::Playing => PlayState::Paused,
|
||||||
|
PlayState::Paused => PlayState::Playing,
|
||||||
|
PlayState::Editing => return,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops play and restores the scene to its pre-play snapshot bit-for-bit,
|
||||||
|
/// then returns to [`Editing`](PlayState::Editing). The restored scene has
|
||||||
|
/// fresh entity handles, so the selection and any in-flight gizmo drag are
|
||||||
|
/// cleared (the old [`Entity`] no longer exists). No-op while already
|
||||||
|
/// editing.
|
||||||
|
///
|
||||||
|
/// A failed restore (corrupt component RON) leaves the live scene in place
|
||||||
|
/// but still returns to editing; the caller may log the returned error.
|
||||||
|
pub fn stop(&mut self) -> Result<(), SceneError> {
|
||||||
|
if !self.is_in_play() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let result = match self.play_snapshot.take() {
|
||||||
|
Some(snapshot) => snapshot.restore(&self.registry).map(|scene| {
|
||||||
|
self.scene = scene;
|
||||||
|
}),
|
||||||
|
None => Ok(()),
|
||||||
|
};
|
||||||
|
self.selected = None;
|
||||||
|
self.gizmo.drag = None;
|
||||||
|
self.play = PlayState::Editing;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors the current [`actions`](Self::actions) overrides into the
|
||||||
|
/// `input.bindings` settings section so the next
|
||||||
|
/// [`Settings::export`](oxide_engine::settings::Settings::export) round-
|
||||||
|
/// trips them. Called by the shell after every binding edit.
|
||||||
|
pub fn sync_action_overrides_to_settings(&mut self) {
|
||||||
|
let overrides = self.actions.overrides();
|
||||||
|
self.settings
|
||||||
|
.set::<ActionOverrides>(bindings::SETTINGS_SECTION, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies any [`ActionOverrides`] previously
|
||||||
|
/// [`Settings::import`](oxide_engine::settings::Settings::import)'d into
|
||||||
|
/// the `input.bindings` section on top of the registered defaults.
|
||||||
|
/// Called by the host runner at startup, after loading the on-disk
|
||||||
|
/// preferences file. No-op if the section is empty or unregistered.
|
||||||
|
pub fn apply_action_overrides_from_settings(&mut self) {
|
||||||
|
if let Some(o) = self
|
||||||
|
.settings
|
||||||
|
.get::<ActionOverrides>(bindings::SETTINGS_SECTION)
|
||||||
|
{
|
||||||
|
// Clone to release the immutable borrow before mutating actions.
|
||||||
|
let o = o.clone();
|
||||||
|
self.actions.apply_overrides(&o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EditorState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers the engine's built-in reflected component types under stable
|
||||||
|
/// names. Kept separate so the shell (and tests) seed a registry identically,
|
||||||
|
/// and so modules layer their own `register_reflected` calls on top.
|
||||||
|
///
|
||||||
|
/// Transform and Node are reflected but **not** addable (every scene entity
|
||||||
|
/// already carries them). `MeshRenderer` is addable, so it shows up in the
|
||||||
|
/// inspector's "Add Component" menu and is copied by Duplicate. `PrimitiveShape`
|
||||||
|
/// registers as an enum so its inspector widget is a dropdown.
|
||||||
|
fn register_builtin_types(registry: &mut TypeRegistry) {
|
||||||
|
// Node-baked components: reflected so the inspector can read/write them,
|
||||||
|
// but **not** addable — every entity carries them inherently
|
||||||
|
// (auto-attached on `Scene::spawn`), so the Add Component menu must not
|
||||||
|
// offer to attach a duplicate.
|
||||||
|
registry.register_reflected::<Transform>("Transform");
|
||||||
|
registry.register_reflected::<Node>("Node");
|
||||||
|
registry.register_reflected::<oxide_engine::layer::Layer>("Layer");
|
||||||
|
// Modular components: addable from the inspector. Having several distinct
|
||||||
|
// addable types is what lets the user attach more than one component to a
|
||||||
|
// node and drag-reorder them (an archetypal ECS allows only one component
|
||||||
|
// of a given type per entity, so a *second* mesh lives on a child — see the
|
||||||
|
// Add Component menu's "as child" path).
|
||||||
|
registry.register_addable::<oxide_engine::render::MeshRenderer>("MeshRenderer");
|
||||||
|
registry.register_enum::<oxide_engine::render::PrimitiveShape>("PrimitiveShape");
|
||||||
|
registry.register_addable::<oxide_engine::render::Camera>("Camera");
|
||||||
|
registry.register_addable::<oxide_engine::render::DirectionalLight>("DirectionalLight");
|
||||||
|
|
||||||
|
// Stage-9 physics components: addable from the inspector and captured by the
|
||||||
|
// play-mode snapshot (so Stop reverts a simulated body). No per-type editor
|
||||||
|
// code — the reflection-driven inspector renders them from their fields, with
|
||||||
|
// the two shape/kind enums shown as dropdowns.
|
||||||
|
registry.register_addable::<oxide_physics::RigidBody>("RigidBody");
|
||||||
|
registry.register_enum::<oxide_physics::RigidBodyKind>("RigidBodyKind");
|
||||||
|
registry.register_addable::<oxide_physics::Collider>("Collider");
|
||||||
|
registry.register_enum::<oxide_physics::ColliderShape>("ColliderShape");
|
||||||
|
registry.register_addable::<oxide_physics::CharacterController>("CharacterController");
|
||||||
|
|
||||||
|
// Stage-10 scripting: the Script component is addable from the inspector and
|
||||||
|
// captured by the play-mode snapshot (so Stop reverts a script attach/detach).
|
||||||
|
// Its `source` field is an `AssetRef<ScriptAsset>`, which the inspector shows
|
||||||
|
// as a picker filtered to the `scripts/` folder.
|
||||||
|
registry.register_addable::<oxide_script::Script>("Script");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The built-in prefabs the hierarchy add-menu offers. Data-driven via
|
||||||
|
/// [`ComponentSpec`]: each prefab is a node name plus the components to attach,
|
||||||
|
/// applied on spawn through the reflection registry. The type names here must
|
||||||
|
/// match those registered in [`register_builtin_types`].
|
||||||
|
fn builtin_prefabs() -> PrefabRegistry {
|
||||||
|
use oxide_engine::render::{Camera, DirectionalLight, MeshRenderer, PrimitiveShape};
|
||||||
|
|
||||||
|
let mut reg = PrefabRegistry::new();
|
||||||
|
// A bare node — just the node-baked Node/Transform/Layer.
|
||||||
|
reg.register(Prefab::new("Empty"));
|
||||||
|
// Primitive meshes (each a MeshRenderer with the matching shape).
|
||||||
|
for (name, shape) in [
|
||||||
|
("Cube", PrimitiveShape::Cube),
|
||||||
|
("Sphere", PrimitiveShape::Sphere),
|
||||||
|
("Plane", PrimitiveShape::Plane),
|
||||||
|
] {
|
||||||
|
let mesh = MeshRenderer {
|
||||||
|
shape,
|
||||||
|
..MeshRenderer::default()
|
||||||
|
};
|
||||||
|
if let Some(spec) = ComponentSpec::of("MeshRenderer", &mesh) {
|
||||||
|
reg.register(Prefab::new(name).with(spec));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Viewpoint + light entities.
|
||||||
|
if let Some(spec) = ComponentSpec::of("Camera", &Camera::default()) {
|
||||||
|
reg.register(Prefab::new("Camera").with(spec));
|
||||||
|
}
|
||||||
|
if let Some(spec) = ComponentSpec::of("DirectionalLight", &DirectionalLight::default()) {
|
||||||
|
reg.register(Prefab::new("Directional Light").with(spec));
|
||||||
|
}
|
||||||
|
reg
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use oxide_engine::math::{Transform, Vec3};
|
||||||
|
|
||||||
|
/// An editor state with one entity, ready to play.
|
||||||
|
fn state_with_entity() -> (EditorState, Entity) {
|
||||||
|
let mut state = EditorState::new();
|
||||||
|
let e = state.scene.spawn("thing", Transform::IDENTITY);
|
||||||
|
(state, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_play_snapshots_and_sets_playing() {
|
||||||
|
let (mut state, _) = state_with_entity();
|
||||||
|
assert_eq!(state.play, PlayState::Editing);
|
||||||
|
assert!(state.play_snapshot.is_none());
|
||||||
|
state.enter_play();
|
||||||
|
assert_eq!(state.play, PlayState::Playing);
|
||||||
|
assert!(state.play_snapshot.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn re_entering_play_does_not_overwrite_the_snapshot() {
|
||||||
|
let (mut state, e) = state_with_entity();
|
||||||
|
state.enter_play();
|
||||||
|
let original = state.play_snapshot.clone();
|
||||||
|
// Mutate, then (defensively) call enter_play again — the snapshot must
|
||||||
|
// remain the *pre-play* one so Stop still reverts correctly.
|
||||||
|
state
|
||||||
|
.scene
|
||||||
|
.set_local_transform(e, Transform::from_translation(Vec3::X));
|
||||||
|
state.enter_play();
|
||||||
|
assert_eq!(state.play_snapshot, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_pause_flips_only_while_in_play() {
|
||||||
|
let (mut state, _) = state_with_entity();
|
||||||
|
// No-op while editing.
|
||||||
|
state.toggle_pause();
|
||||||
|
assert_eq!(state.play, PlayState::Editing);
|
||||||
|
state.enter_play();
|
||||||
|
state.toggle_pause();
|
||||||
|
assert_eq!(state.play, PlayState::Paused);
|
||||||
|
state.toggle_pause();
|
||||||
|
assert_eq!(state.play, PlayState::Playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_restores_the_scene_and_clears_play_state() {
|
||||||
|
let (mut state, e) = state_with_entity();
|
||||||
|
let before = state.scene.to_ron().unwrap();
|
||||||
|
state.selected = Some(e);
|
||||||
|
state.enter_play();
|
||||||
|
// Simulate a play-mode mutation (as a tick would).
|
||||||
|
state
|
||||||
|
.scene
|
||||||
|
.set_local_transform(e, Transform::from_translation(Vec3::new(5.0, 0.0, 0.0)));
|
||||||
|
assert_ne!(state.scene.to_ron().unwrap(), before);
|
||||||
|
|
||||||
|
state.stop().unwrap();
|
||||||
|
assert_eq!(state.play, PlayState::Editing);
|
||||||
|
assert!(state.play_snapshot.is_none());
|
||||||
|
// Scene reverted bit-for-bit; selection dropped (handles changed).
|
||||||
|
assert_eq!(state.scene.to_ron().unwrap(), before);
|
||||||
|
assert!(state.selected.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_while_editing_is_a_noop() {
|
||||||
|
let (mut state, _) = state_with_entity();
|
||||||
|
let before = state.scene.to_ron().unwrap();
|
||||||
|
state.stop().unwrap();
|
||||||
|
assert_eq!(state.play, PlayState::Editing);
|
||||||
|
assert_eq!(state.scene.to_ron().unwrap(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn physics_components_are_addable_and_reflected() {
|
||||||
|
let state = EditorState::new();
|
||||||
|
// Editable via the reflection-driven inspector and offered in the Add
|
||||||
|
// Component menu (addable), with no per-type editor code.
|
||||||
|
for name in ["RigidBody", "Collider", "CharacterController"] {
|
||||||
|
assert!(state.registry.is_registered(name), "{name} not registered");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_script_component_is_addable_and_reflected() {
|
||||||
|
// Stage-10 dual-editability: Script is registered like any other
|
||||||
|
// component, so the inspector offers it in Add Component and renders its
|
||||||
|
// fields generically.
|
||||||
|
let state = EditorState::new();
|
||||||
|
assert!(state.registry.is_registered("Script"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_reverts_a_script_attach() {
|
||||||
|
// Attaching a Script during play must be undone on Stop — the snapshot
|
||||||
|
// captures the reflected Script component like any other.
|
||||||
|
let mut state = EditorState::new();
|
||||||
|
let e = state.scene.spawn("scripted", Transform::IDENTITY);
|
||||||
|
state.enter_play();
|
||||||
|
// The "running game" attaches a script at play time.
|
||||||
|
state
|
||||||
|
.scene
|
||||||
|
.world_mut()
|
||||||
|
.insert_one(e, oxide_script::Script::default())
|
||||||
|
.unwrap();
|
||||||
|
state.stop().unwrap();
|
||||||
|
|
||||||
|
let restored = state
|
||||||
|
.scene
|
||||||
|
.entities()
|
||||||
|
.find(|&e| state.scene.name(e).as_deref() == Some("scripted"))
|
||||||
|
.expect("the entity should be restored");
|
||||||
|
assert!(
|
||||||
|
state.scene.get::<oxide_script::Script>(restored).is_none(),
|
||||||
|
"the play-time script attach should be reverted on Stop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_reverts_a_simulated_physics_body() {
|
||||||
|
// A body that "fell" during play must be restored on Stop — the snapshot
|
||||||
|
// captures reflected physics components like any other.
|
||||||
|
let mut state = EditorState::new();
|
||||||
|
let e = state.scene.spawn(
|
||||||
|
"ball",
|
||||||
|
Transform::from_translation(Vec3::new(0.0, 5.0, 0.0)),
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.scene
|
||||||
|
.world_mut()
|
||||||
|
.insert_one(e, oxide_physics::RigidBody::default())
|
||||||
|
.unwrap();
|
||||||
|
state
|
||||||
|
.scene
|
||||||
|
.world_mut()
|
||||||
|
.insert_one(e, oxide_physics::Collider::ball(0.5))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
state.enter_play();
|
||||||
|
// Simulate physics moving the body down (as the play tick would).
|
||||||
|
state
|
||||||
|
.scene
|
||||||
|
.set_local_transform(e, Transform::from_translation(Vec3::new(0.0, 1.0, 0.0)));
|
||||||
|
state.stop().unwrap();
|
||||||
|
|
||||||
|
// Snapshot restore respawns entities (handles change), so find by name
|
||||||
|
// and confirm both the Transform and the physics components came back.
|
||||||
|
let restored = state
|
||||||
|
.scene
|
||||||
|
.entities()
|
||||||
|
.find(|&e| state.scene.name(e).as_deref() == Some("ball"))
|
||||||
|
.expect("the ball entity should be restored");
|
||||||
|
assert_eq!(
|
||||||
|
state.scene.world_transform(restored).unwrap().translation,
|
||||||
|
Vec3::new(0.0, 5.0, 0.0),
|
||||||
|
"transform should revert to the pre-play pose"
|
||||||
|
);
|
||||||
|
let collider = state
|
||||||
|
.scene
|
||||||
|
.get::<oxide_physics::Collider>(restored)
|
||||||
|
.expect("the Collider component should be restored");
|
||||||
|
assert_eq!(collider.radius, 0.5);
|
||||||
|
assert!(state
|
||||||
|
.scene
|
||||||
|
.get::<oxide_physics::RigidBody>(restored)
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_editor_section_is_registered_and_defaults_to_auto() {
|
||||||
|
let state = EditorState::new();
|
||||||
|
let prefs = state
|
||||||
|
.settings
|
||||||
|
.get::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
|
||||||
|
.expect("external-editor settings section must be registered");
|
||||||
|
assert!(
|
||||||
|
prefs.command.is_empty(),
|
||||||
|
"default is empty = auto ($VISUAL/$EDITOR terminal tab, else xdg-open)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
//! The editor's command terminal: runs a shell command and streams its output
|
||||||
|
//! into the [Console](crate::console) panel.
|
||||||
|
//!
|
||||||
|
//! This is the second half of the Stage-10 editor terminal — the log-capture
|
||||||
|
//! Console shows engine/script output, and this adds **command execution**: type
|
||||||
|
//! a command, it runs (via `sh -c`) with the working directory set to the open
|
||||||
|
//! project, and its stdout/stderr stream back into the same panel as they
|
||||||
|
//! arrive. Long-running commands (a build, a watcher, an AI-agent CLI) stream
|
||||||
|
//! line by line rather than blocking the editor — each line is pushed to the
|
||||||
|
//! shared console buffer from a reader thread, and the panel re-renders it next
|
||||||
|
//! frame.
|
||||||
|
//!
|
||||||
|
//! Running arbitrary commands from the editor is intended: the terminal is the
|
||||||
|
//! drop-in surface for dev tools and AI agents that edit the watched scripts
|
||||||
|
//! (whose edits then flow back through live reload).
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, Read};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use log::Level;
|
||||||
|
|
||||||
|
use crate::console;
|
||||||
|
|
||||||
|
/// The console target terminal lines are tagged with (distinguishes shell output
|
||||||
|
/// from engine `log` records in the panel).
|
||||||
|
const TARGET: &str = "terminal";
|
||||||
|
|
||||||
|
/// Spawns `command` with `sh -c` in `cwd`, streaming its stdout/stderr into the
|
||||||
|
/// console. Returns immediately; output arrives asynchronously. A blank command
|
||||||
|
/// is ignored.
|
||||||
|
///
|
||||||
|
/// The command is echoed first (`$ <command>`); stdout lines log at info level,
|
||||||
|
/// stderr at warn (so errors stand out), and the exit status is reported when
|
||||||
|
/// the process finishes.
|
||||||
|
pub fn run(command: &str, cwd: &Path) {
|
||||||
|
let command = command.trim();
|
||||||
|
if command.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console::append(Level::Info, TARGET, format!("$ {command}"));
|
||||||
|
|
||||||
|
let child = Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(command)
|
||||||
|
.current_dir(cwd)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn();
|
||||||
|
|
||||||
|
let mut child = match child {
|
||||||
|
Ok(child) => child,
|
||||||
|
Err(err) => {
|
||||||
|
console::append(Level::Error, TARGET, format!("failed to start: {err}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let stdout = child.stdout.take();
|
||||||
|
let stderr = child.stderr.take();
|
||||||
|
|
||||||
|
// One supervisor thread owns the child: it streams both pipes (stderr on its
|
||||||
|
// own thread so the two don't deadlock on full buffers), waits, and reports
|
||||||
|
// the exit status. Detached — the panel reads results from the shared buffer.
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let err_thread = stderr.map(|e| std::thread::spawn(move || stream(e, Level::Warn)));
|
||||||
|
if let Some(out) = stdout {
|
||||||
|
stream(out, Level::Info);
|
||||||
|
}
|
||||||
|
if let Some(handle) = err_thread {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
match child.wait() {
|
||||||
|
Ok(status) if status.success() => {
|
||||||
|
console::append(Level::Info, TARGET, "(exit 0)");
|
||||||
|
}
|
||||||
|
Ok(status) => {
|
||||||
|
let code = status
|
||||||
|
.code()
|
||||||
|
.map(|c| c.to_string())
|
||||||
|
.unwrap_or_else(|| "signal".to_string());
|
||||||
|
console::append(Level::Warn, TARGET, format!("(exit {code})"));
|
||||||
|
}
|
||||||
|
Err(err) => console::append(Level::Error, TARGET, format!("wait failed: {err}")),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `reader` line by line, pushing each line into the console at `level`.
|
||||||
|
fn stream<R: Read>(reader: R, level: Level) {
|
||||||
|
let mut buf = BufReader::new(reader);
|
||||||
|
let mut line = String::new();
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
match buf.read_line(&mut line) {
|
||||||
|
Ok(0) => break, // EOF
|
||||||
|
Ok(_) => console::append(
|
||||||
|
level,
|
||||||
|
TARGET,
|
||||||
|
line.trim_end_matches(['\n', '\r']).to_string(),
|
||||||
|
),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
//! The editor's 3D viewport: an orbit / flythrough camera and a forward
|
||||||
|
//! render of the scene.
|
||||||
|
//!
|
||||||
|
//! The engine stays UI-agnostic; this glue lives in the editor. [`Viewport`]
|
||||||
|
//! owns a [`ForwardRenderer`], a small cache of primitive [`GpuMesh`]es, two
|
||||||
|
//! camera modes ([`OrbitCamera`] for inspecting a target,
|
||||||
|
//! [`FlythroughCamera`] for free-look navigation), and draws every scene
|
||||||
|
//! entity that carries a [`MeshRenderer`](oxide_engine::render::MeshRenderer)
|
||||||
|
//! component. The mode toggle preserves pose so the camera does not snap
|
||||||
|
//! when switching.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use oxide_engine::hecs::Entity;
|
||||||
|
use oxide_engine::math::{EulerRot, Quat, Transform, Vec3};
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::wgpu;
|
||||||
|
use oxide_engine::window::RenderCtx;
|
||||||
|
|
||||||
|
/// An orbit camera: looks at `target` from a yaw/pitch/distance offset.
|
||||||
|
pub struct OrbitCamera {
|
||||||
|
/// The point the camera orbits and looks at.
|
||||||
|
pub target: Vec3,
|
||||||
|
/// Horizontal angle (radians) around `+Y`.
|
||||||
|
pub yaw: f32,
|
||||||
|
/// Vertical angle (radians); clamped to avoid flipping over the poles.
|
||||||
|
pub pitch: f32,
|
||||||
|
/// Distance from `target` to the eye.
|
||||||
|
pub distance: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OrbitCamera {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
target: Vec3::new(0.0, 0.8, 0.0),
|
||||||
|
yaw: 0.6,
|
||||||
|
pitch: -0.45,
|
||||||
|
distance: 12.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrbitCamera {
|
||||||
|
/// The camera's orientation as a quaternion.
|
||||||
|
fn rotation(&self) -> Quat {
|
||||||
|
Quat::from_euler(EulerRot::YXZ, self.yaw, self.pitch, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The eye position in world space.
|
||||||
|
fn eye(&self) -> Vec3 {
|
||||||
|
self.target + self.rotation() * Vec3::new(0.0, 0.0, self.distance)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The camera's world transform (what the renderer takes as the view).
|
||||||
|
pub fn view_transform(&self) -> Transform {
|
||||||
|
Transform::looking_at(self.eye(), self.target, Vec3::Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Orbit by a pixel drag delta.
|
||||||
|
pub fn orbit(&mut self, dx: f32, dy: f32) {
|
||||||
|
const SENS: f32 = 0.005;
|
||||||
|
self.yaw -= dx * SENS;
|
||||||
|
self.pitch = (self.pitch - dy * SENS).clamp(-1.54, 1.54);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pan the target in the camera's screen plane by a pixel drag delta.
|
||||||
|
pub fn pan(&mut self, dx: f32, dy: f32) {
|
||||||
|
let rot = self.rotation();
|
||||||
|
let right = rot * Vec3::X;
|
||||||
|
let up = rot * Vec3::Y;
|
||||||
|
// Scale panning with distance so it feels consistent at any zoom.
|
||||||
|
let speed = self.distance * 0.0015;
|
||||||
|
self.target += (-right * dx + up * dy) * speed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zoom by a scroll delta (positive = closer).
|
||||||
|
pub fn zoom(&mut self, amount: f32) {
|
||||||
|
self.distance = (self.distance * (1.0 - amount * 0.1)).clamp(0.5, 500.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A free-look "flythrough" camera: a position in world space plus a
|
||||||
|
/// yaw/pitch orientation, driven by WASD/QE translation + mouse-look in the
|
||||||
|
/// usual first-person convention.
|
||||||
|
///
|
||||||
|
/// Distinct from [`OrbitCamera`] because the two modes have fundamentally
|
||||||
|
/// different controls; switching between them preserves the camera pose via
|
||||||
|
/// [`FlythroughCamera::from_orbit`] / [`OrbitCamera::from_flythrough`] so the
|
||||||
|
/// view doesn't snap on toggle.
|
||||||
|
pub struct FlythroughCamera {
|
||||||
|
/// Eye position in world space.
|
||||||
|
pub position: Vec3,
|
||||||
|
/// Horizontal angle (radians) around `+Y`, matching [`OrbitCamera::yaw`].
|
||||||
|
pub yaw: f32,
|
||||||
|
/// Vertical angle (radians); clamped to avoid flipping over the poles.
|
||||||
|
pub pitch: f32,
|
||||||
|
/// Translation speed in world units per second at the base (non-sprint)
|
||||||
|
/// rate. Adjustable at runtime — the editor binds scroll-wheel to this.
|
||||||
|
pub move_speed: f32,
|
||||||
|
/// Multiplier applied while the "sprint" action is held.
|
||||||
|
pub sprint_multiplier: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FlythroughCamera {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Place the eye where the default OrbitCamera would put it, so a
|
||||||
|
// fresh project that starts in flythrough mode (a future preference)
|
||||||
|
// sees the same opening view.
|
||||||
|
let orbit = OrbitCamera::default();
|
||||||
|
Self::from_orbit(&orbit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FlythroughCamera {
|
||||||
|
/// Position the flythrough camera to look at the same view the given
|
||||||
|
/// orbit camera is showing. The eye lands at the orbit camera's eye
|
||||||
|
/// position and the yaw/pitch are copied verbatim.
|
||||||
|
pub fn from_orbit(orbit: &OrbitCamera) -> Self {
|
||||||
|
Self {
|
||||||
|
position: orbit.eye(),
|
||||||
|
yaw: orbit.yaw,
|
||||||
|
pitch: orbit.pitch,
|
||||||
|
move_speed: 5.0,
|
||||||
|
sprint_multiplier: 4.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The camera's orientation as a quaternion (same Y-yaw-then-X-pitch
|
||||||
|
/// convention as [`OrbitCamera::rotation`]).
|
||||||
|
fn rotation(&self) -> Quat {
|
||||||
|
Quat::from_euler(EulerRot::YXZ, self.yaw, self.pitch, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unit world-space forward direction (where the camera looks).
|
||||||
|
pub fn forward(&self) -> Vec3 {
|
||||||
|
self.rotation() * Vec3::new(0.0, 0.0, -1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unit world-space right direction (camera's screen-right).
|
||||||
|
pub fn right(&self) -> Vec3 {
|
||||||
|
self.rotation() * Vec3::X
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unit world-space up direction.
|
||||||
|
pub fn up(&self) -> Vec3 {
|
||||||
|
self.rotation() * Vec3::Y
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The camera's world transform (what the renderer takes as the view).
|
||||||
|
pub fn view_transform(&self) -> Transform {
|
||||||
|
Transform::looking_at(self.position, self.position + self.forward(), Vec3::Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mouse-look by a pixel drag delta. Same sensitivity as
|
||||||
|
/// [`OrbitCamera::orbit`] so the gesture feels identical in both modes.
|
||||||
|
pub fn look(&mut self, dx: f32, dy: f32) {
|
||||||
|
const SENS: f32 = 0.005;
|
||||||
|
self.yaw -= dx * SENS;
|
||||||
|
self.pitch = (self.pitch - dy * SENS).clamp(-1.54, 1.54);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate by a per-frame move vector in **camera-local** axes (`+X`
|
||||||
|
/// right, `+Y` up, `-Z` forward — the same convention game code uses for
|
||||||
|
/// a first-person move input). Each axis is expected to be in `[-1, 1]`,
|
||||||
|
/// the natural range of an [`AxisBinding`](oxide_engine::input::AxisBinding).
|
||||||
|
pub fn translate_local(&mut self, local: Vec3, dt: f32, sprint: bool) {
|
||||||
|
if local.length_squared() == 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let speed = if sprint {
|
||||||
|
self.move_speed * self.sprint_multiplier
|
||||||
|
} else {
|
||||||
|
self.move_speed
|
||||||
|
};
|
||||||
|
// `local` is in camera-local axes (right / up / forward). Convert to
|
||||||
|
// world by combining with the camera basis. `-Z` is forward, so a
|
||||||
|
// local.z of `-1.0` (from a "forward" axis) moves along +forward.
|
||||||
|
let world = self.right() * local.x + self.up() * local.y + self.forward() * (-local.z);
|
||||||
|
self.position += world * (speed * dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjust the base move speed by a scroll-wheel delta. Clamped so the
|
||||||
|
/// camera never becomes immobile or too fast to control.
|
||||||
|
pub fn adjust_move_speed(&mut self, scroll_lines: f32) {
|
||||||
|
let factor = (1.0 + scroll_lines * 0.1).max(0.1);
|
||||||
|
self.move_speed = (self.move_speed * factor).clamp(0.5, 200.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrbitCamera {
|
||||||
|
/// Position an orbit camera so it shows the same view as the given
|
||||||
|
/// flythrough camera. The target is placed [`OrbitCamera::distance`]
|
||||||
|
/// units in front of the flythrough's eye along its forward direction.
|
||||||
|
pub fn from_flythrough(fly: &FlythroughCamera) -> Self {
|
||||||
|
let distance = OrbitCamera::default().distance;
|
||||||
|
Self {
|
||||||
|
target: fly.position + fly.forward() * distance,
|
||||||
|
yaw: fly.yaw,
|
||||||
|
pitch: fly.pitch,
|
||||||
|
distance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which input scheme drives the viewport camera.
|
||||||
|
///
|
||||||
|
/// [`Orbit`](Self::Orbit) is the default editor convention — useful for
|
||||||
|
/// inspecting a single subject. [`Flythrough`](Self::Flythrough) is a
|
||||||
|
/// first-person fly: WASD/QE translate, right-drag looks around, scroll
|
||||||
|
/// adjusts move speed; better for navigating a level or open scene.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CameraMode {
|
||||||
|
Orbit,
|
||||||
|
Flythrough,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the renderer, primitive mesh cache, camera, and lighting for the editor
|
||||||
|
/// viewport.
|
||||||
|
pub struct Viewport {
|
||||||
|
pipeline: RenderPipeline,
|
||||||
|
meshes: HashMap<PrimitiveShape, GpuMesh>,
|
||||||
|
pub camera: Camera,
|
||||||
|
pub orbit: OrbitCamera,
|
||||||
|
pub flythrough: FlythroughCamera,
|
||||||
|
pub mode: CameraMode,
|
||||||
|
pub lighting: Lighting,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Viewport {
|
||||||
|
/// Builds the viewport, uploading a GPU mesh for every primitive shape.
|
||||||
|
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
|
||||||
|
let meshes = PrimitiveShape::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|&shape| (shape, shape.mesh().upload(device, shape.label())))
|
||||||
|
.collect();
|
||||||
|
// The editor clears the frame before drawing the scene, so the viewport
|
||||||
|
// pipeline is just the forward pass; post passes slot in here later.
|
||||||
|
let mut pipeline = RenderPipeline::new();
|
||||||
|
pipeline.add_pass("forward", ForwardPass::new(device, color_format));
|
||||||
|
let orbit = OrbitCamera::default();
|
||||||
|
let flythrough = FlythroughCamera::from_orbit(&orbit);
|
||||||
|
Self {
|
||||||
|
pipeline,
|
||||||
|
meshes,
|
||||||
|
camera: Camera::default(),
|
||||||
|
orbit,
|
||||||
|
flythrough,
|
||||||
|
mode: CameraMode::Orbit,
|
||||||
|
lighting: Lighting::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The view transform of the **active** camera (whichever mode is
|
||||||
|
/// currently selected).
|
||||||
|
pub fn view_transform(&self) -> Transform {
|
||||||
|
match self.mode {
|
||||||
|
CameraMode::Orbit => self.orbit.view_transform(),
|
||||||
|
CameraMode::Flythrough => self.flythrough.view_transform(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swaps between orbit and flythrough modes while preserving pose, so
|
||||||
|
/// the visible scene does not jump when the user toggles. Returns the
|
||||||
|
/// new mode for the caller to surface in the status bar.
|
||||||
|
pub fn toggle_camera_mode(&mut self) -> CameraMode {
|
||||||
|
match self.mode {
|
||||||
|
CameraMode::Orbit => {
|
||||||
|
self.flythrough = FlythroughCamera::from_orbit(&self.orbit);
|
||||||
|
self.mode = CameraMode::Flythrough;
|
||||||
|
}
|
||||||
|
CameraMode::Flythrough => {
|
||||||
|
self.orbit = OrbitCamera::from_flythrough(&self.flythrough);
|
||||||
|
self.mode = CameraMode::Orbit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the scene's renderable entities into the frame, before the editor
|
||||||
|
/// UI is painted on top.
|
||||||
|
///
|
||||||
|
/// `viewport_rect` restricts drawing and projection to the Viewport
|
||||||
|
/// tab's sub-rectangle of the surface (in physical pixels). `None`
|
||||||
|
/// falls back to the full surface — handy for early frames before
|
||||||
|
/// egui has reported a rect, and for any host that wants to render
|
||||||
|
/// edge-to-edge.
|
||||||
|
pub fn render(
|
||||||
|
&mut self,
|
||||||
|
scene: &Scene,
|
||||||
|
ctx: &RenderCtx<'_>,
|
||||||
|
viewport_rect: Option<oxide_engine::math::Rect>,
|
||||||
|
) {
|
||||||
|
// Snapshot renderables first so the query borrow is released before we
|
||||||
|
// resolve world transforms.
|
||||||
|
let renderables: Vec<(Entity, MeshRenderer)> = scene
|
||||||
|
.world()
|
||||||
|
.query::<&MeshRenderer>()
|
||||||
|
.iter()
|
||||||
|
.map(|(e, mr)| (e, *mr))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let view = self.view_transform();
|
||||||
|
let mut objects = Vec::with_capacity(renderables.len());
|
||||||
|
for (entity, mr) in &renderables {
|
||||||
|
// Hierarchical: a disabled ancestor hides its whole subtree.
|
||||||
|
if !scene.is_effectively_enabled(*entity).unwrap_or(true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Per-component: the MeshRenderer itself may be marked disabled
|
||||||
|
// (e.g. by a script before a trigger fires).
|
||||||
|
if scene.is_component_disabled(*entity, "MeshRenderer") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Honor the camera's layer visibility: entities default to the
|
||||||
|
// Default layer when they carry no explicit `Layer` component.
|
||||||
|
let layers = scene.get::<Layer>(*entity).map(|l| *l).unwrap_or_default();
|
||||||
|
if !self.camera.sees(layers) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(world) = scene.world_transform(*entity) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(mesh) = self.meshes.get(&mr.shape) {
|
||||||
|
objects.push(RenderObject {
|
||||||
|
mesh,
|
||||||
|
material: mr.material,
|
||||||
|
transform: world,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pipeline.render(&mut FrameContext {
|
||||||
|
device: ctx.gpu.device(),
|
||||||
|
queue: ctx.gpu.queue(),
|
||||||
|
color: ctx.view,
|
||||||
|
size: ctx.size,
|
||||||
|
viewport_rect,
|
||||||
|
clear_color: Color::BLACK, // editor clears separately; unused here
|
||||||
|
camera: &self.camera,
|
||||||
|
view_transform: &view,
|
||||||
|
lighting: &self.lighting,
|
||||||
|
objects: &objects,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the world-space ray from `cursor` (window-physical pixels)
|
||||||
|
/// through the viewport using the active camera's projection. The same
|
||||||
|
/// helper feeds both entity picking and gizmo handle hit-testing — they
|
||||||
|
/// must agree on the math or a click on a handle won't line up with
|
||||||
|
/// what the user sees.
|
||||||
|
pub fn ray_from_cursor(
|
||||||
|
&self,
|
||||||
|
cursor: (f32, f32),
|
||||||
|
size: (u32, u32),
|
||||||
|
viewport_rect: Option<oxide_engine::math::Rect>,
|
||||||
|
) -> Ray {
|
||||||
|
let rect = viewport_rect.unwrap_or_else(|| {
|
||||||
|
oxide_engine::math::Rect::from_min_size(
|
||||||
|
oxide_engine::math::Vec2::ZERO,
|
||||||
|
oxide_engine::math::Vec2::new(size.0.max(1) as f32, size.1.max(1) as f32),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let (w, h) = (rect.width().max(1.0), rect.height().max(1.0));
|
||||||
|
// Cursor is in window coords; rebase to viewport-local before NDC.
|
||||||
|
let local_x = cursor.0 - rect.min.x;
|
||||||
|
let local_y = cursor.1 - rect.min.y;
|
||||||
|
// Cursor → normalized device coordinates (flip Y: screen down, NDC up).
|
||||||
|
let ndc_x = 2.0 * local_x / w - 1.0;
|
||||||
|
let ndc_y = 1.0 - 2.0 * local_y / h;
|
||||||
|
|
||||||
|
let view = self.view_transform();
|
||||||
|
let inv_vp = self.camera.view_projection(w / h, &view).inverse();
|
||||||
|
// Unproject the near and far points of the pixel into world space.
|
||||||
|
let near = inv_vp.project_point3(Vec3::new(ndc_x, ndc_y, 0.0));
|
||||||
|
let far = inv_vp.project_point3(Vec3::new(ndc_x, ndc_y, 1.0));
|
||||||
|
Ray::new(near, (far - near).normalize_or_zero())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The combined view-projection matrix the viewport uses for `viewport_rect`'s
|
||||||
|
/// aspect ratio. Exposed so the gizmo overlay can project world points
|
||||||
|
/// to screen pixels with the same math the renderer drew with.
|
||||||
|
pub fn view_projection_for(
|
||||||
|
&self,
|
||||||
|
viewport_rect: Option<oxide_engine::math::Rect>,
|
||||||
|
size: (u32, u32),
|
||||||
|
) -> oxide_engine::math::Mat4 {
|
||||||
|
let rect = viewport_rect.unwrap_or_else(|| {
|
||||||
|
oxide_engine::math::Rect::from_min_size(
|
||||||
|
oxide_engine::math::Vec2::ZERO,
|
||||||
|
oxide_engine::math::Vec2::new(size.0.max(1) as f32, size.1.max(1) as f32),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let aspect = rect.width().max(1.0) / rect.height().max(1.0);
|
||||||
|
let view = self.view_transform();
|
||||||
|
self.camera.view_projection(aspect, &view)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picks the nearest renderable entity under the cursor (physical pixels),
|
||||||
|
/// by casting a ray through the viewport and testing each entity's
|
||||||
|
/// world-space bounds. Returns `None` if the ray hits nothing.
|
||||||
|
///
|
||||||
|
/// `viewport_rect` is the same sub-rectangle the render path used (the
|
||||||
|
/// Viewport tab in the editor's case); the cursor is converted to NDC
|
||||||
|
/// relative to it so a click at the tab's edge corresponds to the ray
|
||||||
|
/// through that edge — not through the corresponding spot in a full-
|
||||||
|
/// window projection. `None` falls back to the full window.
|
||||||
|
pub fn pick(
|
||||||
|
&self,
|
||||||
|
scene: &Scene,
|
||||||
|
cursor: (f32, f32),
|
||||||
|
size: (u32, u32),
|
||||||
|
viewport_rect: Option<oxide_engine::math::Rect>,
|
||||||
|
) -> Option<Entity> {
|
||||||
|
let ray = self.ray_from_cursor(cursor, size, viewport_rect);
|
||||||
|
|
||||||
|
let renderables: Vec<(Entity, MeshRenderer)> = scene
|
||||||
|
.world()
|
||||||
|
.query::<&MeshRenderer>()
|
||||||
|
.iter()
|
||||||
|
.map(|(e, mr)| (e, *mr))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut best: Option<(f32, Entity)> = None;
|
||||||
|
for (entity, mr) in renderables {
|
||||||
|
// Don't pick what isn't visible (effectively disabled subtree, or
|
||||||
|
// a per-component disable on the MeshRenderer).
|
||||||
|
if scene.is_component_disabled(entity, "MeshRenderer") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !scene.is_effectively_enabled(entity).unwrap_or(true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(world) = scene.world_transform(entity) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let aabb = transform_aabb(&world, &mr.shape.local_bounds());
|
||||||
|
if let Some(t) = aabb.ray_intersection(&ray) {
|
||||||
|
if best.map_or(true, |(bt, _)| t < bt) {
|
||||||
|
best = Some((t, entity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(_, e)| e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The world-space AABB of a local AABB transformed by `t` (transform its 8
|
||||||
|
/// corners and re-fit).
|
||||||
|
fn transform_aabb(t: &Transform, local: &oxide_engine::math::Aabb) -> oxide_engine::math::Aabb {
|
||||||
|
oxide_engine::math::Aabb::from_points(local.corners().iter().map(|&c| t.transform_point(c)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "oxide-engine-derive"
|
||||||
|
description = "Derive macros for Oxide's reflection system (#[derive(Reflect)])"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
proc-macro = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
syn.workspace = true
|
||||||
|
quote.workspace = true
|
||||||
|
proc-macro2.workspace = true
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
//! Derive macros for Oxide's reflection system.
|
||||||
|
//!
|
||||||
|
//! This crate exists for exactly one job: `#[derive(Reflect)]`. It is the
|
||||||
|
//! compile-time half of the engine's **dual-editable types** principle —
|
||||||
|
//! every component's public fields should be editable from the editor
|
||||||
|
//! inspector and from scripts through *one* representation, with no
|
||||||
|
//! hand-written per-type code. The runtime half (the `Reflect` trait, the
|
||||||
|
//! `FieldInfo` descriptor, and the `TypeRegistry`) lives in
|
||||||
|
//! `oxide_engine::reflect`; this crate only generates the trait impl.
|
||||||
|
//!
|
||||||
|
//! ## What the derive generates
|
||||||
|
//!
|
||||||
|
//! For a struct with named fields, `#[derive(Reflect)]` emits an
|
||||||
|
//! `oxide_engine::reflect::Reflect` impl that exposes each **public**,
|
||||||
|
//! non-skipped field as:
|
||||||
|
//!
|
||||||
|
//! - a static [`FieldInfo`] entry (`name` + syntactic `type_name`), so a
|
||||||
|
//! generic inspector can enumerate fields and pick a widget per type, and
|
||||||
|
//! - per-field RON get/set, so a single field can be read or written without
|
||||||
|
//! touching the rest of the component (the unit an inspector edits).
|
||||||
|
//!
|
||||||
|
//! Only `pub` fields are reflected — this matches the Unity/Godot convention
|
||||||
|
//! that *public* fields are the editable surface. Use `#[reflect(skip)]` to
|
||||||
|
//! exclude a public field.
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! use oxide_engine::reflect::Reflect;
|
||||||
|
//!
|
||||||
|
//! #[derive(Reflect, serde::Serialize, serde::Deserialize)]
|
||||||
|
//! struct Timer {
|
||||||
|
//! pub repeating: bool,
|
||||||
|
//! pub duration: f32,
|
||||||
|
//! #[reflect(skip)]
|
||||||
|
//! pub elapsed: f32, // runtime state — not an authored field
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Every reflected field must itself be `serde`-serializable, since get/set
|
||||||
|
//! round-trip through RON.
|
||||||
|
|
||||||
|
use proc_macro::TokenStream;
|
||||||
|
use quote::quote;
|
||||||
|
use syn::{parse_macro_input, Data, DeriveInput, Fields, Visibility};
|
||||||
|
|
||||||
|
/// Derives `oxide_engine::reflect::Reflect` for a struct with named fields.
|
||||||
|
///
|
||||||
|
/// See the [crate-level docs](crate) for the field-selection rules
|
||||||
|
/// (public-only, `#[reflect(skip)]`).
|
||||||
|
#[proc_macro_derive(Reflect, attributes(reflect))]
|
||||||
|
pub fn derive_reflect(input: TokenStream) -> TokenStream {
|
||||||
|
let input = parse_macro_input!(input as DeriveInput);
|
||||||
|
let name = &input.ident;
|
||||||
|
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
|
||||||
|
|
||||||
|
// Named-field structs and tuple structs are both supported. Tuple-struct
|
||||||
|
// fields are addressed by their positional index ("0", "1", …), matching
|
||||||
|
// Rust's own `self.0` / `self.1` syntax — this lets one-field newtype
|
||||||
|
// components like `Layers(pub LayerMask)` reflect without a wrapper.
|
||||||
|
let raw_fields = match &input.data {
|
||||||
|
Data::Struct(data) => match &data.fields {
|
||||||
|
Fields::Named(named) => named.named.iter().collect::<Vec<_>>(),
|
||||||
|
Fields::Unnamed(unnamed) => unnamed.unnamed.iter().collect::<Vec<_>>(),
|
||||||
|
Fields::Unit => {
|
||||||
|
return compile_error(name, "Reflect cannot be derived for unit structs")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => return compile_error(name, "Reflect can only be derived for structs"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut infos = Vec::new();
|
||||||
|
let mut get_arms = Vec::new();
|
||||||
|
let mut set_arms = Vec::new();
|
||||||
|
|
||||||
|
for (index, field) in raw_fields.iter().enumerate() {
|
||||||
|
// Public-only: private fields are implementation detail, not the
|
||||||
|
// authored/editable surface.
|
||||||
|
if !matches!(field.vis, Visibility::Public(_)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let attrs = parse_field_attrs(field);
|
||||||
|
if attrs.skip {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For named structs the field name + accessor is the ident; for tuple
|
||||||
|
// structs the name is the index as a string and the accessor is the
|
||||||
|
// syn::Index token (which renders as `0`, `1`, ...).
|
||||||
|
let (field_name, accessor) = match &field.ident {
|
||||||
|
Some(ident) => (ident.to_string(), quote!(#ident)),
|
||||||
|
None => {
|
||||||
|
let idx = syn::Index::from(index);
|
||||||
|
(index.to_string(), quote!(#idx))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let ty = &field.ty;
|
||||||
|
// Syntactic type text, e.g. "f32", "bool", "Vec3", "Handle < Font >".
|
||||||
|
// The inspector dispatches a widget on this; unknown types fall back to
|
||||||
|
// a raw RON editor.
|
||||||
|
let type_name = quote!(#ty).to_string();
|
||||||
|
|
||||||
|
let range_tokens = match attrs.range {
|
||||||
|
Some((min, max)) => quote! {
|
||||||
|
::core::option::Option::Some((#min, #max))
|
||||||
|
},
|
||||||
|
None => quote! { ::core::option::Option::None },
|
||||||
|
};
|
||||||
|
|
||||||
|
infos.push(quote! {
|
||||||
|
::oxide_engine::reflect::FieldInfo {
|
||||||
|
name: #field_name,
|
||||||
|
type_name: #type_name,
|
||||||
|
range: #range_tokens,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
get_arms.push(quote! {
|
||||||
|
#field_name => ::oxide_engine::reflect::__reflect_to_ron(&self.#accessor),
|
||||||
|
});
|
||||||
|
set_arms.push(quote! {
|
||||||
|
#field_name => {
|
||||||
|
self.#accessor = ::oxide_engine::reflect::__reflect_from_ron(#field_name, value)?;
|
||||||
|
::core::result::Result::Ok(())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let field_count = infos.len();
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
impl #impl_generics ::oxide_engine::reflect::Reflect for #name #ty_generics #where_clause {
|
||||||
|
fn fields(&self) -> &'static [::oxide_engine::reflect::FieldInfo] {
|
||||||
|
static FIELDS: [::oxide_engine::reflect::FieldInfo; #field_count] = [
|
||||||
|
#(#infos),*
|
||||||
|
];
|
||||||
|
&FIELDS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_field(&self, name: &str) -> ::core::option::Option<::std::string::String> {
|
||||||
|
match name {
|
||||||
|
#(#get_arms)*
|
||||||
|
_ => ::core::option::Option::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_field(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> ::core::result::Result<(), ::oxide_engine::reflect::ReflectError> {
|
||||||
|
match name {
|
||||||
|
#(#set_arms)*
|
||||||
|
_ => ::core::result::Result::Err(
|
||||||
|
::oxide_engine::reflect::ReflectError::UnknownField(
|
||||||
|
::std::string::ToString::to_string(name),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed `#[reflect(...)]` attributes on a single field.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FieldAttrs {
|
||||||
|
/// `#[reflect(skip)]` — exclude this public field from reflection.
|
||||||
|
skip: bool,
|
||||||
|
/// `#[reflect(min = X, max = Y)]` — numeric bounds passed to inspector
|
||||||
|
/// widgets so a normalized `f32` field becomes a slider instead of a drag.
|
||||||
|
/// Both must be present for a range to be recorded.
|
||||||
|
range: Option<(f32, f32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_field_attrs(field: &syn::Field) -> FieldAttrs {
|
||||||
|
let mut out = FieldAttrs::default();
|
||||||
|
let mut min: Option<f32> = None;
|
||||||
|
let mut max: Option<f32> = None;
|
||||||
|
for attr in &field.attrs {
|
||||||
|
if !attr.path().is_ident("reflect") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let _ = attr.parse_nested_meta(|meta| {
|
||||||
|
if meta.path.is_ident("skip") {
|
||||||
|
out.skip = true;
|
||||||
|
} else if meta.path.is_ident("min") {
|
||||||
|
let lit: syn::LitFloat = meta.value()?.parse()?;
|
||||||
|
min = Some(lit.base10_parse::<f32>()?);
|
||||||
|
} else if meta.path.is_ident("max") {
|
||||||
|
let lit: syn::LitFloat = meta.value()?.parse()?;
|
||||||
|
max = Some(lit.base10_parse::<f32>()?);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let (Some(mn), Some(mx)) = (min, max) {
|
||||||
|
out.range = Some((mn, mx));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives `oxide_engine::reflect::ReflectEnum` for a fieldless (C-like) enum,
|
||||||
|
/// exposing its variant names so a generic inspector can render a dropdown for
|
||||||
|
/// fields of that enum type.
|
||||||
|
///
|
||||||
|
/// Only **unit** variants are supported — a variant carrying data has no single
|
||||||
|
/// "pick from a list" representation. Variant names round-trip as RON (a unit
|
||||||
|
/// variant `Foo::Bar` serializes as `Bar`), which is exactly what `set_field`
|
||||||
|
/// consumes.
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// use oxide_engine::reflect::ReflectEnum;
|
||||||
|
///
|
||||||
|
/// #[derive(ReflectEnum, serde::Serialize, serde::Deserialize)]
|
||||||
|
/// enum Facing { North, East, South, West }
|
||||||
|
/// assert_eq!(Facing::variants(), &["North", "East", "South", "West"]);
|
||||||
|
/// ```
|
||||||
|
#[proc_macro_derive(ReflectEnum)]
|
||||||
|
pub fn derive_reflect_enum(input: TokenStream) -> TokenStream {
|
||||||
|
let input = parse_macro_input!(input as DeriveInput);
|
||||||
|
let name = &input.ident;
|
||||||
|
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
|
||||||
|
|
||||||
|
let data = match &input.data {
|
||||||
|
Data::Enum(data) => data,
|
||||||
|
_ => return compile_error(name, "ReflectEnum can only be derived for enums"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut variant_names = Vec::new();
|
||||||
|
for variant in &data.variants {
|
||||||
|
if !matches!(variant.fields, Fields::Unit) {
|
||||||
|
return compile_error(
|
||||||
|
&variant.ident,
|
||||||
|
"ReflectEnum requires unit (fieldless) variants",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
variant_names.push(variant.ident.to_string());
|
||||||
|
}
|
||||||
|
let count = variant_names.len();
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
impl #impl_generics ::oxide_engine::reflect::ReflectEnum for #name #ty_generics #where_clause {
|
||||||
|
fn variants() -> &'static [&'static str] {
|
||||||
|
static VARIANTS: [&str; #count] = [ #(#variant_names),* ];
|
||||||
|
&VARIANTS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit a `compile_error!` at the derived type so the message is attributed
|
||||||
|
/// to the user's struct, not somewhere inside the generated impl.
|
||||||
|
fn compile_error(name: &syn::Ident, message: &str) -> TokenStream {
|
||||||
|
syn::Error::new(name.span(), message)
|
||||||
|
.to_compile_error()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,33 @@
|
|||||||
|
//! 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, AssetDbError, 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user