diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1baaa3f --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b010297 --- /dev/null +++ b/CLAUDE.md @@ -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 \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6a3d646 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,81 @@ +[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) +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 diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..ccc7409 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,257 @@ +# 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-06-17. **`main` and `dev` are aligned.** **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` not `Handle` in components**: a component stores a + serializable `AssetRef` (= `Option`); `AssetRef::resolve(db, + server)` yields the process-local `Handle` 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 ` +- **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::` (needs `Default`); enums via `register_enum::`. +- 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"); // 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` 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 ` 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 + +**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 (GUI → `dev` + eye-check).** Details in +PLAN.md "Editor-UX follow-ups" + [[editor-file-explorer-preference]]. 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). diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..c37b8d1 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,1230 @@ +# Oxide Engine — Long-Term Development Plan + +Each stage must be completed and signed off (manual + automated testing) before the next begins. +Stages are designed so that each one produces a usable, standalone artifact that later stages build upon. + +The **in-engine editor** (`oxide-editor`) is a first-class deliverable. It is developed in parallel with +the engine: each stage adds editor support for the systems introduced in that stage. +The editor crate lives at `editor/` and is always buildable alongside the engine. + +The project must remain installable as a Linux package at all times. `install.sh` builds a release +binary and installs it to the system. Keep it current whenever new binaries or assets are added. + +--- + +## Development Phases + +Oxide is built in two phases: + +- **Phase 1 — The general-purpose engine (Stages 0–16).** Everything needed to build *and ship* any + 3D game: core framework, editor + project system, input, UI, physics, scripting, animation, + particles, shaders, audio, a content kit, and game export to Linux + Windows. Stage 16 is the + **milestone**: a complete, general-purpose engine that can build and export real games. +- **Phase 2 — Built-in modules (Stages 17+).** Optional, self-contained capabilities — ray-traced + audio, a developer console, a procedural toolkit, terrain, open-world streaming, pathfinding/AI, + water — each shipped as a feature-gated **module** built entirely on Phase-1 systems. Phase 2 is + also the proof the module API holds: adding a capability should mean *writing a module*, not + surgery on the core. + +The headline framing is simply **a general-purpose 3D engine that scales from stylized low-poly to +realistic, shipping only what each game uses.** Simulation, open world, and procedural generation are +*capabilities the engine supports through modules*, not design drivers — the goal is to **build +tools, not games**. + +--- + +## Platform & Target Strategy + +These are project-wide constraints that span every stage, not a single deliverable: + +- **Primary platform is Linux**, and Linux means **both Wayland and Xorg (X11)** — the editor and + any engine app must run on either without code changes. This is the default and must stay working + at every stage (verified via `winit`'s `wayland` + `x11` backends, both enabled). +- **Windows support comes later** — once enough of the engine exists to be worth porting, a compiled + Windows build of the editor/engine is added. Code should avoid Linux-only assumptions so this port + stays cheap; `wgpu`/`winit` already abstract the platform layer. +- **Game export targets both Linux and Windows binaries.** A project built in the editor must be + exportable to standalone runnable binaries for both platforms (see Stage 16). The editor and the + exported game share the same engine runtime. + +## Cross-Cutting Principles (apply to every stage) + +- **Build tools, not games.** The engine ships composable building blocks (e.g. noise + modifiers, + not a "make me a world" button). Genre-specific behavior belongs 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. The same engine produces a tiny stylized game and a heavy realistic + one, differing only in which modules are present. +- **Scalable fidelity.** The renderer is a **data-driven pass pipeline** so a project can run a flat + unlit/low-poly look (or a stylized post effect like a VCR filter) or a full realistic stack with + shadows, SSAO, and bloom — paying only for the passes it enables. +- **Modules are the primary extension point.** A module registers engine logic (systems, components, + asset loaders) **and** editor integration (panels, menu items, tools, inspectors) **and** its own + settings, through one documented API. Anyone — including AI agents — can write a module. Native + modules are Rust crates implementing the `Module` trait; lightweight modules can be `rhai` scripts. +- **Editor- and script-editable types.** Engine component types (e.g. `Transform`, `Node`, `Script`, + materials, colliders) must be editable **both** from the editor UI **and** from scripts/code, + through the same reflected/serializable representation. From Stage 5 this is backed by a reflection/ + type registry, so any editor — or an **AI agent in the editor terminal** — can create and edit game + code and scene data in real time. New component types are added with this dual-editability built in. +- **Live reload by default.** Game scripts/assets are watched on disk; changes are picked up and + applied to the running editor without a restart (watcher foundation in Stage 6, full script reload + in Stage 10). Assets added earlier keep their loaders reload-friendly. +- **The editor grows every stage.** Each stage adds editor support for its systems through the + Stage-6 editor framework: a top menu, dockable panels, an undo/redo command stack, a module-driven + extension API, and a **settings/preferences framework** that every system contributes pages to + (engine prefs, editor prefs, input bindings, per-module settings, enable/disable modules). +- **Choice of subsystems.** Where the engine offers an advanced system it also offers a standard one + so each project can choose its trade-off — most concretely **audio**: a standard mixer/spatial + system (Stage 14, core) *and* a ray-traced propagation module (Stage 17), sharing components. +- **Comprehensive, not minimal, gameplay systems.** Physics, input, and the UI are first-class, full + systems (Stages 7, 8 & 9), not thin wrappers — they must cover the cases real games need. +- **Document as you build, including how to extend.** A stage is not complete until its systems are + documented under `docs/`. Every module ships documentation for **both** how to *use* it **and** how + to *create* one like it (an authoring guide), so the module ecosystem is approachable. + +--- + +## Status + +| Stage | Title | State | +|-------|-------|-------| +| 0 | Project Foundation | ✅ Complete | +| 1 | Math & Core Primitives | ✅ Complete | +| 2 | Window & Render Context | ✅ Complete | +| 3 | Scene Graph & Entity System | ✅ Complete | +| 4 | Basic 3D Rendering | ✅ Complete | +| **Phase 1 — General-purpose engine** | | | +| 5 | Engine Core Framework (modules, layers, assets, reflection, render graph) | ✅ Complete | +| 6 | Editor Framework & Project System | ✅ Complete | +| 7 | Input System (mapping + remappable actions) + gizmos | ✅ Complete | +| 8 | Comprehensive UI System | ✅ Complete (pieces 1–8 on `main`; the editor UI canvas — piece 9 — shipped as Stage 8.5 piece 7) | +| 8.5 | Reflection v2, Editor Redesign & Asset Database (foundation) | ✅ Complete — every component type is editor- and script-editable with no per-type code; asset database + UI canvas on `main` | +| 8.7 | Editor Play Mode (Play/Pause/Step/Stop) | ✅ Complete on `main` — snapshot/restore + play-state model, `App::step`/`tick_for`, toolbar + Ctrl+P/Ctrl+. + viewport tint, scene-swap host runner (eye-checked & approved 2026-06-16) | +| 9 | Physics Integration (comprehensive) | ✅ Complete on `main` — full sim/events/queries/joints/character controller + 2 examples + editor types/play-loop/collider gizmos + raycast debug probe (freeze-on-click; eye-checked & approved 2026-06-17) | +| 10 | Scripting, Live Reload & Editor Terminal | 🚧 Core done on `main` (eye-checked & approved 2026-06-17): `oxide-script` crate (`Script`, `.rhai` loader, sandboxed `ScriptEngine`, `ScriptModule`) + lifecycle (`init`/`update(dt)` via `ScriptHost`) + `Vec3`/transform engine API + **live reload** (`examples/script_spin`) + **editor integration** (`Script` addable; play-loop shares asset server/db → live `.rhai` edits update a *playing* scene) + **Console** (captures the `log` stream — script `print`/errors) + **command terminal** ($ → `sh -c`) + **interactive PTY terminal** (`portable-pty`+`vt100`, tabbed, auto-close on exit, Tab/arrows/Esc routed; runs shells/TUIs/`claude`). **Remaining:** richer script API (spawn/despawn + component add/edit beyond `Transform`); then the editor-UX batch (file explorer, native New/Open dialog, New-Script button, open-script-in-editor) | +| 11 | Animation System | ⬜ Planned | +| 12 | Particle System | ⬜ Planned | +| 13 | Shader System & Advanced Graphics | ⬜ Planned | +| 14 | Standard Audio System | ⬜ Planned | +| 15 | Engine Content & Prototyping Kit | ⬜ Planned | +| 16 | Windows Support & Game Export | ⬜ Planned — **Phase 1 milestone** | +| **Phase 2 — Built-in modules** | | | +| 17 | Ray-Traced Spatial Audio (module) | ⬜ Planned | +| 18 | Developer Console & Cheats (module) | ⬜ Planned | +| 19 | Procedural Toolkit (module) | ⬜ Planned | +| 20 | Terrain System (module) | ⬜ Planned | +| 21 | Open World Support (module) | ⬜ Planned | +| 22 | Pathfinding & NPC AI (module) | ⬜ Planned | +| 23 | Water System (module) | ⬜ Planned | + +> Backlog (post-roadmap capabilities and additional modules) is at the end of this file. + +--- + +## Stage 0 — Project Foundation ✅ +**Goal:** A buildable, testable Rust project with core structure in place. + +### Deliverables +- Cargo workspace layout (`engine/`, `editor/`, `examples/`, `tests/`) +- Basic logging and error handling infrastructure +- CI-ready: `cargo build`, `cargo test`, `cargo clippy`, `cargo fmt` all pass +- Empty integration test harness +- `README.md` with engine description, build instructions, and install instructions +- `install.sh` — builds release binaries and installs to `/usr/local` (or `$PREFIX`) +- Editor stub: `oxide-editor` binary that starts, logs, and exits cleanly + +### Test Criteria +- `cargo build` succeeds from clean checkout +- `cargo test` runs (even with zero tests) without error +- Clippy reports zero warnings with `deny(warnings)` +- `install.sh` completes without errors on a clean Linux system +- `oxide-editor` binary runs after installation + +--- + +## Stage 1 — Math & Core Primitives ✅ +**Goal:** Solid math foundation every other system will depend on. + +### Deliverables +- Choose and integrate math library (`glam` preferred) — done (`glam` with `serde`) +- `Transform` type: position, rotation (quaternion), scale — `engine/src/math/transform.rs` +- `AABB`, `Ray`, `Plane`, `Frustum` types — `engine/src/math/{aabb,ray,plane,frustum}.rs` +- Common utility types: `Color`, `Rect`, `Range3` — `engine/src/math/{color,rect,range3}.rs` +- Unit tests for all types (edge cases: zero scale, gimbal lock paths, etc.) — 69 unit tests +- **Editor:** no visible UI yet; math types available via `oxide_engine::math` / `prelude` + +### Test Criteria +- 100% unit test coverage on all math types — ✅ every type has a `tests` module +- Fuzz transform composition (random chains should remain stable) — ✅ `tests/` `stage1::fuzz_transform_chains_stay_stable` +- Benchmark: 1M transform multiplications under 10ms — ✅ ~5.1ms (`cargo bench -p oxide-engine`) + +### Notes +- `Transform` stores decomposed TRS; composition takes an exact fast path for + uniform scale and re-decomposes from the matrix for the non-uniform case. +- `Range3` is a *value* range (clamp/lerp/remap), distinct from `Aabb` (geometry). +- Runnable example: `cargo run -p oxide-examples --bin math_demo`. + +--- + +## Stage 2 — Window & Render Context ✅ +**Goal:** A window opens, GPU context is acquired, a colored screen is displayed. + +### Deliverables +- `winit` integration for window creation and event loop — `engine/src/window/` (`App` trait, `AppCtx`, `WindowConfig`, `run`) +- `wgpu` device/queue/surface setup — `engine/src/render/` (`Gpu`, `RenderContext`) +- Clear-color render loop (configurable clear color) — `render::clear_view`, `AppCtx::set_clear_color` +- Resize handling — `RenderContext::resize`, driven by the event loop; minimized windows clamp to 1×1 +- Input event plumbing (keyboard, mouse — raw events only, no abstraction yet) — every `WindowEvent` forwarded to `App::event`; types re-exported via `window::event` +- **Editor:** opens its own window using this infrastructure; displays a placeholder viewport (dark clear, raw input logged, `Ctrl+Q` quits) + +### Test Criteria +- Window opens on Linux without errors — ✅ manual sign-off (2026-06-11, Wayland, RADV/Vulkan) +- Resize does not crash or produce artifacts — ✅ manual sign-off +- Clear color changes are reflected immediately — ✅ manual sign-off (`hello_window` keys 1–5/Space) +- Manual: run the `hello_window` example and confirm stable 60+ FPS on minimal GPU — ✅ ~3700 FPS (RX 9070 XT, Mailbox present mode) +- Automated: headless GPU clear verified by pixel readback — ✅ `tests/` `stage2::headless_clear_fills_texture_with_clear_color` + +### Notes +- Docs: `docs/windowing.md`, `docs/render-context.md`. +- `wgpu`/`winit` are re-exported (`oxide_engine::wgpu` / `::winit`) so consumers + don't need to version-match their own copies. +- egui editor panels intentionally deferred to Stage 3 (Stage 2 editor scope is + the placeholder viewport only). + +--- + +## Stage 3 — Scene Graph & Entity System ✅ +**Goal:** Entities with transforms exist in a hierarchy; scene can be queried and mutated. + +### Deliverables +- Choose and integrate ECS — done (`hecs`, re-exported as `oxide_engine::hecs`) +- `Scene` type: add/remove/query entities — `engine/src/scene/graph.rs` +- `Transform` hierarchy (parent-child with world-space resolution) — `Scene::world_transform` / `world_transforms` +- `Node` component: name, enabled flag — `engine/src/scene/node.rs` +- Scene serialization/deserialization (RON) — `Scene::to_ron` / `from_ron`, stable index-based format +- `examples/scene_basic`: create a hierarchy, print world transforms — done +- **Editor:** scene hierarchy panel; select, rename, enable/disable, reparent nodes — done (egui Hierarchy + Inspector panels) + +### Test Criteria +- Parent transform propagates correctly to children — ✅ unit + `tests/` `stage3` +- Removing a parent removes or detaches children (configurable) — ✅ `DespawnPolicy::{Recursive, DetachChildren}` +- Serialize → deserialize round-trip produces identical scene — ✅ byte-identical re-serialization +- 10,000 entity scene with 5-level deep hierarchy resolves in under 1ms — ✅ ~0.6ms (`cargo bench -p oxide-engine --bench scene`) +- Manual: editor hierarchy/inspector panel verified by maintainer (2026-06-11, Wayland, RADV/Vulkan) + +### Notes +- Docs: `docs/scene.md`. +- The hierarchy is owned by `Scene` (ordered `roots`/`children`), not stored as + components, for deterministic ordering and cheap reparenting; the ECS still + owns all entity data, so later stages attach their own components. +- egui is an **editor-only** dependency: the engine exposes a generic post-clear + draw hook (`App::render(&RenderCtx)`) and the editor's `EguiLayer` draws + through it, keeping egui out of the engine's dependency tree. + +--- + +## Stage 4 — Basic 3D Rendering ✅ +**Goal:** Meshes are loaded and rendered with a simple material system. + +### Deliverables +- `Mesh` type: vertex buffer (position, normal, UV), index buffer +- GLTF loader (static meshes only, no animation yet) +- Forward renderer: depth buffer, basic lit pass +- `Material` type: albedo color/texture, roughness, metallic (PBR-lite) +- Camera component with perspective projection +- `examples/hello_mesh`: load a GLTF cube/sphere and render it +- **Editor:** 3D viewport renders the active scene; camera orbit/pan/zoom controls; material inspector panel + +### Test Criteria +- GLTF primitives (cube, sphere, plane) render correctly +- Depth ordering is correct (no z-fighting on simple scenes) +- Camera movement updates the view matrix correctly +- Manual: inspect rendered output for correct lighting on a sphere — ✅ maintainer sign-off (2026-06-12) + +### Notes +- Docs: `docs/rendering.md`. +- The forward renderer has automatic render-backend fallback + (Vulkan/Metal/DX12 → GL → software) so it runs on a wide range of GPUs. +- The hardcoded forward pass from this stage is generalized into a data-driven + **render pass pipeline** in Stage 5 — later passes (shadows, post-process, + overlay UI) compose into it rather than rewriting the renderer. + +--- + +## Stage 5 — Engine Core Framework +**Goal:** The structural "spine" every later system plugs into: a module system, scene/object +**layers & tags**, a central **asset server**, a minimal **reflection/type registry**, and a +**data-driven render pass pipeline**. Built now, while the engine is small, so later stages add +capabilities without rewriting the core. + +### Deliverables +- **Module system + app builder.** An `App`/`Engine` assembled by registering `Module`s. A + `Module::build(&mut App)` hook registers: update systems (with an explicit ordering/schedule), + component types, asset loaders, and (via Stage 6) editor integration. `DefaultModules` bundles the + built-in set. Math/window/render/scene are wrapped as built-in modules; subsystems from Stage 9 on + are built as **their own feature-gated crates** (`oxide-physics`, `oxide-audio`, …) so an exported + game compiles in only what it registers. +- **System scheduling.** A simple, deterministic ordered schedule (e.g. `Input → PreUpdate → Update → + PostUpdate → Render`) that modules attach systems to, with a fixed-timestep slot reserved for + Stage 9 physics. +- **Layer & Tags.** + - **`LayerMask`** — a 32-slot bitmask with a **project-level named-layer registry**. One shared + definition consumed by physics collision groups + sensor filtering (Stage 9), **camera + visibility masks** (which layers a camera renders — first-person view models, editor-only gizmo + layers, minimap cameras), and **scene/raycast query filtering**, plus editor per-layer + show/hide/lock. + - **Groups/tags** — the *multi-valued* counterpart to the *single-valued* `Layer` (Unity model: an + entity is on one layer but in any number of groups). Membership lives in the `Tags` component; + the project's valid group names live in a `GroupRegistry` so the editor offers a fixed vocabulary + to pick from. Separate from the hot-path `LayerMask`. +- **Asset server + handles.** A central registry returning typed, ref-counted **handles** + (`Handle`, `Handle`, …) with async loading and a pluggable loader registry. Stage 4's + GLTF/texture loading is migrated onto it. This one system underpins live reload (Stage 10), + streaming (Stage 21), and export packing (Stage 16), so it lands before more loaders accrue. +- **Reflection / type registry (minimal).** Register a component type once and get serialization + (serde/RON), a generic editor inspector (Stage 6), and script access (Stage 10) — the backbone of + the *dual-editable types* principle. Existing hand-written inspectors are migrated as the registry + matures; every component type from Stage 7 on is dual-editable by construction. +- **Data-driven render pass pipeline.** Generalize Stage 4's forward pass into an ordered, composable + list of passes (depth, lit, transparent, post-process, overlay) with shared render targets. A + project/module enables only the passes it needs — this is the mechanism behind *scalable fidelity* + (a flat low-poly look vs. a full realistic stack) and the insertion point for Stage 13's + post-process effects and Stage 8's UI overlay. +- **Editor:** no new dedicated panel beyond wiring — the hierarchy/inspector now read the reflection + registry, the viewport gains a layer visibility toggle, and the groundwork is laid for Stage 6's + module/extension framework. + +### Delivered (all on `main`) +- ✅ **Layers, groups & tags** (`oxide_engine::layer`) — `LayerMask`, `LayerRegistry`, single-valued + `Layer`, multi-valued `Tags` + `GroupRegistry`. +- ✅ **Reflection/type registry** (`oxide_engine::reflect`) — generic name-keyed component access. +- ✅ **Asset server + handles** (`oxide_engine::asset`) — ref-counted `Handle`, dedup, loaders, + background load, in-place reload; glTF migrated onto it. +- ✅ **Module system + scheduling** (`oxide_engine::app`) — `App` + `Module` + `Schedule` + (phases incl. fixed-timestep), enable/disable/remove; `CoreModule`/`RenderModule`/`DefaultModules`. +- ✅ **Data-driven render pass pipeline** (`oxide_engine::render::RenderPipeline`) — composable + `RenderPass` list, `ClearPass`/`ForwardPass`, camera `LayerMask` visibility; editor viewport + + `hello_mesh` retrofitted. Maintainer manual sign-off 2026-06-12 (viewport + `hello_mesh` render + identically). + +> **Known tech-debt (tracked):** there are now two `App` types — the engine container +> `oxide_engine::app::App` and the windowing `oxide_engine::window::App` trait (the per-window event +> handler). To avoid the clash the core `App` is intentionally **not** in the prelude (import it as +> `oxide_engine::app::App`); `window::App` stays in the prelude. Resolve by renaming the windowing +> trait (e.g. `WindowApp`/`AppHandler`) when the editor adopts the core `App` loop in Stage 6/7 — a +> GUI-affecting change best done with a manual-test pass, not in isolation. + +### Test Criteria +- An app composed purely by registering modules runs; removing a module removes its systems, + components, and assets with no dangling references +- A feature-gated module excluded at compile time produces a binary with none of its code +- `LayerMask` filtering is correct and shared: the same named layer drives a render visibility mask + and (stubbed) a query filter identically +- The asset server returns ref-counted handles; loading the same asset twice yields one resource; + dropping all handles frees it +- A component registered with the reflection registry round-trips through RON and is read/written + generically (proving the inspector/script path) without type-specific code +- The render pipeline runs Stage 4's scene through the new pass list with identical output, and a + pass can be added/removed without touching the renderer core +- Benchmark: module/system scheduling overhead is negligible vs. the Stage 4 hardcoded loop + +--- + +## Stage 6 — Editor Framework & Project System ✅ +**Goal:** Turn the editor from a fixed set of panels into a proper, extensible application shell with +a **project system** — so every later stage (and every third-party module) plugs UI, tools, and +settings into a consistent host, and real projects can be created, opened, and saved. + +### Deliverables +- **Editor shell.** A top **menu bar** (File / Edit / View / Project / Window / Help / module menus), + a **dockable/resizable panel system**, status bar, and a registry of panels modules can add to. +- **Command / undo-redo stack.** A central, editor-wide command stack so every tool (transform + gizmos, inspector edits, and later sculpt/paint/scatter) is undoable/redoable through one + mechanism — landed here so all subsequent editor tools are undoable for free. +- **Module → editor extension API.** The Stage-5 `Module` gains an editor hook to contribute: menu + items, dockable panels, viewport tools/gizmos, component inspectors (via the reflection registry), + and settings pages. This is *the* mechanism by which "anyone can write a module" that extends both + engine logic and the editor. +- **Settings / preferences framework.** A unified, serialized settings system with pages contributed + by the engine, the editor, and each module: + - **Editor preferences** (theme, layout, viewport, shortcuts) and **engine preferences** + (render/quality defaults) — grown incrementally as later stages add their options. + - **Module management:** list installed modules, **enable/disable** them, and edit each module's + own settings page. + - Input bindings get their settings page here once Stage 7 lands. +- **Project system.** Create / open / save a **project**: a project file (RON) plus a defined asset/ + scene/script folder layout, a list of enabled modules and their settings, and per-project + preferences. A recent-projects list and a "new project" flow. Scenes load/save within a project. +- **File-watcher foundation.** Watch the project's asset/scene folders (`notify`) and reload changed + assets live — the groundwork for Stage 10's full script hot-reload and the "edit with any external + editor or AI agent" workflow. +- **Editor:** the existing hierarchy/inspector/viewport are reparented into the new docking shell; + a Project panel/asset browser; a Preferences window with the settings pages above. +- **Resolve the Stage-5 `App` naming clash.** As the editor adopts the engine's core + `oxide_engine::app::App` loop here, rename the windowing `window::App` trait (e.g. `WindowApp`) so + the core `App` can live in the prelude unambiguously (see the Stage-5 tech-debt note). + +### Test Criteria +- Panels can be docked, undocked, resized, and restored across restarts (layout persists) +- A trivial test module adds a menu item, a panel, and a settings page purely through the extension + API — with no edits to editor core +- Undo/redo correctly reverses and re-applies a sequence of edits (e.g. rename, reparent) through the + single command stack +- Creating, saving, closing, and reopening a project restores its scenes, enabled modules, and + settings unchanged (RON round-trip) +- Enabling/disabling a module in settings adds/removes its systems and editor contributions live +- Editing a watched asset file on disk updates the running editor without a restart +- Manual: maintainer creates a project, rearranges the layout, toggles a module, and reopens it + +--- + +## Stage 7 — Input System (mapping + remappable actions) + gizmos ✅ +**Goal:** A comprehensive input abstraction used by all higher-level systems, with full per-key +edge detection and a remappable named-action layer — and the editor's viewport transform gizmos, +built on the Stage-6 command stack. + +**Status (2026-06-14):** ✅ Complete. Shipped in six pieces — `InputState` (1), `ActionMap` / +`Binding` (2), axis + 2D-axis actions (3), editor flythrough camera (4), input-bindings +preferences page with disk persistence (5), gizmo math + viewport tab sizing + viewport +gizmos (6a/6b/6c). Final maintainer test surfaced three issues (snap mid-drag, uniform-scale +sensitivity, sphere winding) — all fixed in the same stage. Gamepad bindings are deferred +("where available" in the deliverables); the rest is in. + +### Deliverables +- `InputState`: keyboard, mouse button, mouse delta, scroll, gamepad (where available) +- **Per-key edge + state queries** for any physical key/button: `pressed` (this frame), + `released` (this frame), and `held`/`down` (current state) — i.e. map any key to its press, up, + and down events. +- **Named-action mapping with defaults and remapping.** Bind named actions (e.g. `"Jump"`) to one or + more physical inputs, each action carrying a **default** binding (e.g. `Space`). Game code queries + only the action name (`input.action_pressed("Jump")`) and never the physical key, so a project's + settings screen can remap `"Jump"` to any other key without touching game code. +- **Bindings persistence**: action maps load/save (RON) so game settings can store user remaps, + surfaced through the Stage-6 settings framework. +- Input consumed/propagated model (UI/editor can consume before game) +- Axis/2D-axis actions (e.g. movement) composed from keys and/or gamepad sticks +- `examples/input_debug`: print all actions and raw input each frame; `examples/input_remap`: + rebind an action at runtime and show game code unaffected +- **Editor:** input bindings editor page (assign/clear/remap action bindings, restore defaults) in + the settings framework; live input-state debug overlay; **viewport flythrough camera** (WASD/QE + move, mouse-look, with Shift to accelerate) layered on top of the existing orbit/pan/zoom controls +- **Editor transform gizmos:** on-screen manipulators for the selected entity — **translate** (axis + arrows + plane handles), **rotate** (axis circles), and **scale** (axis/uniform handles) — dragged + directly in the viewport, every edit recorded on the Stage-6 undo stack. Includes a **snap system** + with configurable increments (grid distance, angle step, scale step) and a modifier key to toggle + snapping; switch active tool with hotkeys (e.g. W/E/R). With gizmos in place, the inspector's + transform fields become **directly type-editable** (click to type an exact value), not drag-only. + +### Test Criteria +- `pressed`/`released` fire exactly once on the correct frame; `held` reflects current state +- Dragging each gizmo axis moves/rotates/scales the selection on exactly that axis; snapping + constrains the result to the configured increment; typed inspector values apply exactly; each is + undoable/redoable through the command stack +- Action bindings can be changed at runtime and persisted/restored via RON +- Multiple keys can be bound to one action; one key can drive multiple actions +- Remapping an action changes behavior while game code (querying the action name) is unchanged +- Mouse delta is correct across frame boundaries (no jump on first frame) + +--- + +## Stage 8 — Comprehensive UI System + +**Status (2026-06-15): 🟡 In progress.** Pieces 1–7 complete and on `main`: + +1. ✅ Widget tree + layout (`oxide_engine::ui::{widget, style, layout}`). +2. ✅ Styling & theming (`visual`, `theme`; cascade default → named → per-instance). +3. ✅ Text shaping + glyph atlas (`text/{font, atlas, shape}` via `ab_glyph`). +4. ✅ 2D overlay render pass — screen-space (4a) + world-space `UiPanel` (4b). +5. ✅ Input routing (`routing::Router`; hit-test + hover / press / focus state machine). +6. ✅ Events + data binding (immediate-mode `RouterFrame::clicked_left(...)` etc. + typed `WidgetValue`). +7. ✅ `examples/ui_menu` — themed main menu + draggable slider + checkbox settings. + +Remaining for sign-off: + +8. ✅ `examples/ui_hud` (health / ammo / minimap overlay + centre crosshair, composited over + the Stage-4 forward pass) — signed off on `main` 2026-06-15 after maintainer eye-check. The + atlas cache-hit claim it demonstrates is also covered by an automated GPU test + (`render::ui_pass::atlas_caches_glyphs_and_reaches_steady_state`). +9. ⬜ Editor **UI canvas** — visual document builder. + +**Goal:** A first-class, **in-game** UI system — widgets, layout, styling, text, and input routing — +that ships inside exported games. This is distinct from the editor's `egui` (which stays +editor-only): this UI is an engine system a project uses to build its menus, HUDs, and tools, and it +consumes input through the Stage 7 model and renders through the Stage 5 pass pipeline. + +### Deliverables +- **Widget tree** owned by the engine and renderable in shipped games: containers/panels, label/ + text, button, image, checkbox, radio, slider, drop-down, text input, progress bar, and a + scrollable list/view +- **Layout system**: stack (row/column), grid, and anchor/dock layouts; padding/margin, alignment, + and sizing modes (fixed / grow / fit-content); resolution- and **DPI-aware** scaling +- **Styling & theming**: per-widget styles plus reusable themes (colors, fonts, spacing, borders), + overridable per instance +- **Text rendering**: TTF font loading, a glyph atlas, alignment, wrapping, and multi-font support +- **Input integration**: UI consumes input *before* the game using Stage 7's consume/propagate model; + hover/focus/press states; keyboard/gamepad focus navigation. (The Stage 7 bindings/remap settings + screen is itself buildable with this UI system.) +- **Events & data binding**: widget callbacks/events and binding widget values to game data +- **2D overlay pass** integrated as a Stage-5 render pass (batched draws), plus optional + **world-space UI** (a UI panel rendered on a quad in 3D) +- **Serializable, dual-editable UI documents** (RON): layouts round-trip through the engine's + representation so they are authored in the editor *and* editable from scripts/AI agents +- `examples/ui_menu` (main menu + settings, including action remapping) and `examples/ui_hud` + (health/ammo/minimap overlay) +- **Editor:** a visual **UI canvas** to build and arrange UI documents — widget palette, drag/resize, + property inspector, and live preview — saving the same RON the runtime loads + +### Test Criteria +- Widgets render and lay out correctly across window sizes and DPI scale factors +- Input routing is correct: UI consumes clicks/keys over its widgets, and the game receives input + only where the UI does not consume it +- Hover/focus/press states and keyboard/gamepad focus navigation behave correctly +- Text renders crisply with correct wrapping and alignment; the atlas handles large glyph sets +- A UI document round-trips through RON unchanged (proving dual-editability) +- Manual: build a menu in the editor's UI canvas, run an example, and interact with it (including + remapping an action from the settings screen) + +--- + +## Stage 8.5 — Reflection v2, Editor Redesign & Asset Database (foundation) + +**Status: ✅ Complete (2026-06-16).** All pieces (1–7) on `main`; piece 7c (UI canvas) eye-checked +& approved. Inserted before Stage 9 (and before Stage 8's deferred piece 9) because every later stage +adds component types (colliders, animation clips, particle emitters, audio sources, …) and each one +would otherwise need a hand-written editor inspector and hand-wired add/remove plumbing. Doing this +**now** was the cheapest it would ever be — it stops that per-type cost from accruing across Stages +9–16. + +> **Known minor follow-up (UI canvas):** egui logs `Widget rect … changed id between passes` while +> interacting with the canvas — id instability from dynamically-sized property widgets across egui's +> two-pass layout. Harmless (egui recovers each frame) but noisy; quiet it by scoping the canvas's +> per-widget sections under stable `ui.push_id(...)`s. Low priority. + +**Why:** Stage 5 shipped a *whole-value* reflection registry (`oxide_engine::reflect` round-trips a +whole component as RON). The editor inspector, however, is still **hand-coded per type** +(`transform_inspector`, `mesh_inspector` in `editor/src/shell.rs`), and component add/remove is +bespoke per type (`PendingAction::AddMesh/RemoveMesh`). This finishes the project's **dual-editable +types** principle: a component's public fields should surface automatically in the inspector and over +scripts (Stage 10), the Unity/Godot model — *public mutable fields appear as editable inspector +fields with no per-type code.* + +### Component model — node-baked vs modular (2026-06-15 decision) + +The maintainer surfaced a distinction the editor must honor explicitly: + +- **Node-baked (essential):** every entity inherently carries them — they're + part of *being a node*, not a feature the user adds. Today: `Node`, + `Transform`, `Layer` (auto-attached on `Scene::spawn`). Always present, + single-instance, rendered in a fixed canonical order, **no** enable + checkbox, **no** remove button, **no** drag-reorder handle, **not** offered + by the Add Component menu. +- **Modular:** the user's choice — `MeshRenderer`, `Camera`, `DirectionalLight`, + future `RigidBody`, particle emitters, audio sources, custom scripts. Addable + from the registry, removable, disable-able, reorderable. + +The editor enforces this with `ESSENTIAL_COMPONENTS = ["Node", "Transform", +"Layer"]` (see `editor/src/shell.rs::is_essential_component`). Future +node-baked additions extend that list; modular ones just register through +`register_addable`. + +**Layers & groups in the inspector (2026-06-16 decision — Unity model "A").** +The single-valued `Layer` renders as a single-select dropdown (with a Layer +Names editor); gameplay **Groups** (multi-valued, backed by `Tags` + a +`GroupRegistry`) render as a multi-select dropdown (with a Groups editor). Both +sit in the node-baked section above the modular list. An archetypal ECS allows +only **one component of a given type per entity**, so a second mesh/collider/etc. +lives on a **child** entity (the Add Component menu's "as child" path); several +*distinct* addable types (`MeshRenderer`, `Camera`, `DirectionalLight`) is what +makes multi-component nodes and drag-reorder exercisable. + +**Multi-component-of-same-type** (e.g. two colliders, two meshes) is not +supported by archetypal ECS (`hecs` allows one per type per entity) and +**won't be lifted in Oxide**. The two principled patterns the engine offers: + +- **Child entities** (Bevy-style): each "additional" piece is its own + entity, parented to the owner. Composes with the existing hierarchy + + drag-drop reparent + per-child enable/disable. +- **Submeshes / multi-slot components** (Unity-style): a single component + wraps a list internally (e.g. a `Mesh` asset can have multiple submeshes + with their own material slots). + +Stage 9 (Physics) picks for colliders; Stage 13 (Shaders) may revisit for +meshes. The Add Component menu surfaces this clearly when a user tries to +add a duplicate ("Add Child with …" affordance — TODO in piece 4 polish). + +### Deliverables + +- **Reflection v2 — per-field reflection + a `#[derive(Reflect)]` macro.** A new + `oxide-engine-derive` proc-macro crate generates, for a struct's public fields, a field table + (name + type + typed get/set) re-exported from `oxide_engine`. `reflect.rs` grows from whole-value + to field-level access while keeping the existing name-keyed registry API. Pure logic, fully + unit-testable. +- **Generic reflection-driven inspector.** One inspector that walks a selected entity's registered + components and renders a widget per field by type (`f32`→drag, `bool`→checkbox, `Color`→picker, + `Vec3`→vec3 drag, enum→combo, `Handle`→asset picker), falling back to a RON text field for + unknown types. The hand-written `transform_inspector` / `mesh_inspector` are **deleted** and proven + equivalent. All edits route through the existing undo/redo command stack. +- **Editor redesign items.** Hierarchy **right-click context menu** (Add Child / Add prefab / Rename + / Duplicate / Delete); inspector **"Add Component ▾"** menu enumerated from the type registry; + remove-component control per component. (The deferred Stage-8 piece 9 — the visual UI canvas — lands + on top of this redesign so its property inspector is reflection-driven, not bespoke.) +- **Prefabs / archetypes (lightweight).** A named "spawn a thing that already carries these + components" entry so the hierarchy add-menu is data-driven (an entity is its component set; this is + not a parallel "object type" system). +- **Asset database + typed project folders.** `assets/` gains typed subfolders + (`fonts/`, `textures/`, `models/`, `audio/`, `ui/`); an asset database maps **project-relative + paths ↔ stable ids ↔ `Handle`** so scenes and UI documents reference `"fonts/Inter-Regular.ttf"` + rather than absolute system paths (a prerequisite for clean game export at Stage 16). The Project + panel becomes an asset browser; "import" = drop a file into the right folder and let the watcher + register it. A bundled default UI font lands here (resolves the Stage-8 follow-up). +- **`Handle` as a reflected field type** so the generic inspector renders an asset-picker filtered + by type — selecting a UI element then choosing its font is exactly this. +- **Docs:** `docs/reflection.md` (per-field model + derive), `docs/assets.md` (database + folders + + picker), and an editor-redesign note; update `architecture.md`. + +### Piece breakdown (each = one green commit) + +1. ✅ `oxide-engine-derive` crate + `Reflect` trait (per-field name/type/get/set) + derive — pure + logic, unit-tested, on `main`. Public-only fields + `#[reflect(skip)]`; per-field RON get/set. +2. ✅ `reflect.rs` per-field get/set through the registry via `register_reflected::` + (`field_infos` / `get_field` / `set_field`); Transform + Node derive `Reflect`. Whole-value API + unchanged. Pure logic, on `main`. +3. ✅ Generic reflection-driven inspector (signed off on `main` 2026-06-15 after eye-check). Split: + - **3a**: `EditorState.registry` (Transform + Node reflected) + generic + `SetFieldCmd{entity,type,field,before,after}` with merge-coalescing. Pure logic. + - **3b**: the inspector UI — walks the registry, one heading per component, one typed widget per + field (f32→drag, bool→checkbox, Vec3→3 drags, String→text, `Quat`→Euler degrees, unknown→RON + fallback). Deletes the hand-written `transform_inspector`; `mesh_inspector` stays until piece 4. + `Node` skipped (name/enabled shown by the header). Enum→combo deferred (needs variant reflection). +4. Hierarchy redesign + component lifecycle (GUI) — → `dev`, eye-check: + - Hierarchy **right-click** context menu (Add Child / Add prefab / Rename / Duplicate / Delete). + - **Drag-and-drop reparenting in the hierarchy**, and **remove the inspector's Parent dropdown** + (maintainer request 2026-06-15: the dropdown lists every entity, so it doesn't scale to large + scenes — reparenting belongs in the tree via drag-and-drop). + - Inspector **"Add Component ▾"** (enumerated from the registry) + per-component remove control; + make `MeshRenderer` reflected and retire the bespoke `mesh_inspector`. + - Enum→combo widget (add variant reflection to `#[derive(Reflect)]` so enum fields like + `PrimitiveShape` get a dropdown instead of the RON fallback). +5. Prefab/archetype spawn entries (logic + a little GUI) — split: + - ✅ **5a** (engine logic, on `main`): `oxide_engine::prefab` — `ComponentSpec`/`Prefab`/ + `PrefabRegistry`, data-driven (component name + RON applied via the `TypeRegistry`), `spawn`/ + `spawn_child`/`unknown_specs`. Pure logic, unit-tested; `docs/prefabs.md`. + - 🟡 **5b** (editor GUI, on `dev` — awaiting eye-check): `EditorState.prefab_registry` seeded + with built-in prefabs (Empty/Cube/Sphere/Plane/Camera/Directional Light); hierarchy add-menus + (toolbar Root/Child, row right-click Add Child, empty-area Add Root) are submenus listing them. + Plain `AddRoot`/`AddChild` retired (the `Empty` prefab covers a bare node). +6. Asset database + typed folders + `Handle` reflected + asset-picker widget (logic → `main`; + picker GUI → `dev`) — split: + - ✅ **6a** (engine logic, on `main`): `oxide_engine::asset::AssetDatabase` — typed `assets/` + subfolders (`AssetKind`: fonts/textures/models/audio/ui), stable `AssetUid` ↔ relative-path + manifest (`assets.manifest`), `scan`/`register`/`open`/`save`, and uid→`Handle` resolution + through the `AssetServer`. Unit-tested round-trip + moved-project case; `docs/assets.md`. + - ✅ **6b** (engine logic, on `main`): `asset_ref_target` parses an `AssetRef`/`Handle` + field's syntactic `type_name` to its target type, and `AssetKind::for_handle_target` maps that + to the picker's filter kind. Added the serializable `AssetRef` reference type (the form a + component stores — `Option` that `resolve`s to a `Handle`). Pure logic, unit-tested. + - 🟡 **6c** (editor GUI, on `dev` — awaiting eye-check): `EditorState.asset_db` (opened + scanned + on project open/create, rescanned on watcher events); Project panel is an asset browser + (typed-folder sections from the database + Rescan); `field_widget` asset-picker for + `AssetRef`/`Handle` fields (lists assets of the matching kind, stores the `AssetUid`); + bundles **Inter** (SIL OFL) as the default UI font, seeded into new projects' `fonts/`. The + first component *using* an `AssetRef` field arrives with piece 7 (UI canvas). +7. Migrate Stage-8 piece 9 (UI canvas) onto the reflection-driven inspector — split: + - ✅ **7a** (engine logic, on `main`): widget-tree authoring primitives in `oxide_engine::ui` — + `WidgetPath` (positional addressing) + `Widget::{get_path,insert_child,remove_path,move_subtree}` + (the basis for the canvas's undoable add/remove/move). Unit-tested. + - ✅ **7b** (engine logic, on `main`): `FontLoader` (default-registered, makes `.ttf`/`.otf` + loadable via `AssetServer`) + `VisualStyle.font_asset: Option>` (the engine's + first `AssetRef` field; overrides the `FontRef` descriptor when set). Unit-tested. + - ✅ **7c** (editor GUI, on `main` — eye-checked & approved 2026-06-16): the UI canvas (`PanelKind::UiCanvas`, + **centre tab beside the Viewport** — it needs the large central area), in a scroll area — + open/new a `UiPanel` document; widget-tree panel (selectable, using 7a), Add ▾ palette + (Leaf/Row/Column/Grid/Anchor), Remove, scaled canvas preview (egui painter), and a **type-aware + property panel**: id/text, kind-specific (Leaf intrinsic; Stack direction/gap/main-align; Grid + cols/rows/gap), visual (background/foreground/font size/**font asset picker**), and a Layout + section (sizing, align x/y, padding, margin, **anchor preset**) so widgets are sized and + positioned. Save to a `ui/` asset; edits route through `SetUiPanelCmd` (undoable). Follow-ups: + engine-font-accurate preview via the real `UiOverlayPass`; change-kind; widget drag-reorder + (the 7a `move_subtree` primitive is ready); anchor offset/handles for free drag-positioning. + +### Test Criteria + +- A brand-new component (e.g. a `Timer { repeating: bool, duration: f32 }`) becomes fully editable in + the inspector **and** over RON/scripts with only `#[derive(Reflect, Serialize, Deserialize)]` + one + registration line — **no per-type editor code**. +- The generic inspector reproduces the old Transform/Mesh inspectors' behavior (positions, rotation + in degrees, scale, mesh shape) — verified by an automated round-trip plus a manual eye-check. +- An asset referenced by project-relative path resolves to the same `Handle` across save/load and + survives moving the project directory. +- Selecting a UI element in the editor and picking a font from the asset browser updates the rendered + text. +- Manual: right-click the hierarchy to add/duplicate/delete; add a component from the inspector; pick + an asset for a `Handle` field. + +### Editor-UX follow-ups (deferred from piece 6c — maintainer feedback 2026-06-16) + +Piece 6c shipped a *functional* asset browser, but the maintainer flagged UX +improvements to do **properly later** (not blockers; kept out of their stages to +avoid feature creep mid-stage). **Reconfirmed + expanded 2026-06-17** (during +Stage 10): do this batch **after Stage 10 pieces 4 (terminal panel) & 5 (error +console)**. Each is GUI → `dev` + eye-check. + +- **Unity-style asset/file explorer in the Project panel.** Today the typed + folders (`fonts/ textures/ …`) are fixed, so importing means creating/using a + folder with that exact name. Wanted: a real file explorer — create/rename/move + folders, rename/delete assets, **drag files in from the OS** to import, context + menus, breadcrumb navigation. The `AssetDatabase` already tracks arbitrary + relative paths, so this is a UI/interaction layer over it (re-classify by + folder still applies; loose files fall back to extension). +- **Proper file/path opener for New/Open Project.** Today both take a raw typed + path, which the maintainer confirms "is very hard to use". Wanted: a native + file/folder picker (a `rfd`-style dialog, working on Wayland + X11) and/or a + better-designed in-editor chooser, instead of a free-typed path field. +- **"New Script" button on the `Script` component inspector** (Stage 10 + follow-up). Create a new `.rhai` from a template into `assets/scripts/` and + auto-assign it to the component, without leaving the editor — today scripts + must be authored as files outside the editor. The `.rhai` loader + + `AssetKind::Script` already exist; this writes a template file + registers it. +- **Open a script in an editor** (Stage 10 follow-up). Double-click / button to + open a `.rhai` in an in-editor text view or launch `$EDITOR` / a configured + external editor; edits flow back through the existing live reload. + +--- + +## Stage 8.7 — Editor Play Mode + +**Status: ✅ Complete on `main`** (all three pieces; piece 3 GUI eye-checked & approved 2026-06-16). +Inserted before Stage 9 because Stage 9 already assumes the editor can **play/pause/step** the +simulation to test physics, yet no piece defined that loop. Decided with the maintainer 2026-06-16: +a **hybrid** model — in-editor play as the primary loop now, a standalone launch later. +Docs: [`docs/play-mode.md`](docs/play-mode.md). + +### Piece breakdown +1. **Play-state model + snapshot/restore** — ✅ `main`. `PlayState {Editing,Playing,Paused}` + + `play_snapshot` on `EditorState` (`enter_play`/`toggle_pause`/`stop`); registry-aware + `oxide_engine::scene::SceneSnapshot` (captures reflected components + intrinsic `Tags`/ + `DisabledComponents`, not just `to_ron`'s hierarchy), restoring the scene bit-for-bit. +2. **Schedule driver primitives** — ✅ `main`. `App::step()` (one fixed tick, accumulator bypassed) + and `oxide_editor::play::tick_for` (the testable Playing→Frame / Paused+Step→FixedStep / else Idle + decision). +3. **Toolbar + viewport tint + runner** — ✅ `main`. Play/Pause/Step/Stop toolbar (gated by state, + badge; Resume routes through `play_or_resume`), Ctrl+P / Ctrl+. shortcuts, green/amber viewport + border; host runner owns the play `App`, swaps the editor scene in/out per tick (`state.scene` + stays the source of truth), undo cleared on Play/Stop. + +**Goal:** Drive the engine's update [`Schedule`](#stage-5--engine-core-framework) from the editor so +the open scene can be run, paused, single-stepped, and stopped **in the viewport**, with edits made +during play safely reverted on stop. The same fixed-timestep tick a shipped game uses is what the +editor drives, so play behaves like the real runtime. + +### Deliverables +- **Play-mode toolbar** (Play / Pause / Step / Stop) in the editor's top bar, with matching + shortcuts; a clear **"PLAYING" viewport tint / indicator** so edit vs play state is never ambiguous. +- **Play-state model** on `EditorState` (`Editing` / `Playing` / `Paused`); the host runner ticks the + `Schedule` (incl. the fixed-timestep slot) only while `Playing`, and advances exactly one fixed tick + per **Step** while `Paused`. +- **Snapshot / restore.** On **Play**, snapshot the scene (reuse the RON scene serialization); on + **Stop**, restore it, so play-mode mutations (physics moving bodies, scripts spawning entities) + never corrupt the authored scene — avoiding Unity's classic "edited in play mode, lost it" footgun. +- **Live inspect during play.** The reflection inspector and gizmos keep working while `Paused` (and + `Playing`), so a field can be tweaked and the result observed live — the payoff of the Stage-8.5 + reflection work. (rhai scripts hot-reload with no compile step, so iteration is instant.) +- **Deferred to Stage 16 (export):** a **"Launch standalone"** button that runs the *real* exported + runtime in a separate window/process via the export builder — the truest-to-ship check, reusing the + Stage-16 packer rather than the in-editor loop. + +### Test Criteria +- Press Play → the scene's update systems run and the viewport animates; Pause halts ticking; Step + advances exactly one fixed tick; Stop returns the scene **bit-for-bit** to its pre-play state + (automated: snapshot, mutate via a tick, stop, assert the scene RON matches the snapshot). +- Entering/leaving play mode does not leak entities, handles, or undo-stack entries into edit mode. +- Manual: play a scene, pause, edit a reflected field, observe the change; stop and confirm the scene + reverts. + +--- + +## Stage 9 — Physics Integration (comprehensive) +**Goal:** A comprehensive rigid-body physics system — collision, queries, constraints, and a +character controller — built as a **module** (`oxide-physics`) on `rapier3d`, not a minimal wrapper. + +### Deliverables +- Integrate `rapier3d` as a feature-gated module registered through the Stage-5 app builder +- `RigidBody` component: static, kinematic, dynamic (mass, damping, gravity scale, CCD) +- `Collider` component: box, sphere, capsule, cylinder, convex hull, trimesh; friction/restitution, + **collision groups/masks driven by the Stage-5 `LayerMask`**, and **sensor (trigger) colliders** + that report overlap without resolving (filtered by layer — e.g. a trigger that only fires for the + Player or NPC layer) +- Forces & control: apply force/impulse/torque, set velocities, sleep/wake +- **Joints/constraints**: fixed, revolute, prismatic, spherical +- **Scene queries**: raycast, shape-cast, point/overlap queries against the physics world, with + `LayerMask` filtering +- **Kinematic character controller** (capsule): move-and-slide, step offset, slope limit, grounded + state — reused later by the Stage-15 prototyping kit's character controller +- Collision/trigger **events** surfaced to game code (enter/stay/exit) +- Physics step synchronized with game loop (**fixed timestep** with interpolation to render, using + the Stage-5 fixed-timestep schedule slot) +- `examples/physics_stack` (boxes fall and collide) and `examples/character_capsule` + (walk/jump a capsule over terrain) +- **Editor:** collider shape gizmos in viewport; RigidBody/Collider inspector panels; play/pause/step + physics simulation; raycast debug visualization — all contributed via the Stage-6 extension API + +### Test Criteria +- A dropped sphere lands on a static plane and comes to rest +- Stacked boxes maintain stable contact without jitter +- Kinematic bodies move without being affected by forces +- Raycast/shape-cast return correct hits; sensor colliders fire enter/exit without resolving contact; + layer masks correctly include/exclude bodies from collisions, triggers, and queries +- A joint (e.g. revolute) constrains motion to its expected degrees of freedom +- Character controller climbs steps below the offset, is blocked by walls, and reports grounded +- Physics and render transforms stay in sync (no visual lag/offset) under fixed-timestep interpolation + +### Piece breakdown (status) +The `oxide-physics` crate is a new workspace member; the ECS is the source of truth and the rapier +world is a transient resource rebuilt from components (so play-mode snapshot/restore works for free). +Pure-logic/headless pieces go to `main`; editor gizmos/visual behaviour stop at `dev` for eye-check. + +| # | Piece | Where | Status | +|---|-------|-------|--------| +| 1 | Component data model + module wiring: `RigidBody`/`RigidBodyKind`, `Collider`/`ColliderShape`, `PhysicsModule`, `PhysicsSettings` (gravity) | `oxide-physics` (new crate) | ✅ on `main` | +| 2 | Rapier-backed simulation: build the world from components, step on `FixedUpdate`, write transforms back; by-entity forces/velocities/sleep | `oxide-physics` | ✅ on `main` | +| 3 | Collision groups/masks via `LayerMask`, sensors, collision/trigger events (enter/stay/exit) | `oxide-physics` | ✅ on `main` | +| 4 | Scene queries: raycast, shape-cast, point/overlap with `LayerMask` filtering | `oxide-physics` | ✅ on `main` | +| 5 | Joints/constraints: fixed, revolute, prismatic, spherical (programmatic API; component authoring needs serializable entity refs — backlog) | `oxide-physics` | ✅ on `main` | +| 6 | Kinematic character controller (capsule): move-and-slide, step offset, slope limit, grounded | `oxide-physics` | ✅ on `main` | +| 7 | `examples/physics_stack` + `examples/character_capsule` (headless console demos) | `examples` | ✅ on `main` | +| 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) | `oxide-editor` | ✅ on `main` | +| 8b | Editor viewport: collider shape wireframe gizmos (box/sphere/capsule/cylinder), green=solid / amber=sensor, View ▸ Show Colliders toggle; matches the sim (ignores `Transform::scale`) | `oxide-editor` | ✅ on `main` | +| 8c | Editor viewport: raycast debug viz — `PhysicsWorld::sync_to_scene` (pure-logic) + a View ▸ Raycast Probe toggle: click freezes a camera→cursor ray into the world (orbit to view it as a 3D line) + hit point/normal | `oxide-physics` + `oxide-editor` | ✅ on `main` | + +Standalone "Launch" stays deferred to Stage 16. Convex-hull/trimesh colliders (need mesh data) land +within piece 2/3 or as a follow-up. + +--- + +## Stage 10 — Scripting, Live Reload & Editor Terminal +**Goal:** Game logic lives in watched scripts that hot-reload while the editor runs, an integrated +terminal hosts tools and AI agents, and every component type is editable from both the editor and +scripts/code through the Stage-5 reflection registry. + +### Deliverables +- **Scripting layer** (`rhai` preferred — embeddable, sandboxed, Rust-friendly): a `Script` + component attaching a script file to an entity; lifecycle hooks (`init`, `update(dt)`, events). + Scripts can also register lightweight **script modules** through the Stage-5 module API. +- **Component reflection / dual-editability**: scripts read/write engine component types through the + Stage-5 reflection registry, so the **same** fields are editable from the editor inspector, from + scripts, and from external tools/AI agents — backed by the serde/RON representation. Scripts can + spawn entities and add/edit components. +- **File watching + live reload**: extend the Stage-6 watcher to recompile/reload changed scripts + into the running editor with no restart, preserving scene state where possible. This is what lets + *any* external editor or AI agent edit game code and have it take effect live. +- **Integrated editor terminal panel**: a real shell/terminal inside the editor able to run commands + and host long-running processes — including **AI agents** that edit the watched scripts, whose + edits flow back through live reload. +- Safe error surfacing: a script error pauses that script and reports to the editor console/terminal + without crashing the editor. +- `examples/script_spin`: a script rotates an entity each frame; editing the script live changes the + spin without restarting +- **Editor:** terminal panel; script console (errors/logs/`print`); per-entity script attach/detach; + generic reflection-driven inspector for component fields + +### Test Criteria +- Editing a watched script changes running behavior without an editor restart +- A script reads and writes a component (e.g. `Transform`) and the editor inspector reflects the + same value (and vice-versa) — proving dual-editability over one representation +- A scripted entity spawn/despawn and component add/edit round-trips through RON serialization +- A script runtime error is reported and isolated; the editor stays alive +- A command run in the editor terminal executes and streams output back to the panel +- Manual: run an AI agent / external editor against the watched script dir and confirm live updates + +--- + +## Stage 11 — Animation System +**Goal:** Skeletal animation plays on a skinned mesh. + +### Deliverables +- `Skeleton` type: joint hierarchy with bind pose +- GLTF skinned mesh and animation clip loading (via the Stage-5 asset server) +- `AnimationPlayer` component: play, pause, seek, loop +- Animation blending (linear blend between two clips) +- `examples/anim_character`: load a character GLTF and play walk/idle clips +- **Editor:** animation timeline panel; clip browser; playback controls; blend weight sliders + +### Test Criteria +- Idle and walk clips play without visual artifacts +- Blend between clips produces smooth interpolation +- Seeking to frame 0 and frame N is deterministic +- Manual: visually verify no mesh distortion at extreme joint angles + +--- + +## Stage 12 — Particle System +**Goal:** GPU-driven particle emitters with configurable behavior. + +### Deliverables +- `ParticleEmitter` component: spawn rate, lifetime, velocity, gravity, color-over-lifetime +- GPU-side simulation (compute shader) +- Billboard rendering pass (registered into the Stage-5 render pipeline) +- Emitter types: point, cone, sphere surface +- `examples/particles_fire`: a fire-like particle effect +- **Editor:** particle emitter inspector with live preview; curve editor for color/size over lifetime + +### Test Criteria +- 100,000 particles maintain 60 FPS on mid-tier GPU +- Lifetime and color curves produce correct visual output +- Emitter enable/disable works without particle artifacts +- Manual: fire effect looks plausible + +--- + +## Stage 13 — Shader System & Advanced Graphics +**Goal:** Custom shaders, post-processing, and advanced rendering effects — the realistic end of the +*scalable fidelity* spectrum, all composed as passes in the Stage-5 render pipeline. + +### Deliverables +- Hot-reloadable WGSL shader pipeline (shaders watched via the Stage-6 file watcher) +- **Post-process stack** built as composable render passes: bloom, SSAO, tone mapping, FXAA, plus a + stylization slot (e.g. a **VCR/CRT-style filter**) — each pass opt-in, so a project ships only the + effects it enables +- Shadow mapping (directional light, single cascade) +- Skybox rendering (HDR cubemap) +- `examples/advanced_scene`: showcase scene with shadows, SSAO, bloom; `examples/stylized_scene`: + the same scene with a flat/low-poly look and a VCR post filter (demonstrating fidelity scaling) +- **Editor:** shader asset editor with hot-reload button; post-process stack configuration panel + (enable/reorder passes) + +### Test Criteria +- Shader hot-reload works without GPU crash or validation errors +- Shadows appear on correct geometry, no peter-panning +- SSAO adds visible depth cues on curved surfaces +- Enabling/disabling individual post passes changes only that effect; a project with no post passes + pays no post-process cost +- Manual: realistic scene looks polished; stylized scene shows the low-poly + VCR look + +--- + +## Stage 14 — Standard Audio System +**Goal:** A conventional, low-overhead audio system every project can use by default — the simple +audio model, with the ray-traced model available later as an opt-in module (Stage 17). + +### Deliverables +- Audio backend/device + mixer (`kira` or `rodio`): load/decode clips (WAV/OGG via the asset server), + play/stop/pause, volume, pitch, looping, per-bus mixing (master/music/sfx) +- `AudioSource` / `AudioListener` components designed to be shared with Stage 17 (same component + types, swappable backend) — **standard spatialization**: distance attenuation (inverse-square) + + stereo panning from listener-relative position, optional low-pass with distance +- One-shot and streaming playback; music crossfade +- Per-source **audio-model selector** (standard now; "ray-traced" becomes selectable once the + Stage-17 module is enabled) — both share components and the listener +- `examples/audio_basic`: positional sound that pans and attenuates as the listener moves +- **Editor:** audio source inspector (clip, volume, range, loop, bus); per-source model selector; + range-sphere gizmo + +### Test Criteria +- Clips play/stop/loop with correct volume and pitch; buses mix independently +- Panning and attenuation track listener-relative position (inverse-square within 5% error) +- Selecting a non-standard audio model on a source requires no component-type change (forward-compat + with Stage 17) +- Manual: positional audio feels correct moving around a source + +--- + +## Stage 15 — Engine Content & Prototyping Kit +**Goal:** Ship the batteries — built-in primitives, controllers, shaders, and example scenes — so +projects start building immediately instead of from an empty scene. The general-purpose capstone +before export. + +### Deliverables +- **Prototyping primitives**: ready-to-use cube/sphere/capsule/plane/cylinder meshes and a grid/ + checker "prototype" material, placeable from the editor (greybox blockout workflow) +- **Built-in character controller** asset: a drop-in first/third-person controller built on the + Stage-9 capsule controller + Stage-7 named actions (move/look/jump/crouch/sprint), remappable +- **Built-in shader library**: curated, documented WGSL shaders/materials (unlit, PBR, prototype + grid, simple sky) usable as-is or as templates for Stage-13's shader editor +- **Example games/scenes**: a small set of runnable example projects exercising the engine end to + end (e.g. a walk-around prototype level, a physics playground) +- **Editor:** an "Add ▸" content menu/asset browser exposing the primitives, controller, materials, + and example scenes +- Everything here is built only from already-shipped engine systems (no new core subsystems) + +### Test Criteria +- Each prototyping primitive spawns and renders correctly from the editor +- The built-in character controller walks/looks/jumps on a prototype level with remappable actions +- Built-in shaders compile and render without validation errors +- Example projects build and run via `cargo run` and from the editor +- Manual: blockout a small level using only built-in content and play it with the controller + +--- + +## Stage 16 — Windows Support & Game Export +**Goal:** A Windows build of the editor/engine, and one-click export of a project to standalone +Linux **and** Windows game binaries. **This completes Phase 1: a general-purpose engine that builds +and ships real games.** + +### Deliverables +- **Windows build** of `oxide-engine` and `oxide-editor` (verified on Windows; `wgpu` DX12/Vulkan, + `winit` Win32) alongside the existing Linux Wayland/Xorg build +- **Game runtime/export pipeline**: package a project's scenes, scripts, and assets into a + standalone runnable game that links the engine runtime (without the editor), compiling in **only + the modules the project enables** (validating the Stage-5 feature-gating) +- **Cross-target export**: from the editor (on Linux) export to both a Linux binary and a Windows + binary (cross-compile, e.g. `x86_64-pc-windows-gnu`), bundling assets next to the executable +- Export presets (target platform, asset packing, build profile) and a CLI equivalent for CI +- Packaging: keep `install.sh` for the editor; add export output layout docs for both platforms +- `examples/`: export one of the Stage-15 example projects to Linux and Windows binaries +- **Editor:** "Export Project" dialog (choose Linux/Windows targets, output dir, profile) + +### Test Criteria +- The editor builds and runs on Windows (Wayland/Xorg Linux build remains unaffected) +- A project exports to a Linux binary that runs standalone (no editor, no toolchain) with its assets +- The same project cross-exports to a Windows binary that runs on Windows +- Exported binaries contain no editor-only code paths and none of the modules the project didn't use +- Manual: run an exported Linux build and an exported Windows build of an example project + +--- + +# Phase 2 — Built-in Modules + +Each Phase-2 stage is a **self-contained, feature-gated module** built on Phase-1 systems and wired in +through the Stage-5 module API and Stage-6 editor extension API. Every module ships documentation for +both *using* it and *authoring* one like it, and each automatically works with export (Stage 16) and +the settings framework (Stage 6). The order below is a default; modules are largely independent and +can be reprioritized to match the needs of the first real game built in Oxide. + +## Stage 17 — Ray-Traced Spatial Audio (module) +**Goal:** Sound propagation that simulates real wave behavior from scene geometry — the advanced +audio model, built on the Stage-14 components so projects opt in per source. + +### Deliverables +- Reuses `AudioSource` / `AudioListener` from Stage 14 (ray-traced model selected per source) +- Sound ray casting: shoot rays from source, accumulate energy at listener (built on the engine's + own ray casts / `LayerMask` filtering) +- Occlusion: blocked rays reduce volume +- Reflection: rays bounce off surfaces and arrive with delay (reverb model) +- Distance attenuation using inverse square law +- `examples/sound_cave`: walk around a cave with echoing footstep sounds +- **Editor:** audio source visualizer (range sphere, ray debug lines); reverb parameter inspector; + the module's settings page + +### Test Criteria +- Sound behind a wall is measurably quieter than line-of-sight +- Moving away from source follows inverse square law within 5% error +- Reverb tail length correlates with room size +- Switching a source between standard and ray-traced models requires no component-type change +- Manual: walk through the cave example and confirm intuitive sound behavior + +--- + +## Stage 18 — Developer Console & Cheats (module) +**Goal:** A drop-in developer console any game can enable for commands, cheats, and debugging help — +trivially added to a project and removed from release builds. + +### Deliverables +- In-game console overlay (built on the Stage-8 UI) with command input, history, autocomplete, and + log output; toggled by a Stage-7 action (default backtick) +- **Command registry**: register named commands with typed arguments from engine, game code, scripts, + or other modules; built-in commands (spawn, teleport, set var, toggle layer, time scale, etc.) +- **Cheats / dev variables (cvars)**: registerable toggles and values (godmode, noclip, give, fly) + with a guard so they can be compiled out or locked in release/export builds +- Bind console commands to keys via the Stage-7 input system for quick dev shortcuts +- `examples/console_demo`: a scene with registered cheats and commands +- **Editor:** command browser/inspector; console mirrored in the editor; the module's settings page + (enable in build, default key, release lockout) + +### Test Criteria +- Registered commands execute with correct typed-argument parsing; unknown commands report cleanly +- Cheats/cvars change game state live and can be disabled/compiled out for release +- The console consumes input over the game via the Stage-8 routing model +- Console state and command registry are unaffected by hot-reload of game scripts +- Manual: enable the module in a game, open the console, run commands and toggle a cheat + +--- + +## Stage 19 — Procedural Toolkit (module) +**Goal:** Composable procedural *building blocks* — noise and a modifier/operation stack the user +combines — not a fixed world generator. Consumed by the terrain module (Stage 20) and usable +standalone. **Build tools, not the game.** + +### Deliverables +- Noise library: Perlin, Simplex, Worley, fractal octaves, domain warping (deterministic, seeded) +- **Modifier/operation stack**: chainable operations over heightfields/value grids (add, multiply, + terrace, erode, mask, remap, blend) authored as a data-driven, serializable graph/stack +- Seeded RNG utilities and distribution helpers for scatter/placement +- Simple L-system helper for vegetation/structure layout +- Region/`Biome` helper: blend by temperature/humidity/height maps (a tool, not a built-in world) +- `examples/procgen_playground`: build a noise + modifier stack and preview the result +- **Editor:** noise preview panel (2D heatmap); modifier-stack editor with live re-evaluation; seed + controls; the module's settings page + +### Test Criteria +- Same seed + same stack always produces identical output (determinism) +- Each modifier transforms its input correctly and composes in a chain +- A modifier stack round-trips through RON unchanged (dual-editable) +- Benchmark: evaluating a representative stack over a 1024² grid completes within target time +- Manual: build and preview a multi-stage noise stack in the editor + +--- + +## Stage 20 — Terrain System (module) +**Goal:** A comprehensive terrain module: generate terrain (via the Stage-19 toolkit), sculpt and +paint it with editor brushes, and scatter vegetation/objects onto it. Designed to stream later under +Stage 21. + +### Deliverables +- **Terrain representation**: chunked heightmap terrain with a meshing pass (normals, UVs) and + collider generation (heightfield collider via Stage 9); LOD-ready chunk layout +- **Generators**: drive terrain from the Stage-19 noise + modifier stack, from basic Perlin/Simplex + up to layered erosion/domain-warp stacks and biome-driven generation +- **Sculpt brush tools** (editor): raise/lower, smooth, flatten, noise — adjustable radius, strength, + falloff; live edits, undoable through the Stage-6 command stack +- **Texture/splat painting** (editor): paint multiple terrain material layers (e.g. grass/rock/sand) + with a splat map and per-layer tiling; brush-based painting with falloff +- **Foliage & object scatter** (editor): paint/spray grass, trees, rocks, or arbitrary prefabs with + density/scale/rotation jitter and slope/height masks; instanced rendering for dense foliage, + entity placement for larger objects +- **Dual-editable & serializable**: terrain data (heightmap, splat layers, scatter sets) round-trips + through RON so it is editable from editor and scripts/AI agents +- `examples/terrain_playground`: generate a terrain, sculpt/paint it, scatter grass and trees +- **Editor:** terrain tool panel (generate, sculpt, paint, scatter sub-tools); brush settings; + layer/material list; foliage/prefab palette; the module's settings page + +### Test Criteria +- A generated terrain produces correct mesh normals and a matching heightfield collider +- The same seed/generator stack produces identical terrain (determinism) +- Sculpt and paint brush edits modify the heightmap/splat map correctly and are undoable +- Scatter respects density and slope/height masks; instanced foliage renders without per-instance + entity overhead +- Terrain + splat + scatter data round-trips through serialization unchanged +- Manual: sculpt, paint layers, and scatter grass/trees on a terrain and confirm it collides correctly + +--- + +## Stage 21 — Open World Support (module) +**Goal:** Streaming, chunked worlds that can exceed memory limits. + +### Deliverables +- World chunk system: load/unload chunks based on camera position (built on the Stage-5 asset server) +- Async asset streaming (chunks load on background threads) +- Level of Detail (LOD) system for meshes and terrain +- Entity streaming: entities activate/deactivate with their chunk +- `examples/open_world`: a large world that streams seamlessly as you move +- **Editor:** world chunk map overlay; streaming debug view (loaded/unloaded chunks); LOD visualizer; + the module's settings page + +### Test Criteria +- No frame drops during chunk load/unload transitions +- Memory usage stays bounded regardless of world size +- Entities in unloaded chunks do not tick (verified via counters) +- Manual: drive across chunk boundaries and confirm invisible seams + +--- + +## Stage 22 — Pathfinding & NPC AI (module) +**Goal:** Built-in navigation and reusable NPC behavior building blocks, so games get agents that +move and decide without writing it all from scratch. + +### Deliverables +- **NavMesh**: bake a navigation mesh from scene/terrain geometry (with `LayerMask`-driven + walkable/obstacle filtering); runtime queries (path find, nearest point, path smoothing) +- **Agent component**: follow a path with steering, avoidance, speed/acceleration, and grounded + movement via the Stage-9 character controller +- **NPC AI building blocks**: a behavior-tree and/or state-machine system, perception helpers + (vision cones / hearing using scene queries and layers), and steering behaviors (seek/flee/wander/ + patrol) — composable, not a fixed AI +- Dual-editable, serializable behavior/agent definitions +- `examples/npc_patrol`: agents navigate, patrol waypoints, chase the player, and avoid obstacles +- **Editor:** navmesh bake/visualize panel; agent inspector; behavior-tree/state-machine editor; the + module's settings page + +### Test Criteria +- A baked navmesh produces correct paths around obstacles; queries return valid points +- Agents follow paths, avoid each other, and stop at goals without jitter +- A behavior tree/state machine transitions correctly and is serializable (dual-editable) +- Perception correctly gates on layers and line-of-sight +- Manual: watch NPCs patrol, detect, chase, and give up + +--- + +## Stage 23 — Water System (module) +**Goal:** Built-in water — rendering and gameplay mechanics — that a project can drop in. + +### Deliverables +- **Water rendering**: animated water surface (waves/normals), reflection/refraction, depth-based + color/fog, shoreline blending — composed as Stage-5 render passes and a Stage-13 shader material +- **Water bodies**: planar/ocean and bounded volumes (lakes/pools) with configurable height/level +- **Gameplay mechanics**: buoyancy/floating on Stage-9 rigid bodies, water-volume triggers + (enter/exit, submerged state), simple flow/current forces, and a swim mode for the Stage-15 + character controller +- Optional underwater post-process (tint, distortion, muffled audio hook for Stages 14/17) +- Dual-editable, serializable water-body definitions +- `examples/water_scene`: a floating object, a swimmable lake, and an ocean horizon +- **Editor:** water body inspector and placement gizmos; surface/material settings; the module's + settings page + +### Test Criteria +- Water renders with plausible reflection/refraction and correct depth blending at shorelines +- Rigid bodies float and settle at the correct waterline; flow forces push bodies as configured +- Water-volume triggers fire enter/exit and report submerged state correctly +- The character controller enters swim mode in water and exits on leaving +- Manual: drop objects in water, swim the character, and confirm it looks and behaves correctly + +--- + +## Backlog (additional modules & post-roadmap capabilities) + +### Editor UX / inspector (raised 2026-06-16, maintainer GUI review) +- **Editor visual overhaul** — overall styling, spacing, and panel layout need a + dedicated polish pass (egui theme, inspector/panel density). Tracked as a + standalone editor-styling piece, not folded into feature work. +- **Component gizmos / viewport handles** — components with spatial meaning need + editor-only viewport visualization: `DirectionalLight` direction arrow (+ cone + for future spot lights), camera frustum, collider/audio shapes. Pairs with the + multiple-light-types work below. +- **Multiple light types** — `DirectionalLight` is the only light today; add + point + spot (and the renderer support to gather light *entities*, which the + reflected `DirectionalLight`/`Camera` components are scaffolding toward). +- **Component multiplicity model (DECIDED 2026-06-16 — hybrid).** Archetypal ECS + (`hecs`) allows one component of a given type per entity. Chosen approach, + applied as each component type lands: + - **Local-offset field** for things that are naturally *single per node but + positioned* — a collider's `center`, a light/audio emitter offset — stored as + a `Vec3`/`Transform` field on the component (one node, no extra hierarchy). + This mirrors Unity (`BoxCollider.center`). + - **Child entities** for *genuine multiples* (several meshes on one logical + object): each is a real node with its own `Transform`, gizmo-movable and + shown in the viewport — what Unity/Bevy do for multi-mesh. The "Add … (as + child)" path stays; editor UX is polished so it doesn't feel like clutter. + - Explicitly **not** doing internal multi-instance lists + sub-object gizmos + (rejected as the biggest lift / least ECS-native). + Concretely: colliders (Stage 9) and audio sources (later) get offset fields; + multi-mesh stays child-based. + +- Networking (deterministic lockstep or rollback for simulation use) +- Multi-threading and job system (rayon or custom) +- **Dynamically loadable native plugins** (C-ABI/dylib) — currently modules are compile-time crates + or `rhai` scripts; revisit once the static module API is proven +- Save-format/asset versioning and migration (backward compatibility for shipped projects) +- VR/XR support +- Volumetric fog and clouds +- GPU-driven rendering (indirect draw, bindless resources) +- Terrain deformation at runtime +- Fluid simulation (beyond the Stage-23 water surface model) +- Vehicle physics module +- Destruction / fracture module +- **SDF (signed-distance-field) text rendering** for the Stage-8 UI — each + glyph rasterized once at one canonical size as an SDF, then scaled + arbitrarily in the shader. Decouples atlas memory from font-size + combinatorics (HiDPI, animated scale, many sizes) and is how Unity + TextMeshPro / Unreal Slate / Godot 4's font system all ship. Slots in + next to the piece-3 `ab_glyph` rasterizer without rewriting the shaper. + +--- + +## Testing Protocol (All Stages) + +1. **Unit tests** — every module has tests for core behavior and edge cases +2. **Integration tests** — the `tests/` crate runs end-to-end scenarios +3. **Example review** — each stage ships at least one runnable example; both user and Claude review it +4. **Benchmarks** — performance-sensitive systems have `criterion` benchmarks; regressions block stage completion +5. **Clippy + fmt** — zero warnings, consistent formatting required before stage sign-off +6. **Module authoring docs** — every module (Stage 9 on) ships both a usage guide and an authoring + guide under `docs/`; a stage is not done until its docs land diff --git a/README.md b/README.md index 5f59b05..1699a77 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,252 @@ -# Oxide +# Oxide Engine -Oxide general purpose 3D game engine \ No newline at end of file +![Oxide Engine](assets/oxide.png) + +> **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` (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). diff --git a/assets/fonts/InterVariable.ttf b/assets/fonts/InterVariable.ttf new file mode 100644 index 0000000..4ab79e0 Binary files /dev/null and b/assets/fonts/InterVariable.ttf differ diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/assets/fonts/OFL.txt @@ -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. diff --git a/assets/models/cube.gltf b/assets/models/cube.gltf new file mode 100644 index 0000000..dd6df8f --- /dev/null +++ b/assets/models/cube.gltf @@ -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" + } + ] +} diff --git a/assets/oxide.png b/assets/oxide.png new file mode 100644 index 0000000..2b42831 Binary files /dev/null and b/assets/oxide.png differ diff --git a/assets/oxide_nogb.png b/assets/oxide_nogb.png new file mode 100644 index 0000000..6ed47ab Binary files /dev/null and b/assets/oxide_nogb.png differ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..db3ddbf --- /dev/null +++ b/docs/README.md @@ -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). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..6fa31a0 --- /dev/null +++ b/docs/architecture.md @@ -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 diff --git a/docs/assets.md b/docs/assets.md new file mode 100644 index 0000000..b3ea5ce --- /dev/null +++ b/docs/assets.md @@ -0,0 +1,221 @@ +# 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`]: 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 = assets.load("assets/models/cube.gltf"); + +if let Some(model) = model.get() { // Option>, None until loaded + println!("{} meshes", model.meshes.len()); +} +``` + +Key handle methods: + +| Method | Meaning | +|--------|---------| +| `get()` | `Some(Arc)` 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 = assets.load("model.gltf"); +let b: Handle = 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 = 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 { + 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 = assets.load("model.gltf"); +// ... the file changes on disk ... +let same = assets.reload::("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(); // discover files in fonts/ models/ … +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::(&server, uid); // Option> +``` + +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) | + +`AssetKind::classify(path)` infers the kind: the leading folder wins, with the +file extension as a fallback for files dropped directly in `assets/`. + +### Asset-reference fields in the inspector + +A component points at an asset with an [`AssetRef`] field — **not** a live +`Handle`. A handle is process-local and not serializable, so persisting one +would be wrong; an `AssetRef` is a thin, serializable wrapper over +`Option` 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 } // serializes as just the uid + +# fn demo(label: &Label, db: &AssetDatabase, server: &AssetServer) { +let handle = label.font.resolve(db, server); // Option> +# } +``` + +Because `AssetRef` 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` 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 just **dropping the file into +the matching typed folder** (`fonts/`, `textures/`, …) — no separate import +step. The **Project panel** is an asset browser listing each typed folder's +assets from the database, and an asset-reference field in the inspector renders +as a **picker** populated from it, 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. + +[`AssetDatabase`]: ../engine/src/asset/database.rs +[`AssetKind`]: ../engine/src/asset/database.rs +[`AssetUid`]: ../engine/src/asset/database.rs +[`AssetRef`]: ../engine/src/asset/database.rs +[`asset_ref_target`]: ../engine/src/asset/database.rs +[`AssetKind::for_handle_target`]: ../engine/src/asset/database.rs +[`Handle`]: ../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 diff --git a/docs/conventions.md b/docs/conventions.md new file mode 100644 index 0000000..0f8df3f --- /dev/null +++ b/docs/conventions.md @@ -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. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..f39fbd7 --- /dev/null +++ b/docs/development.md @@ -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. diff --git a/docs/editor-extensions.md b/docs/editor-extensions.md new file mode 100644 index 0000000..a124b49 --- /dev/null +++ b/docs/editor-extensions.md @@ -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>`). + +## 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 diff --git a/docs/editor-shell.md b/docs/editor-shell.md new file mode 100644 index 0000000..6308450 --- /dev/null +++ b/docs/editor-shell.md @@ -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 diff --git a/docs/file-watching.md b/docs/file-watching.md new file mode 100644 index 0000000..5043849 --- /dev/null +++ b/docs/file-watching.md @@ -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 diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..e503a5d --- /dev/null +++ b/docs/getting-started.md @@ -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 +``` diff --git a/docs/input.md b/docs/input.md new file mode 100644 index 0000000..da8c441 --- /dev/null +++ b/docs/input.md @@ -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::(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::("input.bindings"); + +// User remap → write into Settings → export to disk. +actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]); +*settings.get_mut::("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::("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::("input.bindings"); +*settings.get_mut::("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. diff --git a/docs/layers.md b/docs/layers.md new file mode 100644 index 0000000..19c0f15 --- /dev/null +++ b/docs/layers.md @@ -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 diff --git a/docs/math.md b/docs/math.md new file mode 100644 index 0000000..76da8e5 --- /dev/null +++ b/docs/math.md @@ -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. diff --git a/docs/modules.md b/docs/modules.md new file mode 100644 index 0000000..bb3eba7 --- /dev/null +++ b/docs/modules.md @@ -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::().unwrap() += 1; +assert_eq!(app.get_resource::(), 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::>() { + if let Some(mut t) = app.scene.get_mut::(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::().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 diff --git a/docs/physics.md b/docs/physics.md new file mode 100644 index 0000000..6d65b6a --- /dev/null +++ b/docs/physics.md @@ -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::()`. + +### 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::().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::().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 diff --git a/docs/play-mode.md b/docs/play-mode.md new file mode 100644 index 0000000..7e8dcc7 --- /dev/null +++ b/docs/play-mode.md @@ -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. diff --git a/docs/prefabs.md b/docs/prefabs.md new file mode 100644 index 0000000..cf272b6 --- /dev/null +++ b/docs/prefabs.md @@ -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"); + +// 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 diff --git a/docs/projects.md b/docs/projects.md new file mode 100644 index 0000000..7c7321e --- /dev/null +++ b/docs/projects.md @@ -0,0 +1,85 @@ +# 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. + +## 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 diff --git a/docs/reflection.md b/docs/reflection.md new file mode 100644 index 0000000..d42c3dd --- /dev/null +++ b/docs/reflection.md @@ -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"); +registry.register::("Node"); +``` + +`register::(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"); +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"); +# registry.register::("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::("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"); +# 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"); +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 diff --git a/docs/render-context.md b/docs/render-context.md new file mode 100644 index 0000000..9710943 --- /dev/null +++ b/docs/render-context.md @@ -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` 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. diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md new file mode 100644 index 0000000..8f03a14 --- /dev/null +++ b/docs/render-pipeline.md @@ -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::(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 diff --git a/docs/rendering.md b/docs/rendering.md new file mode 100644 index 0000000..96ead33 --- /dev/null +++ b/docs/rendering.md @@ -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). diff --git a/docs/scene.md b/docs/scene.md new file mode 100644 index 0000000..1ccc739 --- /dev/null +++ b/docs/scene.md @@ -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 +``` + +`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 +let on = scene.is_enabled(moon); // Option +let local = scene.local_transform(moon); // Option +``` + +### Querying + +```rust +for &root in scene.roots() { /* … */ } +for &child in scene.children(planet) { /* … */ } +let parent = scene.parent(moon); // Option +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::(planet); // Option> +``` + +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. diff --git a/docs/scripting.md b/docs/scripting.md new file mode 100644 index 0000000..e136b42 --- /dev/null +++ b/docs/scripting.md @@ -0,0 +1,332 @@ +# 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` | 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`](assets.md), not a live `Handle`, so it is +serializable and stable across runs; the inspector recognises the +`AssetRef` 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::("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` 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`, which the inspector shows as an asset picker filtered + to the project's `scripts/` folder (`AssetKind::Script`). +- **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::("…")`). 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 diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000..e10dc0c --- /dev/null +++ b/docs/settings.md @@ -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::("editor"); + +// Typed read/write. +settings.get_mut::("editor").unwrap().theme = "dark".into(); +assert_eq!(settings.get::("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::("editor"); +let saved = settings.export(); // BTreeMap + +let mut restored = Settings::new(); +restored.register::("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 diff --git a/docs/ui.md b/docs/ui.md new file mode 100644 index 0000000..0413473 --- /dev/null +++ b/docs/ui.md @@ -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` 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`. 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` (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>` (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` +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` 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` 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` 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>` 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` + +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`) forces every callback to either: + +- own its game state via `Rc>` (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` that registers per-field + getter/setter pairs and runs them automatically) — adds a `Box` + 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. diff --git a/docs/windowing.md b/docs/windowing.md new file mode 100644 index 0000000..bf64c76 --- /dev/null +++ b/docs/windowing.md @@ -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`. diff --git a/editor/Cargo.toml b/editor/Cargo.toml new file mode 100644 index 0000000..857a96a --- /dev/null +++ b/editor/Cargo.toml @@ -0,0 +1,34 @@ +[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 +# 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 + +# 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" diff --git a/editor/src/assets.rs b/editor/src/assets.rs new file mode 100644 index 0000000..5eb901f --- /dev/null +++ b/editor/src/assets.rs @@ -0,0 +1,127 @@ +//! Bundled editor assets — locating the shared `assets/` tree and seeding a +//! new project's default content (currently the default UI font). +//! +//! 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. `/../share/oxide/assets` — the install layout (`bin/` next to +//! `share/`); +//! 3. `/../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 { + // 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: /bin/oxide-editor + /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 { + 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 { + 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) +} + +#[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(); + } +} diff --git a/editor/src/bindings.rs b/editor/src/bindings.rs new file mode 100644 index 0000000..a559556 --- /dev/null +++ b/editor/src/bindings.rs @@ -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)] + ); + } +} diff --git a/editor/src/command.rs b/editor/src/command.rs new file mode 100644 index 0000000..b3024f2 --- /dev/null +++ b/editor/src/command.rs @@ -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: '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) -> 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 { + label: String, + commands: Vec>>, +} + +impl Group { + /// A new, empty group with the given label. + pub fn new(label: impl Into) -> 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 + 'static) { + self.commands.push(Box::new(command)); + } + + /// Whether the group has no commands. + pub fn is_empty(&self) -> bool { + self.commands.is_empty() + } +} + +impl Command for Group { + 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 { + undo: Vec>>, + redo: Vec>>, + capacity: usize, +} + +impl CommandStack { + /// 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 + '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>, 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 { + 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 { + 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 { + self.undo.last().map(|c| c.label()) + } + + /// The label of the next redo, if any. + pub fn redo_label(&self) -> Option { + 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 Default for CommandStack { + 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 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) -> bool { + if !self.mergeable { + return false; + } + if let Some(other) = next.as_any_mut().downcast_mut::() { + 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 + } +} diff --git a/editor/src/commands.rs b/editor/src/commands.rs new file mode 100644 index 0000000..1dfee1e --- /dev/null +++ b/editor/src/commands.rs @@ -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 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 { + let before = state.scene.local_transform(entity)?; + Some(Self { + entity, + before, + after, + }) + } +} + +impl Command 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) -> bool { + let Some(next) = next.as_any_mut().downcast_mut::() 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 { + let before = state + .registry + .get_field(state.scene.world(), entity, type_name, field) + .ok()?; + Some(Self { + entity, + type_name, + field, + before, + after, + }) + } +} + +impl Command 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) -> bool { + let Some(next) = next.as_any_mut().downcast_mut::() 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 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 = 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 = 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 = 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 = 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 = 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 = 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")); + } +} diff --git a/editor/src/console.rs b/editor/src/console.rs new file mode 100644 index 0000000..e242481 --- /dev/null +++ b/editor/src/console.rs @@ -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, +} + +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 { + 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>> = OnceLock::new(); + +/// The shared capture buffer, if logging has been initialised. +pub fn log_buffer() -> Option<&'static Arc>> { + 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) { + 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>, +} + +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()); + } +} diff --git a/editor/src/egui_layer.rs b/editor/src/egui_layer.rs new file mode 100644 index 0000000..d87f4ea --- /dev/null +++ b/editor/src/egui_layer.rs @@ -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())), + ); + } +} diff --git a/editor/src/extension.rs b/editor/src/extension.rs new file mode 100644 index 0000000..b096723 --- /dev/null +++ b/editor/src/extension.rs @@ -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>`). + +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, + /// Invoked when the item is clicked. The shell decides when to call it. + pub action: Box, +} + +/// 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, +} + +/// 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, +} + +/// 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, +} + +/// 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, +} + +/// 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 { + 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>, + panels: Vec>, + viewport_tools: Vec>, + inspectors: BTreeMap>, + settings_pages: BTreeMap>, + 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(&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 + '_ { + 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, + 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, + 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, + 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, + 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, + title: impl Into, + 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 { + self.iter_menu_items().map(|i| i.path.as_str()) + } + + /// Names of every currently-enabled panel. + pub fn panel_names(&self) -> impl Iterator { + self.iter_panels().map(|p| p.name.as_str()) + } + + /// Names of every currently-enabled viewport tool. + pub fn viewport_tool_names(&self) -> impl Iterator { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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!["demo"]); + + assert_eq!( + ext.menu_item_paths().collect::>(), + vec!["Demo/Hello"] + ); + assert_eq!(ext.panel_names().collect::>(), vec!["Demo Panel"]); + assert_eq!( + ext.viewport_tool_names().collect::>(), + 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!["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"); + } +} diff --git a/editor/src/gizmo.rs b/editor/src/gizmo.rs new file mode 100644 index 0000000..2a26b87 --- /dev/null +++ b/editor/src/gizmo.rs @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/editor/src/lib.rs b/editor/src/lib.rs new file mode 100644 index 0000000..98fd183 --- /dev/null +++ b/editor/src/lib.rs @@ -0,0 +1,26 @@ +//! 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 extension; +pub mod gizmo; +pub mod play; +pub mod preferences; +pub mod pty; +pub mod shell; +pub mod state; +pub mod terminal; diff --git a/editor/src/main.rs b/editor/src/main.rs new file mode 100644 index 0000000..237ade0 --- /dev/null +++ b/editor/src/main.rs @@ -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, + viewport: Option, + /// 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, + 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 { + 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, + ) -> Option { + 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(|| "".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` 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()) +} diff --git a/editor/src/play.rs b/editor/src/play.rs new file mode 100644 index 0000000..c0a6ec6 --- /dev/null +++ b/editor/src/play.rs @@ -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); + } +} diff --git a/editor/src/preferences.rs b/editor/src/preferences.rs new file mode 100644 index 0000000..0667e49 --- /dev/null +++ b/editor/src/preferences.rs @@ -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 { + 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) -> Option { + 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> { + 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) -> 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 + '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 = ron::from_str(&read_back).unwrap(); + assert_eq!(parsed, map); + + let _ = std::fs::remove_file(&scratch); + } +} diff --git a/editor/src/pty.rs b/editor/src/pty.rs new file mode 100644 index 0000000..74ebb8b --- /dev/null +++ b/editor/src/pty.rs @@ -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>, + /// The PTY master — kept for resizing. + master: Box, + /// Writes keystrokes to the program (the PTY input side). + writer: Box, + /// The spawned child — killed on drop so closing the panel ends the program. + child: Box, + /// 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, + program: &str, + args: &[&str], + cwd: &std::path::Path, + rows: u16, + cols: u16, + ) -> std::io::Result { + 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(&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, parser: Arc>) { + 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> { + // 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 { + 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()); + } +} diff --git a/editor/src/shell.rs b/editor/src/shell.rs new file mode 100644 index 0000000..de15de2 --- /dev/null +++ b/editor/src/shell.rs @@ -0,0 +1,6042 @@ +//! The editor's docking shell. +//! +//! Owns the top **menu bar**, the **dockable panel layout** (built on +//! [`egui_dock`]), the **status bar**, and the **Preferences** window. The +//! shell is the host every other Stage-6 piece plugs into: +//! +//! - The Stage-6 [command stack](crate::command) drives the Edit menu and +//! `Ctrl+Z` / `Ctrl+Y`. +//! - The Stage-6 [project system](oxide_engine::project) drives the +//! File / Project menus (New / Open / Save). +//! - The Stage-6 [settings framework](oxide_engine::settings) drives the +//! Preferences window. +//! - The Stage-6 [file watcher](oxide_engine::watch) is started when a +//! project opens, and its events feed +//! [`reload_changed_assets`](oxide_engine::watch::reload_changed_assets) +//! each frame. +//! - The Stage-6 [extension API](crate::extension) provides module- +//! contributed menu items, panels, and settings pages, hosted alongside +//! the built-in ones. +//! +//! ## Why a registry, not hard-coded panels +//! +//! The built-in panels (Hierarchy, Inspector, Viewport, Project, Console) +//! are dispatched by an enum [`PanelKind`] and rendered in the shell itself, +//! because they need direct access to [`EditorState`] (scene, selection, asset +//! server, project). Module-contributed panels go through the +//! [`EditorExtensions`](crate::extension::EditorExtensions) registry; their +//! `FnMut(&mut egui::Ui)` closure can capture module-owned state, and the +//! shell renders them as additional tabs in the same dock. +//! +//! Piece-6 scope: ship the shell with built-in panels at feature parity with +//! Stage-5's egui::Panel layout, the Edit-menu undo/redo wired to scene +//! mutations, the file watcher pumping into asset reload on project open, and +//! a Preferences window listing the registered settings sections and the +//! enabled modules. Visual polish and richer settings editors land +//! incrementally in later stages. + +use std::path::PathBuf; +use std::time::Duration; + +use egui_dock::{DockArea, DockState, NodeIndex, Style}; +use oxide_engine::asset::{asset_ref_target, AssetDatabase, AssetEntry}; +use oxide_engine::input::{Binding, InputState}; +use oxide_engine::prelude::*; +use oxide_engine::project::{Project, ProjectError}; +use oxide_engine::scene::{DespawnPolicy, DisabledComponents}; +use oxide_engine::watch::{reload_changed_assets, ChangeEvent, FileWatcher}; +use oxide_engine::winit::event::MouseButton; +use oxide_engine::winit::keyboard::KeyCode; + +use crate::command::{Command, CommandStack}; +use crate::commands::{RenameCmd, SetFieldCmd, SetUiPanelCmd}; +use crate::extension::EditorExtensions; +use crate::gizmo::{self, Axis3, GizmoHandle, GizmoMode, PlaneAxis}; +use crate::state::{EditorState, PlayState}; +use oxide_engine::math::{Mat4, Vec4}; +use oxide_engine::reflect::FieldInfo; + +/// Identifies one panel inside the dock. Built-in panels are explicit +/// variants; module-contributed panels carry their registered name in +/// [`Custom`](PanelKind::Custom). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum PanelKind { + /// Scene hierarchy tree. + Hierarchy, + /// Selection-bound inspector / properties. + Inspector, + /// The 3D viewport. The tab itself draws nothing — the host renders the + /// 3D scene directly to the surface before egui composites, and this tab + /// suppresses its own background (`clear_background` returns `false`) so + /// the 3D content shows through where the tab is. The other panels keep + /// opaque backgrounds and occlude the surrounding 3D area. + Viewport, + /// Project file browser (a tree over `assets/`, `scenes/`, `scripts/`). + Project, + /// Visual UI-document builder (widget tree + canvas preview + properties). + UiCanvas, + /// Log / status console + non-interactive command runner. + Console, + /// Interactive PTY terminal — runs shells / TUIs / AI-agent CLIs. + Terminal, + /// A panel contributed by a module through + /// [`EditorExtensions::add_panel`](crate::extension::EditorExtensions::add_panel). + Custom(String), +} + +impl PanelKind { + /// Human-readable title for the dock tab. + pub fn title(&self) -> &str { + match self { + PanelKind::Hierarchy => "Hierarchy", + PanelKind::Inspector => "Inspector", + PanelKind::Viewport => "Viewport", + PanelKind::Project => "Project", + PanelKind::UiCanvas => "UI Canvas", + PanelKind::Console => "Console", + PanelKind::Terminal => "Terminal", + PanelKind::Custom(name) => name, + } + } +} + +/// A structural scene edit that bypasses the command stack today (spawn / +/// despawn / reparent). Tracked here only because the hierarchy panel builds +/// these while the UI closure runs and applies them after, the same idiom +/// the Stage-5 main.rs used. +enum PendingAction { + /// Spawn a named prefab as a root entity (data-driven add-menu). The + /// `Empty` prefab is a bare node; the rest carry components. + AddRootPrefab(String), + /// Spawn a named prefab as a child of `entity`. + AddChildPrefab(Entity, String), + Delete(Entity), + /// Duplicate an entity as a sibling, copying its registered components + /// (via the reflection registry). Children are not duplicated yet. + Duplicate(Entity), + SetEnabled(Entity, bool), + + // --- Bindings preferences page (Stage 7 piece 5) ------------------ + /// Restore every editor action to its defaults. + RestoreAllBindings, + /// Restore one action to its defaults (button or axis or 2D axis — + /// the [`ActionMap`] dispatches by name across kinds). + RestoreActionDefaults(String), + /// Drop one binding from a button action's current list. + RemoveButtonBinding { + action: String, + index: usize, + }, + /// Drop one binding from a direction-set of an axis (1D or 2D). + RemoveAxisBinding { + action: String, + dir: AxisDirection, + index: usize, + }, +} + +/// Discriminator used by the bindings UI to talk about "one direction of an +/// axis" without caring whether the action is 1D or 2D. Converted to a +/// [`CaptureTarget`] when capture begins and back to the right axis edit +/// when applying pending actions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AxisDirection { + Axis(AxisSide), + Axis2D(Axis2DSide), +} + +impl AxisDirection { + /// Short label shown next to the direction row in the bindings UI. + fn label(&self) -> &'static str { + match self { + AxisDirection::Axis(AxisSide::Positive) => "+", + AxisDirection::Axis(AxisSide::Negative) => "−", + AxisDirection::Axis2D(Axis2DSide::Right) => "→", + AxisDirection::Axis2D(Axis2DSide::Left) => "←", + AxisDirection::Axis2D(Axis2DSide::Up) => "↑", + AxisDirection::Axis2D(Axis2DSide::Down) => "↓", + } + } +} + +/// One frame's data needed to paint the gizmo handles over the Viewport +/// tab. The host (binary) sets it before [`Shell::build`] runs; the Shell +/// stashes it on a field that the Viewport tab reads. +/// +/// Decoupled from the gizmo *math* in [`crate::gizmo`] so the math stays +/// pure and the renderer can change (egui overlay today, native 3D mesh +/// in a later piece) without touching either side. +#[derive(Debug, Clone, Copy)] +pub struct ViewportOverlay { + /// The view-projection matrix the renderer drew the scene with — used + /// to project handle world points to screen pixels here. + pub view_proj: Mat4, + /// World-space length of axis arrows / circles / cubes. Same value + /// `gizmo::hit_test` was called with, so what the user sees matches + /// where clicks land. + pub gizmo_size: f32, +} + +/// A frozen raycast-probe visualization the Viewport tab paints when **View ▸ +/// Raycast Probe** is on (Stage 9 piece 8c). +/// +/// On a viewport click the host casts the editor camera→cursor ray against the +/// edited scene's colliders (via +/// [`PhysicsWorld::sync_to_scene`](oxide_physics::PhysicsWorld::sync_to_scene) + +/// [`raycast`](oxide_physics::PhysicsWorld::raycast)) and **freezes** the result +/// here. The tab redraws it in world space every frame, so orbiting the camera +/// reveals the ray as a real 3D line — a ray cast from the live camera is +/// otherwise just a point in that same camera's view. Like [`ViewportOverlay`] +/// it is `Copy` and projected with the same view-projection the scene was drawn +/// with. +#[derive(Debug, Clone, Copy)] +pub struct RaycastProbeViz { + /// World-space ray origin (the camera eye-point under the cursor). + pub origin: Vec3, + /// World-space end of the drawn ray: the hit point on a hit, else the ray + /// extended to its probe distance on a miss. + pub end: Vec3, + /// The surface the ray struck, if any. + pub hit: Option, +} + +/// The surface a [`RaycastProbeViz`] ray struck. +#[derive(Debug, Clone, Copy)] +pub struct RaycastProbeHit { + /// World-space hit point on the collider surface. + pub point: Vec3, + /// World-space unit surface normal at the hit. + pub normal: Vec3, +} + +/// Builds a [`CaptureTarget`] for one direction row of an axis action. +fn make_axis_capture(action: &str, dir: AxisDirection, slot: BindingSlot) -> CaptureTarget { + match dir { + AxisDirection::Axis(side) => CaptureTarget::Axis { + action: action.to_string(), + side, + slot, + }, + AxisDirection::Axis2D(side) => CaptureTarget::Axis2D { + action: action.to_string(), + side, + slot, + }, + } +} + +/// Single flat status-bar line and how long it remains visible. +struct StatusLine { + text: String, + /// Wall-clock frames remaining (decremented in `frame_tick`). 0 = hidden. + ttl: u32, +} + +impl StatusLine { + fn idle() -> Self { + Self { + text: String::new(), + ttl: 0, + } + } + + fn say(&mut self, text: impl Into) { + self.text = text.into(); + // ~5 seconds at 60 FPS — short-lived but readable. + self.ttl = 300; + } +} + +/// Which slot of an action's binding list the user is currently rebinding. +/// +/// The bindings preferences page enters one of these states when the user +/// clicks a "Change" / "Add" button; the host runner consumes the next +/// pressed key or mouse button into that slot via +/// [`Shell::try_complete_capture`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CaptureTarget { + /// One of a button action's bindings (`Replace` an existing index, or + /// `Append` a new binding to the list). + Button { action: String, slot: BindingSlot }, + /// One of a 1D-axis action's direction-set bindings. + Axis { + action: String, + side: AxisSide, + slot: BindingSlot, + }, + /// One of a 2D-axis action's four direction-set bindings. + Axis2D { + action: String, + side: Axis2DSide, + slot: BindingSlot, + }, +} + +/// Whether a capture replaces an existing binding at a given index, or +/// appends a new one to the slot's binding list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindingSlot { + Replace(usize), + Append, +} + +/// The two directions of a 1D axis (positive = right/forward/up). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AxisSide { + Positive, + Negative, +} + +/// The four direction-sets of a 2D axis. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Axis2DSide { + Right, + Left, + Up, + Down, +} + +impl CaptureTarget { + /// The action name this capture targets — used by status messages and + /// the bindings UI to label the in-progress capture. + pub fn action(&self) -> &str { + match self { + CaptureTarget::Button { action, .. } + | CaptureTarget::Axis { action, .. } + | CaptureTarget::Axis2D { action, .. } => action.as_str(), + } + } +} + +/// Every key/mouse-button the bindings preferences page will offer to bind +/// when the user clicks "Change". Curated to keep the UI scrollbar humane; +/// the underlying `ActionMap` accepts every `KeyCode`/`MouseButton` so the +/// list can be extended without breaking the model. Iteration order is the +/// order shown in the UI. +const BINDABLE_KEYS: &[KeyCode] = &[ + // Letters + KeyCode::KeyA, + KeyCode::KeyB, + KeyCode::KeyC, + KeyCode::KeyD, + KeyCode::KeyE, + KeyCode::KeyF, + KeyCode::KeyG, + KeyCode::KeyH, + KeyCode::KeyI, + KeyCode::KeyJ, + KeyCode::KeyK, + KeyCode::KeyL, + KeyCode::KeyM, + KeyCode::KeyN, + KeyCode::KeyO, + KeyCode::KeyP, + KeyCode::KeyQ, + KeyCode::KeyR, + KeyCode::KeyS, + KeyCode::KeyT, + KeyCode::KeyU, + KeyCode::KeyV, + KeyCode::KeyW, + KeyCode::KeyX, + KeyCode::KeyY, + KeyCode::KeyZ, + // Top row + KeyCode::Digit0, + KeyCode::Digit1, + KeyCode::Digit2, + KeyCode::Digit3, + KeyCode::Digit4, + KeyCode::Digit5, + KeyCode::Digit6, + KeyCode::Digit7, + KeyCode::Digit8, + KeyCode::Digit9, + // Whitespace + arrows + modifiers + KeyCode::Space, + KeyCode::Tab, + KeyCode::Enter, + KeyCode::Backspace, + KeyCode::ArrowLeft, + KeyCode::ArrowRight, + KeyCode::ArrowUp, + KeyCode::ArrowDown, + KeyCode::ShiftLeft, + KeyCode::ShiftRight, + KeyCode::ControlLeft, + KeyCode::ControlRight, + KeyCode::AltLeft, + KeyCode::AltRight, + // Function keys + KeyCode::F1, + KeyCode::F2, + KeyCode::F3, + KeyCode::F4, + KeyCode::F5, + KeyCode::F6, + KeyCode::F7, + KeyCode::F8, + KeyCode::F9, + KeyCode::F10, + KeyCode::F11, + KeyCode::F12, +]; + +/// The mouse buttons offered as bindings. `Left` is excluded by default so +/// the click that initiated a capture cannot accidentally bind itself +/// (egui's button uses left-click and the runner pumps that into +/// `InputState` before the capture state machine sees the next frame — +/// excluding it removes the whole class of footguns). +const BINDABLE_MOUSE_BUTTONS: &[MouseButton] = &[ + MouseButton::Right, + MouseButton::Middle, + MouseButton::Back, + MouseButton::Forward, +]; + +/// Scans `input` for the first pressed-this-frame key or mouse button (in +/// curated order) and wraps it as a [`Binding`]. Returns `None` when +/// nothing in the curated list was newly pressed this frame. Escape is +/// **not** treated as bindable — the capture state machine reserves it +/// for "cancel". +fn pick_capture_binding(input: &InputState) -> Option { + for &key in BINDABLE_KEYS { + if input.pressed(key) { + return Some(Binding::Key(key)); + } + } + for &button in BINDABLE_MOUSE_BUTTONS { + if input.mouse_pressed(button) { + return Some(Binding::Mouse(button)); + } + } + None +} + +/// Applies a captured `binding` to the target slot of the appropriate +/// binding list, mutating `actions` in place. No-op when the action is +/// unregistered (the UI is built against `actions`, so this should not +/// happen in practice). +fn apply_capture( + actions: &mut oxide_engine::input::ActionMap, + target: &CaptureTarget, + binding: Binding, +) { + match target { + CaptureTarget::Button { action, slot } => { + let mut bindings = actions.bindings(action).to_vec(); + apply_to_list(&mut bindings, *slot, binding); + actions.set_bindings(action, bindings); + } + CaptureTarget::Axis { action, side, slot } => { + let Some(current) = actions.axis_bindings(action) else { + return; + }; + let mut next = current.clone(); + apply_to_list(side_list_mut(&mut next, *side), *slot, binding); + actions.set_axis_bindings(action, next); + } + CaptureTarget::Axis2D { action, side, slot } => { + let Some(current) = actions.axis_2d_bindings(action) else { + return; + }; + let mut next = current.clone(); + apply_to_list(side_2d_list_mut(&mut next, *side), *slot, binding); + actions.set_axis_2d_bindings(action, next); + } + } +} + +fn apply_to_list(list: &mut Vec, slot: BindingSlot, binding: Binding) { + match slot { + BindingSlot::Replace(i) if i < list.len() => list[i] = binding, + BindingSlot::Replace(_) | BindingSlot::Append => list.push(binding), + } +} + +fn side_list_mut(axis: &mut oxide_engine::input::AxisBinding, side: AxisSide) -> &mut Vec { + match side { + AxisSide::Positive => &mut axis.positive, + AxisSide::Negative => &mut axis.negative, + } +} + +fn side_2d_list_mut( + axis: &mut oxide_engine::input::Axis2DBinding, + side: Axis2DSide, +) -> &mut Vec { + match side { + Axis2DSide::Right => &mut axis.x.positive, + Axis2DSide::Left => &mut axis.x.negative, + Axis2DSide::Up => &mut axis.y.positive, + Axis2DSide::Down => &mut axis.y.negative, + } +} + +/// Human-readable label for a binding (used in the UI and status messages). +fn describe_binding(b: &Binding) -> String { + match b { + Binding::Key(k) => format!("{k:?}"), + Binding::Mouse(m) => format!("Mouse({m:?})"), + } +} + +/// The editor's docking shell. +/// +/// Owns the dock state, the command stack, the extension registry, and the +/// transient UI state (dialog open flags, edit buffers, file-watcher events +/// queued for the next frame). [`EditorState`] is the heap of mutable runtime +/// data that commands act on; the shell holds it. +pub struct Shell { + /// Runtime state commands mutate (scene, selection, assets, settings, + /// project). + pub state: EditorState, + /// Layout of every dockable panel. Persisted layouts land in a later + /// piece; today the default is built in [`Shell::default_dock`]. + dock: DockState, + /// Editor-wide undo/redo. Cleared on project open; bounded capacity. + commands: CommandStack, + /// Module-contributed UI (menu items, panels, settings pages…). Populated + /// by the host before [`build`](Self::build) runs. + pub extensions: EditorExtensions, + /// Active file watcher (if a project is open) and its event receiver. The + /// shell pumps the receiver each frame in [`frame_tick`](Self::frame_tick). + watcher: Option, + watcher_events: Option>, + + // --- dialog flags -------------------------------------------------- + show_preferences: bool, + show_about: bool, + show_new_project: bool, + show_open_project: bool, + /// The "Edit layer names" modal — opened from the `Layer` dropdown so the + /// project can name layers `Player`, `Enemy`, `World`, … instead of bare + /// numeric indices. + show_layer_editor: bool, + /// The "Groups" modal — opened from the `Groups` dropdown so the project + /// can define the gameplay groups an entity may be tagged with. + show_group_editor: bool, + + // --- modal scratch ------------------------------------------------- + /// Text entered in the "New Project" modal (path + display name). + new_project_path: String, + new_project_name: String, + /// Text entered in the "Open Project" modal. + open_project_path: String, + /// Text entered in the "Groups" editor's "add group" field. + new_group_name: String, + /// The Console panel's command-input buffer (the terminal prompt). + terminal_input: String, + + /// File → Quit sets this; the host polls and calls `request_exit` on + /// the next `update`. egui's own viewport-close command doesn't reach + /// our winit runner, so the round-trip lives here. + quit_requested: bool, + + /// Screen-space rect of the Viewport tab from the most recent UI build, + /// in physical pixels. The host queries this to decide whether the + /// cursor is over the 3D viewport (and thus should drive orbit/pan/zoom/ + /// pick) versus over an opaque panel. + viewport_rect_px: Option<(f32, f32, f32, f32)>, + + // --- per-frame UI scratch ----------------------------------------- + pending: Vec, + rename_buf: String, + /// Euler-degrees buffer for the inspector's quaternion fields. A `Quat` + /// field is edited as Euler angles (raw quaternions are unusable by hand); + /// the buffer holds the in-progress angles and is re-synced from the stored + /// quaternion only when the edited field changes (so the displayed angles + /// don't jump mid-edit from lossy quat↔euler round-trips). Keyed by which + /// `(entity, type, field)` it currently mirrors. + rot_euler: Vec3, + euler_for: Option<(Entity, &'static str, &'static str)>, + /// Most recently seen status text + remaining frames. + status: StatusLine, + + /// Set by the bindings preferences page when the user clicks "Change" / + /// "Add". The host polls [`Shell::try_complete_capture`] each frame and + /// consumes the next pressed key/button into the selected slot. + capture: Option, + /// Set to `true` whenever the input bindings change so the host can + /// flush the editor preferences file to disk. Cleared by + /// [`Shell::take_bindings_dirty`]. + bindings_dirty: bool, + + /// Per-frame projection data the host feeds in before [`build`](Self::build) + /// runs, so the Viewport tab can paint the gizmo overlay using the same + /// math the renderer drew with. `None` means no overlay this frame. + viewport_overlay: Option, + + /// Set by the play toolbar's **Step** button while paused; the host polls + /// [`take_step_request`](Self::take_step_request) once per frame and, if set, + /// advances the play [`App`](oxide_engine::app::App) exactly one fixed tick. + /// One-shot: a single click steps once. + step_requested: bool, + + /// View ▸ Show Colliders: when set, the Viewport tab paints a wireframe + /// outline of every entity's physics `Collider` (Stage 9 piece 8b). On by + /// default — the usual DCC convention so colliders are visible the moment + /// one is added. + show_colliders: bool, + + /// View ▸ Raycast Probe: when on, a viewport click casts the editor + /// camera→cursor ray against the scene's colliders and **freezes** the + /// result into the world (Stage 9 piece 8c). Off by default — a debug aid + /// to verify the raycast API and inspect colliders. + raycast_probe: bool, + + /// The frozen probe ray, drawn by the Viewport tab every frame in world + /// space (so orbiting the camera reveals it as a real 3D line). Set on a + /// probe click via [`set_raycast_probe_viz`](Self::set_raycast_probe_viz); + /// cleared when the probe is toggled off. `None` = nothing cast yet. + raycast_probe_viz: Option, + + /// The interactive PTY terminal sessions shown as tabs in the Terminal panel + /// (a shell, an AI-agent CLI, …). Empty until one is launched; a session is + /// dropped (which kills its child) when closed or when its program exits. + terminals: Vec, + /// Index of the active terminal tab within [`terminals`](Self::terminals). + active_terminal: usize, +} + +impl Shell { + /// A shell with a starter scene and the default dock layout. + pub fn new() -> Self { + Self::from_state(EditorState::with_scene(starter_scene())) + } + + /// A shell wrapping the given state (useful for tests). + pub fn from_state(state: EditorState) -> Self { + Self { + state, + dock: Self::default_dock(), + commands: CommandStack::with_capacity(256), + extensions: EditorExtensions::new(), + watcher: None, + watcher_events: None, + show_preferences: false, + show_about: false, + show_new_project: false, + show_open_project: false, + show_layer_editor: false, + show_group_editor: false, + new_project_path: String::new(), + new_project_name: String::new(), + open_project_path: String::new(), + new_group_name: String::new(), + terminal_input: String::new(), + quit_requested: false, + viewport_rect_px: None, + pending: Vec::new(), + rename_buf: String::new(), + rot_euler: Vec3::ZERO, + euler_for: None, + status: StatusLine::idle(), + capture: None, + bindings_dirty: false, + viewport_overlay: None, + step_requested: false, + show_colliders: true, + raycast_probe: false, + raycast_probe_viz: None, + terminals: Vec::new(), + active_terminal: 0, + } + } + + /// Sets the per-frame data the Viewport tab needs to paint the gizmo + /// overlay. The host calls this before [`build`](Self::build) each + /// frame; pass `None` to suppress the overlay (e.g. while a modal is + /// open). + pub fn set_viewport_overlay(&mut self, overlay: Option) { + self.viewport_overlay = overlay; + } + + /// Whether **View ▸ Raycast Probe** is on. The host polls this each frame + /// and, when set, computes the probe and feeds it back via + /// [`set_raycast_probe_viz`](Self::set_raycast_probe_viz). + pub fn raycast_probe_enabled(&self) -> bool { + self.raycast_probe + } + + /// Stores the raycast-probe result for the Viewport tab to paint this frame + /// (set by the host; `None` clears it). + pub fn set_raycast_probe_viz(&mut self, viz: Option) { + self.raycast_probe_viz = viz; + } + + /// Returns whether the user has asked to quit since the last call, and + /// clears the flag. The host runner calls this in `update` and forwards + /// to `AppCtx::request_exit`. + pub fn take_quit_request(&mut self) -> bool { + std::mem::take(&mut self.quit_requested) + } + + /// Sets the status-bar hint to `text` for a short fade-out. The host + /// uses this to surface non-shell events (e.g. the camera-mode toggle) + /// in the same place as undo/redo and save messages. + pub fn set_status_hint(&mut self, text: impl Into) { + self.status.say(text); + } + + // --- Play-mode controls (Stage 8.7) ----------------------------------- + + /// Whether the user asked for a single **Step** since the last call, and + /// clears the flag. The host polls this each frame and, while paused, + /// advances the play [`App`](oxide_engine::app::App) one fixed tick. + pub fn take_step_request(&mut self) -> bool { + std::mem::take(&mut self.step_requested) + } + + /// Enters **Play**: snapshots the scene and starts running it. Clears the + /// undo history so play-mode edits never leak into edit-mode undo (the scene + /// is restored wholesale on Stop). No-op if already playing or paused. + pub fn play(&mut self) { + if self.state.is_in_play() { + return; + } + self.state.enter_play(); + self.commands.clear(); + self.status.say("Playing"); + } + + /// The Play/Resume button's action: starts play while editing, or resumes + /// (un-pauses) while paused. A no-op while already playing. This is distinct + /// from [`play`](Self::play), which only ever *starts* play — calling it + /// while paused does nothing, which is why Resume must route through here. + pub fn play_or_resume(&mut self) { + match self.state.play { + PlayState::Editing => self.play(), + PlayState::Paused => self.toggle_pause(), + PlayState::Playing => {} + } + } + + /// Toggles **Pause** while in play. No-op while editing. + pub fn toggle_pause(&mut self) { + if !self.state.is_in_play() { + return; + } + self.state.toggle_pause(); + let label = if self.state.play == PlayState::Paused { + "Paused" + } else { + "Playing" + }; + self.status.say(label); + } + + /// Requests a single fixed-step **Step**. Only meaningful while paused; the + /// host honours it through [`take_step_request`](Self::take_step_request). + pub fn request_step(&mut self) { + if self.state.play == PlayState::Paused { + self.step_requested = true; + self.status.say("Step"); + } + } + + /// **Stops** play and restores the scene to its pre-play snapshot. Clears the + /// undo history (the restore is not itself undoable). No-op while editing. + pub fn stop(&mut self) { + if !self.state.is_in_play() { + return; + } + if let Err(err) = self.state.stop() { + log::error!("failed to restore scene on Stop: {err}"); + self.status.say(format!("Stop: restore failed: {err}")); + } else { + self.status.say("Stopped"); + } + self.commands.clear(); + } + + // --- Input-bindings capture API (Stage 7 piece 5) --------------------- + + /// Whether the bindings preferences page is currently waiting on the + /// next key/button press to bind. The host runner uses this to gate + /// other input handling (e.g. the camera mode toggle) while a capture + /// is in flight, so the same press cannot do two things. + pub fn capture_active(&self) -> bool { + self.capture.is_some() + } + + /// The target of the current capture, if any. The bindings UI uses this + /// to render the in-progress slot differently ("Press a key…"). + pub fn current_capture(&self) -> Option<&CaptureTarget> { + self.capture.as_ref() + } + + /// Drives the [bindings preferences page](Self::capture_active) into a + /// "waiting for next key/button" state. Called by the page's UI when the + /// user clicks Change/Add. + pub fn begin_capture(&mut self, target: CaptureTarget) { + self.capture = Some(target); + } + + /// Cancels any in-progress capture without binding anything. + pub fn cancel_capture(&mut self) { + if self.capture.take().is_some() { + self.status.say("Binding capture cancelled"); + } + } + + /// Looks at `input` and, if a key or non-`Escape` mouse button became + /// pressed this frame, commits it as the binding for the currently- + /// captured slot. `Escape` cancels capture without binding. + /// + /// Returns whether the capture finished this call (either committed or + /// cancelled) so the host can drop other input handling for the rest of + /// the frame. + pub fn try_complete_capture(&mut self, input: &InputState) -> bool { + if self.capture.is_none() { + return false; + } + if input.pressed(KeyCode::Escape) { + self.cancel_capture(); + return true; + } + let Some(binding) = pick_capture_binding(input) else { + return false; + }; + // `take` unwrap is safe — guarded by the early-return above. + let target = self.capture.take().unwrap(); + apply_capture(&mut self.state.actions, &target, binding); + self.state.sync_action_overrides_to_settings(); + self.bindings_dirty = true; + self.status.say(format!( + "Bound {} → {}", + target.action(), + describe_binding(&binding) + )); + true + } + + /// Reads-and-clears the bindings-dirty flag. The host calls this each + /// frame and writes the editor preferences file when it returns `true`. + pub fn take_bindings_dirty(&mut self) -> bool { + std::mem::take(&mut self.bindings_dirty) + } + + /// Pushes `command` onto the shell's undo stack with `EditorState` as + /// its context. The hosting binary uses this to record changes it made + /// directly to the scene (e.g. ending a gizmo drag) so the same Ctrl+Z + /// rolls them back as if the user had used the inspector. + pub fn push_command(&mut self, command: impl Command + 'static) { + self.commands.push(command, &mut self.state); + // Rotation buffers, etc. don't necessarily match the new transform. + self.euler_for = None; + } + + /// Restores every editor action to its code-defined defaults and flips + /// the dirty flag. Called by the "Restore all defaults" button. + pub fn restore_default_bindings(&mut self) { + self.state.actions.restore_all_defaults(); + self.state.sync_action_overrides_to_settings(); + self.bindings_dirty = true; + self.status.say("Restored default bindings"); + } + + /// Whether the physical-pixel cursor lies inside the Viewport tab's + /// most-recent rect — i.e. over the visible 3D scene rather than an + /// opaque panel. egui marks the whole tab area as "consumed" because + /// the tab is technically an egui widget; the host uses this to override + /// that and route orbit/pan/zoom/pick to the camera. + pub fn cursor_over_viewport(&self, cursor: (f32, f32)) -> bool { + let Some((x0, y0, x1, y1)) = self.viewport_rect_px else { + return false; + }; + cursor.0 >= x0 && cursor.0 <= x1 && cursor.1 >= y0 && cursor.1 <= y1 + } + + /// The Viewport tab's most-recent rect in physical pixels, as a + /// [`Rect`](oxide_engine::math::Rect). The host's render path uses this + /// to restrict the wgpu viewport and projection so the 3D scene + /// matches the tab's bounds (rather than stretching across the whole + /// window) and so picking aligns with what the user sees. `None` until + /// the first UI build (the editor's render falls back to the full + /// surface in that case). + pub fn viewport_rect(&self) -> Option { + let (x0, y0, x1, y1) = self.viewport_rect_px?; + Some(oxide_engine::math::Rect::from_min_size( + oxide_engine::math::Vec2::new(x0, y0), + oxide_engine::math::Vec2::new((x1 - x0).max(1.0), (y1 - y0).max(1.0)), + )) + } + + /// The shipped layout: Hierarchy on the left, Inspector on the right, + /// Viewport + UI Canvas in the center (both need the large central area), + /// Project / Console along the bottom. + fn default_dock() -> DockState { + // The UI Canvas shares the central node with the Viewport as tabs: it + // needs both horizontal and vertical room, so the thin bottom strip is + // the wrong home for it. + let mut dock = DockState::new(vec![PanelKind::Viewport, PanelKind::UiCanvas]); + let root = NodeIndex::root(); + let surface = dock.main_surface_mut(); + let [_center, _left] = surface.split_left(root, 0.22, vec![PanelKind::Hierarchy]); + let [center_remaining, _right] = + surface.split_right(NodeIndex::root(), 0.78, vec![PanelKind::Inspector]); + // Bottom strip on the center column carries Project + Console as tabs. + surface.split_below( + center_remaining, + 0.70, + vec![PanelKind::Project, PanelKind::Console, PanelKind::Terminal], + ); + dock + } + + // ---- project / watcher ------------------------------------------- + + /// Opens `path` as a project, clearing the undo history and starting a + /// file watcher over the project's `assets/`, `scenes/`, and `scripts/` + /// directories so live-reload is on by default. + pub fn open_project(&mut self, path: impl Into) -> Result<(), ProjectError> { + let path = path.into(); + let project = Project::open(&path)?; + self.state.recent.record(project.root()); + self.attach_watcher(&project); + self.open_asset_db(project.root()); + self.state.project = Some(project); + self.commands.clear(); + self.status.say(format!("Opened {}", path.display())); + Ok(()) + } + + /// Creates a new project at `path` with the given display name. + pub fn create_project( + &mut self, + path: impl Into, + name: impl Into, + ) -> Result<(), ProjectError> { + let path = path.into(); + let project = Project::create(&path, name)?; + self.state.recent.record(project.root()); + // Seed the bundled default UI font so a fresh project has something to + // pick in the asset browser, referenced by project-relative path. + match crate::assets::seed_default_font(&project.assets_dir()) { + Ok(true) => self.status.say("Added default UI font"), + Ok(false) => {} + Err(err) => log::warn!("could not seed default font: {err}"), + } + self.attach_watcher(&project); + self.open_asset_db(project.root()); + self.state.project = Some(project); + self.commands.clear(); + self.status.say(format!("Created {}", path.display())); + Ok(()) + } + + /// Closes the open project (if any), stopping the watcher. + pub fn close_project(&mut self) { + self.state.project = None; + self.state.asset_db = None; + self.watcher = None; + self.watcher_events = None; + self.commands.clear(); + self.status.say("Closed project"); + } + + /// Opens (and scans) the asset database for the project at `root`, then + /// persists the manifest so freshly-assigned uids survive the next session. + /// Stored on [`EditorState::asset_db`] for the browser and asset-picker. + fn open_asset_db(&mut self, root: &std::path::Path) { + let mut db = AssetDatabase::open(root); + db.scan(); + if let Err(err) = db.save() { + log::warn!("could not write asset manifest: {err}"); + } + self.state.asset_db = Some(db); + } + + fn attach_watcher(&mut self, project: &Project) { + // ~150 ms is the Stage-6 default; long enough to coalesce a "save" + // burst, short enough that the editor feels responsive. + match FileWatcher::new(Duration::from_millis(150)) { + Ok((mut watcher, events)) => { + let mut failed = false; + for dir in [ + project.assets_dir(), + project.scenes_dir(), + project.scripts_dir(), + ] { + if dir.exists() { + if let Err(err) = watcher.watch(&dir) { + log::warn!("file watcher could not watch {}: {err}", dir.display()); + failed = true; + } + } + } + self.watcher = Some(watcher); + self.watcher_events = Some(events); + if failed { + self.status.say("File watcher partially active (see log)"); + } + } + Err(err) => { + log::warn!("file watcher unavailable: {err}"); + self.watcher = None; + self.watcher_events = None; + self.status.say("File watcher unavailable"); + } + } + } + + /// Called once per frame (between event handling and rendering) to drain + /// any settled file-watcher events into the asset server. Cheap when + /// there's nothing to do. + pub fn frame_tick(&mut self) { + if let Some(rx) = &self.watcher_events { + let mut events: Vec = Vec::new(); + while let Ok(ev) = rx.try_recv() { + events.push(ev); + } + if !events.is_empty() { + let n = reload_changed_assets(&self.state.assets, events); + if n > 0 { + self.status.say(format!("Reloaded {n} asset(s)")); + } + // A watcher burst may have added or removed files under + // `assets/`; reconcile the database so the browser and picker + // reflect the change. Cheap (a directory walk) and only when + // something actually changed on disk. + if let Some(db) = &mut self.state.asset_db { + let added = db.scan(); + if added > 0 { + let _ = db.save(); + self.status.say(format!("Imported {added} asset(s)")); + } + } + } + } + if self.status.ttl > 0 { + self.status.ttl -= 1; + } + } + + // ---- key bindings ------------------------------------------------- + + /// Returns whether `key_event` was handled (so the caller can suppress + /// further processing). Handles editor-global shortcuts: + /// + /// - `Ctrl+Z` — undo + /// - `Ctrl+Y` / `Ctrl+Shift+Z` — redo + /// - `Ctrl+S` — save project (no-op without a project) + /// - `Ctrl+,` — toggle Preferences + /// - `Ctrl+P` — Play / Pause toggle (Play when editing; Pause⇄Resume when + /// running) + /// - `Ctrl+.` — Step one fixed tick (while paused) + pub fn try_consume_shortcut(&mut self, ctrl: bool, shift: bool, ch: Option) -> bool { + if !ctrl { + return false; + } + let Some(ch) = ch.map(|c| c.to_ascii_lowercase()) else { + return false; + }; + match ch { + 'z' if !shift => { + if let Some(label) = self.commands.undo(&mut self.state) { + self.status.say(format!("Undo: {label}")); + } + true + } + 'y' | 'z' /* shift+z = redo */ => { + if let Some(label) = self.commands.redo(&mut self.state) { + self.status.say(format!("Redo: {label}")); + } + true + } + 's' => { + self.save_project(); + true + } + ',' => { + self.show_preferences = !self.show_preferences; + true + } + 'p' => { + // Play when editing; toggle pause while running. + if self.state.is_in_play() { + self.toggle_pause(); + } else { + self.play(); + } + true + } + '.' => { + self.request_step(); + true + } + _ => false, + } + } + + /// Sets (or clears) the selection from outside the UI pass — used by the + /// viewport's ray-pick on click. Also refreshes the inspector's rename + /// buffer and the rotation-euler scratch buffer, so the inspector reflects + /// the new selection on its next frame. + pub fn select(&mut self, entity: Option) { + self.state.selected = entity; + self.rename_buf = entity + .and_then(|e| self.state.scene.name(e)) + .unwrap_or_default(); + self.euler_for = None; + } + + fn save_project(&mut self) { + let Some(project) = &self.state.project else { + self.status.say("No project open"); + return; + }; + match project.save() { + Ok(()) => self.status.say(format!("Saved {}", project.name())), + Err(err) => self.status.say(format!("Save failed: {err}")), + } + } + + // ---- top-level UI build ------------------------------------------ + + /// Builds the editor UI for one frame: menu bar, dock area, status bar, + /// and any open modal windows. Must be idempotent — egui may run the + /// closure more than once during layout. + pub fn build(&mut self, ui: &mut egui::Ui) { + self.pending.clear(); + + self.menu_bar(ui); + self.play_toolbar(ui); + self.status_bar(ui); + self.preferences_window(ui); + self.about_window(ui); + self.layer_editor_window(ui); + self.group_editor_window(ui); + self.new_project_window(ui); + self.open_project_window(ui); + + // Clear the rect each frame so a hidden Viewport tab reverts to + // "cursor never over viewport" — the next time the tab is shown its + // `ui` callback refills it. + self.viewport_rect_px = None; + let pixels_per_point = ui.ctx().pixels_per_point(); + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show_inside(ui, |ui| { + let mut viewer = ShellTabViewer { + state: &mut self.state, + commands: &mut self.commands, + extensions: &mut self.extensions, + pending: &mut self.pending, + rename_buf: &mut self.rename_buf, + terminal_input: &mut self.terminal_input, + rot_euler: &mut self.rot_euler, + euler_for: &mut self.euler_for, + show_layer_editor: &mut self.show_layer_editor, + show_group_editor: &mut self.show_group_editor, + viewport_rect_px: &mut self.viewport_rect_px, + viewport_overlay: self.viewport_overlay, + pixels_per_point, + show_colliders: self.show_colliders, + raycast_probe_viz: self.raycast_probe_viz, + terminals: &mut self.terminals, + active_terminal: &mut self.active_terminal, + }; + DockArea::new(&mut self.dock) + .style(Style::from_egui(ui.style().as_ref())) + .show_inside(ui, &mut viewer); + }); + + // Apply the structural edits collected during the UI pass. + let actions = std::mem::take(&mut self.pending); + for action in actions { + self.apply(action); + } + } + + fn menu_bar(&mut self, ui: &mut egui::Ui) { + egui::Panel::top("oxide.menu_bar").show_inside(ui, |ui| { + egui::MenuBar::new().ui(ui, |ui| { + ui.menu_button("File", |ui| { + if ui.button("New Project…").clicked() { + // Pre-fill with a sensible default so the user only + // has to confirm; they can edit either field. + if self.new_project_path.is_empty() { + if let Some(home) = std::env::var_os("HOME") { + let mut p = PathBuf::from(home); + p.push("oxide-projects/untitled"); + self.new_project_path = p.display().to_string(); + } + } + if self.new_project_name.is_empty() { + self.new_project_name = "Untitled".to_owned(); + } + self.show_new_project = true; + ui.close(); + } + if ui.button("Open Project…").clicked() { + if self.open_project_path.is_empty() { + // Suggest the last-used project as a starting + // point if there is one. + if let Some(p) = self.state.recent.entries().last() { + self.open_project_path = p.display().to_string(); + } + } + self.show_open_project = true; + ui.close(); + } + ui.menu_button("Open Recent", |ui| { + if self.state.recent.entries().is_empty() { + ui.weak("(none)"); + } + let recents: Vec = self.state.recent.entries().to_vec(); + for path in recents { + if ui.button(path.display().to_string()).clicked() { + if let Err(err) = self.open_project(&path) { + self.status.say(format!("Open failed: {err}")); + } + ui.close(); + } + } + }); + ui.separator(); + let enabled = self.state.project.is_some(); + if ui + .add_enabled(enabled, egui::Button::new("Save Project")) + .clicked() + { + self.save_project(); + ui.close(); + } + if ui + .add_enabled(enabled, egui::Button::new("Close Project")) + .clicked() + { + self.close_project(); + ui.close(); + } + ui.separator(); + if ui.button("Quit (Ctrl+Q)").clicked() { + // Flagged here, observed by the host runner in + // `update()` next frame — egui's `ViewportCommand::Close` + // is a viewport-level signal, not a winit close, so + // we route through our own mechanism. + self.quit_requested = true; + ui.close(); + } + }); + + ui.menu_button("Edit", |ui| { + // Keep the menu labels short — long action names made the + // buttons stretch vertically. The label still appears in + // the status bar after the action runs. + let can_undo = self.commands.can_undo(); + let can_redo = self.commands.can_redo(); + if ui + .add_enabled(can_undo, egui::Button::new("Undo")) + .clicked() + { + if let Some(label) = self.commands.undo(&mut self.state) { + self.status.say(format!("Undo: {label}")); + } + ui.close(); + } + if ui + .add_enabled(can_redo, egui::Button::new("Redo")) + .clicked() + { + if let Some(label) = self.commands.redo(&mut self.state) { + self.status.say(format!("Redo: {label}")); + } + ui.close(); + } + }); + + ui.menu_button("View", |ui| { + for kind in [ + PanelKind::Hierarchy, + PanelKind::Inspector, + PanelKind::Viewport, + PanelKind::Project, + PanelKind::UiCanvas, + PanelKind::Console, + ] { + let visible = self.dock_contains(&kind); + if ui + .selectable_label(visible, format!("Show {}", kind.title())) + .clicked() + { + self.toggle_panel(kind); + ui.close(); + } + } + ui.separator(); + // Viewport overlays (Stage 9 piece 8b: physics collider wireframes). + if ui + .selectable_label(self.show_colliders, "Show Colliders") + .clicked() + { + self.show_colliders = !self.show_colliders; + ui.close(); + } + // Stage 9 piece 8c: raycast debug probe (click to freeze a + // camera→cursor ray into the world, then orbit to view it). + if ui + .selectable_label(self.raycast_probe, "Raycast Probe") + .clicked() + { + self.raycast_probe = !self.raycast_probe; + if self.raycast_probe { + self.set_status_hint( + "Raycast probe on — click in the viewport to cast a debug ray", + ); + } else { + self.raycast_probe_viz = None; + } + ui.close(); + } + }); + + ui.menu_button("Project", |ui| { + if let Some(project) = &self.state.project { + ui.label(format!("Open: {}", project.name())); + ui.label(project.root().display().to_string()); + } else { + ui.weak("(no project open)"); + } + }); + + // Module-contributed menu items, attributed to their owning + // module's group; built-in menu items above are not routed + // through the registry. + if self.extensions.iter_menu_items().next().is_some() { + ui.menu_button("Modules", |ui| { + let paths: Vec = self + .extensions + .iter_menu_items() + .map(|i| i.path.clone()) + .collect(); + for path in paths { + if ui.button(&path).clicked() { + self.invoke_menu_item(&path); + ui.close(); + } + } + }); + } + + ui.menu_button("Help", |ui| { + if ui.button("About Oxide…").clicked() { + self.show_about = true; + ui.close(); + } + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("⚙ Preferences").clicked() { + self.show_preferences = !self.show_preferences; + } + }); + }); + }); + } + + /// The play-mode toolbar (Stage 8.7): a centered Play/Pause/Step/Stop row + /// just below the menu bar. Buttons are gated by the current + /// [`PlayState`](crate::state::PlayState) — Pause/Step/Stop only enable while + /// running — and a "PLAYING"/"PAUSED" badge makes edit-vs-play unambiguous. + /// Text labels (no media glyphs) keep it readable in egui's bundled font. + fn play_toolbar(&mut self, ui: &mut egui::Ui) { + egui::Panel::top("oxide.play_toolbar").show_inside(ui, |ui| { + ui.horizontal(|ui| { + let playing = self.state.play == PlayState::Playing; + let paused = self.state.play == PlayState::Paused; + let in_play = self.state.is_in_play(); + + // Play / Resume — disabled while already playing. While paused + // this resumes (un-pauses); while editing it starts play. + let play_label = if paused { "Resume" } else { "Play" }; + if ui + .add_enabled(!playing, egui::Button::new(play_label)) + .on_hover_text("Run the scene (Ctrl+P)") + .clicked() + { + self.play_or_resume(); + } + // Pause — only while playing. + if ui + .add_enabled(playing, egui::Button::new("Pause")) + .on_hover_text("Freeze the simulation (Ctrl+P)") + .clicked() + { + self.toggle_pause(); + } + // Step — one fixed tick, only while paused. + if ui + .add_enabled(paused, egui::Button::new("Step")) + .on_hover_text("Advance one fixed tick (Ctrl+.)") + .clicked() + { + self.request_step(); + } + // Stop — restore the scene, only while running. + if ui + .add_enabled(in_play, egui::Button::new("Stop")) + .on_hover_text("Stop and restore the scene") + .clicked() + { + self.stop(); + } + + // State badge so edit-vs-play is never ambiguous. + ui.separator(); + if playing { + ui.colored_label(egui::Color32::from_rgb(120, 220, 120), "PLAYING"); + } else if paused { + ui.colored_label(egui::Color32::from_rgb(235, 200, 90), "PAUSED"); + } else { + ui.weak("Editing"); + } + }); + }); + } + + fn status_bar(&mut self, ui: &mut egui::Ui) { + egui::Panel::bottom("oxide.status_bar").show_inside(ui, |ui| { + ui.horizontal(|ui| { + if self.status.ttl > 0 { + ui.label(&self.status.text); + } else { + ui.weak("ready"); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let depth = self.commands.undo_depth(); + ui.weak(format!("undo: {depth}")); + ui.separator(); + let modules = self.extensions.modules().count(); + ui.weak(format!("modules: {modules}")); + }); + }); + }); + } + + fn preferences_window(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let mut open = self.show_preferences; + egui::Window::new("Preferences") + .open(&mut open) + .default_size([640.0, 480.0]) + .resizable(true) + .show(&ctx, |ui| { + egui::Panel::left("oxide.prefs.sidebar") + .default_size(160.0) + .show_inside(ui, |ui| { + ui.heading("Sections"); + for name in self.state.settings.names() { + ui.label(name); + } + ui.separator(); + ui.heading("Modules"); + let modules: Vec<&'static str> = self.extensions.modules().collect(); + for m in modules { + let mut enabled = self.extensions.is_module_enabled(m); + if ui.checkbox(&mut enabled, m).changed() { + self.extensions.set_module_enabled(m, enabled); + } + } + }); + egui::CentralPanel::default().show_inside(ui, |ui| { + ui.heading("Settings"); + // Snapshot the section names so the central panel can + // mutably borrow `self` (the bindings UI needs to call + // `self.input_bindings_page` and edit `self.state`). + let section_names: Vec<&'static str> = self.state.settings.names().collect(); + if section_names.is_empty() { + ui.weak( + "No settings sections registered yet. Engine, editor, and \ + modules add sections here.", + ); + } + for name in section_names { + // The input bindings section gets a rich UI; every + // other section still shows raw RON until a per- + // section editor lands. + if name == crate::bindings::SETTINGS_SECTION { + ui.collapsing("Input Bindings", |ui| self.input_bindings_page(ui)); + } else { + let ron = self.state.settings.section_ron(name); + ui.collapsing(name, |ui| match ron { + Some(ron) => { + ui.monospace(ron); + } + None => { + ui.weak("(no value)"); + } + }); + } + } + ui.separator(); + ui.weak( + "Rich per-section editors arrive incrementally — engine prefs \ + (Stage 7+ render quality), editor prefs (theme, layout), and \ + per-module pages from the extension API.", + ); + }); + }); + self.show_preferences = open; + } + + /// Renders the input-bindings preferences page: every registered editor + /// action with its current bindings, a Change/Add/Clear control per + /// slot, a per-action "Restore defaults" button, and a global + /// "Restore all defaults" button. While a capture is in flight the + /// targeted slot shows "Press a key… (Esc to cancel)" and the rest of + /// the page is read-only. + fn input_bindings_page(&mut self, ui: &mut egui::Ui) { + let capturing = self.capture.is_some(); + ui.horizontal(|ui| { + ui.label(if capturing { + "Press a key or button to bind · Esc cancels" + } else { + "Click a binding to change it · empty slot adds" + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Restore all defaults").clicked() { + self.pending.push(PendingAction::RestoreAllBindings); + } + }); + }); + ui.separator(); + + // Buttons ----------------------------------------------------------- + let button_actions: Vec = + self.state.actions.actions().map(str::to_string).collect(); + if !button_actions.is_empty() { + ui.heading("Buttons"); + for action in &button_actions { + self.button_row(ui, action, capturing); + } + ui.add_space(8.0); + } + + // 1D axes ---------------------------------------------------------- + let axis_actions: Vec = self.state.actions.axes().map(str::to_string).collect(); + if !axis_actions.is_empty() { + ui.heading("Axes (1D)"); + for action in &axis_actions { + self.axis_row(ui, action, capturing); + } + ui.add_space(8.0); + } + + // 2D axes ---------------------------------------------------------- + let axis_2d_actions: Vec = + self.state.actions.axes_2d().map(str::to_string).collect(); + if !axis_2d_actions.is_empty() { + ui.heading("Axes (2D)"); + for action in &axis_2d_actions { + self.axis_2d_row(ui, action, capturing); + } + } + } + + fn button_row(&mut self, ui: &mut egui::Ui, action: &str, capturing: bool) { + let bindings = self.state.actions.bindings(action).to_vec(); + ui.horizontal(|ui| { + ui.label(action); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("↺").on_hover_text("Restore defaults").clicked() { + self.pending + .push(PendingAction::RestoreActionDefaults(action.to_string())); + } + if ui.button("+").on_hover_text("Add binding").clicked() && !capturing { + self.capture = Some(CaptureTarget::Button { + action: action.to_string(), + slot: BindingSlot::Append, + }); + } + for (i, binding) in bindings.iter().enumerate().rev() { + if ui.small_button("🗑").on_hover_text("Remove").clicked() { + self.pending.push(PendingAction::RemoveButtonBinding { + action: action.to_string(), + index: i, + }); + } + let label = self.slot_label( + &CaptureTarget::Button { + action: action.to_string(), + slot: BindingSlot::Replace(i), + }, + Some(*binding), + ); + if ui.button(label).clicked() && !capturing { + self.capture = Some(CaptureTarget::Button { + action: action.to_string(), + slot: BindingSlot::Replace(i), + }); + } + } + }); + }); + } + + fn axis_row(&mut self, ui: &mut egui::Ui, action: &str, capturing: bool) { + let axis = self.state.actions.axis_bindings(action).cloned(); + let Some(axis) = axis else { + return; + }; + ui.horizontal(|ui| { + ui.label(action); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("↺").on_hover_text("Restore defaults").clicked() { + self.pending + .push(PendingAction::RestoreActionDefaults(action.to_string())); + } + }); + }); + self.direction_row( + ui, + action, + AxisDirection::Axis(AxisSide::Positive), + &axis.positive, + capturing, + ); + self.direction_row( + ui, + action, + AxisDirection::Axis(AxisSide::Negative), + &axis.negative, + capturing, + ); + } + + fn axis_2d_row(&mut self, ui: &mut egui::Ui, action: &str, capturing: bool) { + let axis = self.state.actions.axis_2d_bindings(action).cloned(); + let Some(axis) = axis else { + return; + }; + ui.horizontal(|ui| { + ui.label(action); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("↺").on_hover_text("Restore defaults").clicked() { + self.pending + .push(PendingAction::RestoreActionDefaults(action.to_string())); + } + }); + }); + self.direction_row( + ui, + action, + AxisDirection::Axis2D(Axis2DSide::Right), + &axis.x.positive, + capturing, + ); + self.direction_row( + ui, + action, + AxisDirection::Axis2D(Axis2DSide::Left), + &axis.x.negative, + capturing, + ); + self.direction_row( + ui, + action, + AxisDirection::Axis2D(Axis2DSide::Up), + &axis.y.positive, + capturing, + ); + self.direction_row( + ui, + action, + AxisDirection::Axis2D(Axis2DSide::Down), + &axis.y.negative, + capturing, + ); + } + + fn direction_row( + &mut self, + ui: &mut egui::Ui, + action: &str, + dir: AxisDirection, + bindings: &[Binding], + capturing: bool, + ) { + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.weak(dir.label()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("+").on_hover_text("Add binding").clicked() && !capturing { + self.capture = Some(make_axis_capture(action, dir, BindingSlot::Append)); + } + for (i, binding) in bindings.iter().enumerate().rev() { + if ui.small_button("🗑").on_hover_text("Remove").clicked() { + self.pending.push(PendingAction::RemoveAxisBinding { + action: action.to_string(), + dir, + index: i, + }); + } + let target = make_axis_capture(action, dir, BindingSlot::Replace(i)); + let label = self.slot_label(&target, Some(*binding)); + if ui.button(label).clicked() && !capturing { + self.capture = Some(target); + } + } + }); + }); + } + + /// The label shown on a binding button. Becomes "Press a key…" when the + /// slot is the active capture target; otherwise the binding's own + /// description (or "(empty)" for an Append slot with no value yet). + fn slot_label(&self, target: &CaptureTarget, binding: Option) -> String { + if Some(target) == self.capture.as_ref() { + return "Press a key…".to_string(); + } + match binding { + Some(b) => describe_binding(&b), + None => "(empty)".to_string(), + } + } + + fn about_window(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let mut open = self.show_about; + egui::Window::new("About Oxide") + .open(&mut open) + .resizable(false) + .show(&ctx, |ui| { + ui.heading("Oxide Engine"); + ui.label("In-engine editor — Stage 6 shell."); + ui.label(format!("Engine v{}", env!("CARGO_PKG_VERSION"))); + }); + self.show_about = open; + } + + /// Project-wide layer-name editor. Lists the 32 layer slots; each one has + /// an editable name + a Clear button. Index 0 is `"Default"` by default + /// and can be renamed but the layer itself can't be removed. + fn layer_editor_window(&mut self, ui: &mut egui::Ui) { + use oxide_engine::layer::MAX_LAYERS; + let ctx = ui.ctx().clone(); + let mut open = self.show_layer_editor; + // Pull all current names into a parallel String buffer for editing, + // then write back any that changed when the user edits a row. + let mut edits: Vec<(u32, Option)> = Vec::new(); + egui::Window::new("Layer Names") + .open(&mut open) + .default_size([340.0, 560.0]) + .resizable(true) + .show(&ctx, |ui| { + ui.label( + "Project-wide layer names. Cameras, raycasts, and physics filters use \ + these to refer to layers by name instead of bare bit indices.", + ); + ui.label( + egui::RichText::new("Edit a name and press Enter or click away to apply.") + .weak(), + ); + ui.separator(); + // Fill the window width (don't shrink to content) so the name + // fields can stretch the full row. + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + for i in 0..MAX_LAYERS { + let current = self + .state + .layer_registry + .name(i) + .map(String::from) + .unwrap_or_default(); + let mut buf = current.clone(); + ui.horizontal(|ui| { + ui.monospace(format!("{i:>2}")); + // Reserve room for the trailing clear button so + // the field fills the rest of the row and grows + // when the window is widened. + let field_w = (ui.available_width() - 30.0).max(80.0); + let edited = ui.add( + egui::TextEdit::singleline(&mut buf) + .desired_width(field_w) + .hint_text("(unnamed)"), + ); + // Commit on focus loss (Enter or click-away). + if edited.lost_focus() && buf != current { + edits.push(( + i, + if buf.trim().is_empty() { + None + } else { + Some(buf.trim().to_string()) + }, + )); + } + if !current.is_empty() + && ui + .small_button("🗑") + .on_hover_text("Clear this layer's name") + .clicked() + { + edits.push((i, None)); + } + }); + } + }); + }); + for (i, new_name) in edits { + match new_name { + Some(name) => self.state.layer_registry.set(i, name), + None => self.state.layer_registry.clear(i), + } + } + self.show_layer_editor = open; + } + + /// Project-wide gameplay-group editor. Lists the defined groups (each with a + /// delete button) and an "add group" row. Unlike layers (a fixed 32-slot + /// bitset) groups are an open-ended named set, so this grows/shrinks freely. + /// Deleting a group only removes it from the project's vocabulary — entities + /// already tagged with it keep the tag (shown as an "ungrouped tag" in the + /// inspector) until cleared there. + fn group_editor_window(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let mut open = self.show_group_editor; + let mut to_remove: Option = None; + let mut to_add: Option = None; + egui::Window::new("Groups") + .open(&mut open) + .default_size([320.0, 420.0]) + .resizable(true) + .show(&ctx, |ui| { + ui.label( + "Project-wide gameplay groups. Tag nodes with any of these in the \ + inspector; game code and scripts query membership by name.", + ); + ui.separator(); + egui::ScrollArea::vertical() + .max_height(320.0) + .show(ui, |ui| { + let defined: Vec = + self.state.group_registry.iter().map(String::from).collect(); + if defined.is_empty() { + ui.weak("No groups defined yet. Add one below."); + } + for name in defined { + ui.horizontal(|ui| { + if ui + .small_button("🗑") + .on_hover_text("Delete this group") + .clicked() + { + to_remove = Some(name.clone()); + } + ui.label(&name); + }); + } + }); + ui.separator(); + ui.horizontal(|ui| { + let resp = ui.text_edit_singleline(&mut self.new_group_name); + let entered = + resp.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)); + let add_clicked = ui.button("Add").clicked(); + if (add_clicked || entered) && !self.new_group_name.trim().is_empty() { + to_add = Some(self.new_group_name.trim().to_string()); + } + }); + }); + if let Some(name) = to_add { + self.state.group_registry.define(name); + self.new_group_name.clear(); + } + if let Some(name) = to_remove { + self.state.group_registry.undefine(&name); + } + self.show_group_editor = open; + } + + /// In-app New Project dialog: a path field, a name field, and Create / Cancel. + /// A native OS file dialog (`rfd` or similar) is a piece-6 polish item — for + /// now the path is typed, which is enough to exercise the flow end-to-end and + /// keeps the editor dependency-light. + fn new_project_window(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let mut open = self.show_new_project; + let mut create_now = false; + let mut cancel_now = false; + egui::Window::new("New Project") + .open(&mut open) + .default_size([520.0, 160.0]) + .resizable(true) + .show(&ctx, |ui| { + ui.label("Project folder"); + ui.text_edit_singleline(&mut self.new_project_path); + ui.label("Display name"); + ui.text_edit_singleline(&mut self.new_project_name); + ui.add_space(6.0); + ui.horizontal(|ui| { + let valid = !self.new_project_path.trim().is_empty() + && !self.new_project_name.trim().is_empty(); + if ui.add_enabled(valid, egui::Button::new("Create")).clicked() { + create_now = true; + } + if ui.button("Cancel").clicked() { + cancel_now = true; + } + }); + ui.weak( + "Creates the folder layout (assets/, scenes/, scripts/) and writes \ + project.oxide.", + ); + }); + if create_now { + let path = PathBuf::from(self.new_project_path.trim()); + let name = self.new_project_name.trim().to_owned(); + match self.create_project(path, name) { + Ok(()) => { + self.show_new_project = false; + } + Err(err) => { + self.status.say(format!("Create failed: {err}")); + // Leave the dialog open so the user can fix the path. + } + } + } else if cancel_now { + self.show_new_project = false; + } else { + self.show_new_project = open; + } + } + + /// In-app Open Project dialog: one path field, Open / Cancel. + fn open_project_window(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let mut open = self.show_open_project; + let mut open_now = false; + let mut cancel_now = false; + egui::Window::new("Open Project") + .open(&mut open) + .default_size([520.0, 140.0]) + .resizable(true) + .show(&ctx, |ui| { + ui.label("Project folder (or path to project.oxide)"); + ui.text_edit_singleline(&mut self.open_project_path); + ui.add_space(6.0); + ui.horizontal(|ui| { + let valid = !self.open_project_path.trim().is_empty(); + if ui.add_enabled(valid, egui::Button::new("Open")).clicked() { + open_now = true; + } + if ui.button("Cancel").clicked() { + cancel_now = true; + } + }); + }); + if open_now { + let path = PathBuf::from(self.open_project_path.trim()); + match self.open_project(path) { + Ok(()) => { + self.show_open_project = false; + } + Err(err) => { + self.status.say(format!("Open failed: {err}")); + } + } + } else if cancel_now { + self.show_open_project = false; + } else { + self.show_open_project = open; + } + } + + fn dock_contains(&self, kind: &PanelKind) -> bool { + self.dock.iter_all_tabs().any(|(_, tab)| tab == kind) + } + + fn toggle_panel(&mut self, kind: PanelKind) { + // Existing tab: remove it. Missing tab: drop it into the currently- + // focused dock leaf so the user gets it back somewhere visible. + let path = self + .dock + .iter_all_tabs() + .find_map(|(path, tab)| if *tab == kind { Some(path) } else { None }); + match path { + Some(path) => { + self.dock.remove_tab(path); + } + None => { + self.dock.push_to_focused_leaf(kind); + } + } + } + + fn invoke_menu_item(&mut self, path: &str) { + let mut fired = false; + for item in self.extensions.iter_menu_items_mut() { + if item.path == path { + (item.action)(); + fired = true; + break; + } + } + if fired { + self.status.say(format!("Menu: {path}")); + } + } + + /// Selects a freshly spawned entity and syncs the inspector scratch buffers + /// (rename field + euler cache) to it. + fn select_spawned(&mut self, entity: Entity) { + self.rename_buf = self.state.scene.name(entity).unwrap_or_default(); + self.state.selected = Some(entity); + self.euler_for = None; + } + + fn apply(&mut self, action: PendingAction) { + match action { + PendingAction::AddRootPrefab(name) => { + // Disjoint field borrows: prefab_registry (read) + scene (write) + // + registry (read) are separate fields of `state`. + if let Some(e) = self.state.prefab_registry.spawn( + &name, + &mut self.state.scene, + &self.state.registry, + ) { + self.select_spawned(e); + } + } + PendingAction::AddChildPrefab(parent, name) => { + if self.state.scene.contains(parent) { + if let Some(e) = self.state.prefab_registry.spawn_child( + &name, + parent, + &mut self.state.scene, + &self.state.registry, + ) { + self.select_spawned(e); + } + } + } + PendingAction::Delete(entity) => { + self.state.scene.despawn(entity, DespawnPolicy::Recursive); + self.state.component_order.remove(&entity); + if self.state.selected == Some(entity) { + self.state.selected = None; + } + } + PendingAction::Duplicate(entity) => { + if self.state.scene.contains(entity) { + let name = self.state.scene.name(entity).unwrap_or_default(); + let transform = self.state.scene.local_transform(entity).unwrap_or_default(); + let parent = self.state.scene.parent(entity); + let copy_name = format!("{name} copy"); + let new = match parent { + Some(p) => self + .state + .scene + .spawn_child(p, copy_name.clone(), transform), + None => self.state.scene.spawn(copy_name.clone(), transform), + }; + // Copy every registered modular component via whole-value + // RON round-trip. Essential (node-baked) components are + // already set by spawn — but Layer' mask isn't, so copy + // that too separately. + let comps: Vec<&'static str> = self + .state + .registry + .components_on(self.state.scene.world(), entity) + .into_iter() + .filter(|n| !is_essential_component(n) || *n == "Layer") + .collect(); + for comp in comps { + if let Ok(ron) = + self.state + .registry + .get_ron(self.state.scene.world(), entity, comp) + { + let _ = self.state.registry.set_ron( + self.state.scene.world_mut(), + new, + comp, + &ron, + ); + } + } + // Preserve the inspector order from the source so the copy + // shows its components in the same arrangement. + if let Some(order) = self.state.component_order.get(&entity).cloned() { + self.state.component_order.insert(new, order); + } + // Carry the per-component disable set along too — + // DisabledComponents isn't in the reflection registry, so + // the registry-driven copy above doesn't see it. Scope the + // immutable borrow so the world is freed before insert_one. + let dc: Option = { + let world = self.state.scene.world(); + world + .get::<&DisabledComponents>(entity) + .ok() + .map(|d| (*d).clone()) + }; + if let Some(dc) = dc { + let _ = self.state.scene.world_mut().insert_one(new, dc); + } + self.state.selected = Some(new); + self.rename_buf = copy_name; + self.euler_for = None; + } + } + PendingAction::SetEnabled(entity, enabled) => { + self.state.scene.set_enabled(entity, enabled); + } + PendingAction::RestoreAllBindings => { + self.restore_default_bindings(); + } + PendingAction::RestoreActionDefaults(action) => { + self.state.actions.restore_defaults(&action); + self.state.actions.restore_axis_defaults(&action); + self.state.actions.restore_axis_2d_defaults(&action); + self.state.sync_action_overrides_to_settings(); + self.bindings_dirty = true; + self.status.say(format!("Restored '{action}' defaults")); + } + PendingAction::RemoveButtonBinding { action, index } => { + let mut bindings = self.state.actions.bindings(&action).to_vec(); + if index < bindings.len() { + bindings.remove(index); + self.state.actions.set_bindings(&action, bindings); + self.state.sync_action_overrides_to_settings(); + self.bindings_dirty = true; + } + } + PendingAction::RemoveAxisBinding { action, dir, index } => match dir { + AxisDirection::Axis(side) => { + if let Some(current) = self.state.actions.axis_bindings(&action).cloned() { + let mut next = current; + let list = side_list_mut(&mut next, side); + if index < list.len() { + list.remove(index); + self.state.actions.set_axis_bindings(&action, next); + self.state.sync_action_overrides_to_settings(); + self.bindings_dirty = true; + } + } + } + AxisDirection::Axis2D(side) => { + if let Some(current) = self.state.actions.axis_2d_bindings(&action).cloned() { + let mut next = current; + let list = side_2d_list_mut(&mut next, side); + if index < list.len() { + list.remove(index); + self.state.actions.set_axis_2d_bindings(&action, next); + self.state.sync_action_overrides_to_settings(); + self.bindings_dirty = true; + } + } + } + }, + } + } +} + +impl Default for Shell { + fn default() -> Self { + Self::new() + } +} + +// ---- tab viewer (renders each panel's content) ------------------------- + +/// Holds the mutable references each panel needs to render. Constructed +/// per-frame inside [`Shell::build`] so the borrow lives only for one +/// `DockArea::show` call. +struct ShellTabViewer<'a> { + state: &'a mut EditorState, + commands: &'a mut CommandStack, + extensions: &'a mut EditorExtensions, + pending: &'a mut Vec, + rename_buf: &'a mut String, + /// The Console command-input buffer (the terminal prompt). + terminal_input: &'a mut String, + rot_euler: &'a mut Vec3, + euler_for: &'a mut Option<(Entity, &'static str, &'static str)>, + /// Set to `true` when the Layer dropdown's "Edit names…" entry is clicked. + show_layer_editor: &'a mut bool, + /// Set to `true` when the Groups dropdown's "Edit groups…" entry is clicked. + show_group_editor: &'a mut bool, + /// Filled when the Viewport tab renders, so the shell can answer + /// `cursor_over_viewport` next frame. + viewport_rect_px: &'a mut Option<(f32, f32, f32, f32)>, + /// Per-frame projection data the Viewport tab's gizmo overlay paints + /// with (set by the host before `Shell::build`). `None` skips the + /// overlay this frame. + viewport_overlay: Option, + /// Physical-pixel scale for converting egui logical coords (points) to + /// the pixel-space cursor coordinates the host uses. + pixels_per_point: f32, + /// When set, the Viewport tab paints collider wireframe gizmos (View ▸ + /// Show Colliders). Copied in from [`Shell::show_colliders`]. + show_colliders: bool, + /// The raycast-probe result to paint this frame, if any (View ▸ Raycast + /// Probe). Copied in from [`Shell::raycast_probe_viz`]. + raycast_probe_viz: Option, + /// The interactive PTY terminal sessions shown as tabs (the Terminal panel + /// renders/drives them). Borrowed from [`Shell::terminals`]. + terminals: &'a mut Vec, + /// The active terminal tab index. Borrowed from [`Shell::active_terminal`]. + active_terminal: &'a mut usize, +} + +impl<'a> egui_dock::TabViewer for ShellTabViewer<'a> { + type Tab = PanelKind; + + fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText { + tab.title().into() + } + + fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Self::Tab) { + match tab { + PanelKind::Hierarchy => self.hierarchy(ui), + PanelKind::Inspector => self.inspector(ui), + // The 3D viewport is rendered to the full surface *before* egui + // paints, so the Viewport tab is left empty and its background is + // suppressed (see `clear_background` below) — the 3D content + // shows through where the Viewport tab is, and the opaque + // backgrounds of every other panel occlude the rest. The rect + // is captured so the host knows where the cursor is interacting + // with the 3D scene rather than an opaque panel. + PanelKind::Viewport => { + let r = ui.max_rect(); + let s = self.pixels_per_point; + *self.viewport_rect_px = Some((r.min.x * s, r.min.y * s, r.max.x * s, r.max.y * s)); + if self.show_colliders { + self.paint_collider_gizmos(ui, r); + } + self.paint_raycast_probe(ui, r); + self.paint_gizmo_overlay(ui, r); + self.paint_play_indicator(ui, r); + } + PanelKind::Project => self.project_panel(ui), + PanelKind::UiCanvas => self.ui_canvas_panel(ui), + PanelKind::Console => self.console(ui), + PanelKind::Terminal => self.terminal_panel(ui), + PanelKind::Custom(name) => self.custom_panel(ui, name), + } + } + + fn clear_background(&self, tab: &Self::Tab) -> bool { + // Transparent only for the 3D viewport tab; every other tab keeps its + // background or it'd bleed onto the 3D scene behind. + !matches!(tab, PanelKind::Viewport) + } +} + +impl<'a> ShellTabViewer<'a> { + // --- Gizmo overlay rendering (Stage 7 piece 6c) ----------------------- + + /// Paints the active gizmo's handles over the Viewport tab using egui's + /// 2D painter — same convention every DCC tool uses for transform + /// gizmos. Hit-testing is in 3D world space (see `crate::gizmo`); this + /// is purely the visualization. + /// Paints a play-state border + corner badge over the viewport so it is + /// unmistakable when the scene is running vs. being authored — the editor's + /// analogue of Unity's play-mode tint. No-op while editing. + fn paint_play_indicator(&self, ui: &egui::Ui, tab_rect: egui::Rect) { + let (color, label) = match self.state.play { + PlayState::Playing => (egui::Color32::from_rgb(120, 220, 120), "PLAYING"), + PlayState::Paused => (egui::Color32::from_rgb(235, 200, 90), "PAUSED"), + PlayState::Editing => return, + }; + let painter = ui.painter_at(tab_rect); + // Inset by half the stroke width so the full border stays inside the + // viewport rect rather than being clipped at the edges. + stroke_rect( + &painter, + tab_rect.shrink(1.5), + egui::Stroke::new(3.0, color), + ); + painter.text( + tab_rect.left_top() + egui::vec2(8.0, 6.0), + egui::Align2::LEFT_TOP, + label, + egui::FontId::proportional(14.0), + color, + ); + } + + /// Paints a wireframe outline of every entity's physics + /// [`Collider`](oxide_physics::Collider) over the Viewport (Stage 9 piece + /// 8b), projected with the same view-projection the scene was drawn with. + /// Solid colliders are green, sensor (trigger) colliders amber — the usual + /// DCC convention; the selected entity's outline is drawn thicker. Mirrors + /// the simulation, which **ignores `Transform::scale`**, so the outline + /// shows the actual shape rapier builds (translation + rotation only). + /// Toggled from View ▸ Show Colliders. + fn paint_collider_gizmos(&self, ui: &egui::Ui, tab_rect: egui::Rect) { + use oxide_engine::math::Vec3; + let Some(overlay) = self.viewport_overlay else { + return; + }; + let scene = &self.state.scene; + // Snapshot colliders so the query borrow is released before we resolve + // world transforms (which re-borrow the scene). + let colliders: Vec<(Entity, oxide_physics::Collider)> = scene + .world() + .query::<&oxide_physics::Collider>() + .iter() + .map(|(e, c)| (e, *c)) + .collect(); + if colliders.is_empty() { + return; + } + let painter = ui.painter_at(tab_rect); + for (entity, col) in colliders { + // Honor the same visibility rules the renderer/picking use. + if scene.is_component_disabled(entity, "Collider") { + continue; + } + if !scene.is_effectively_enabled(entity).unwrap_or(true) { + continue; + } + let Some(world) = scene.world_transform(entity) else { + continue; + }; + // Physics ignores scale; build the pose from translation + rotation + // only so the wireframe matches the simulated shape exactly. + let pose = oxide_engine::math::Transform { + translation: world.translation, + rotation: world.rotation, + scale: Vec3::ONE, + }; + let color = if col.sensor { + egui::Color32::from_rgb(235, 200, 90) // amber: trigger + } else { + egui::Color32::from_rgb(110, 220, 120) // green: solid + }; + let selected = self.state.selected == Some(entity); + let stroke = egui::Stroke::new(if selected { 2.0 } else { 1.2 }, color); + for (a, b) in collider_wire_segments(&col) { + let (Some(pa), Some(pb)) = ( + project(pose.transform_point(a), &overlay.view_proj, tab_rect), + project(pose.transform_point(b), &overlay.view_proj, tab_rect), + ) else { + continue; + }; + painter.line_segment([pa, pb], stroke); + } + } + } + + /// Paints the **View ▸ Raycast Probe** visualization (Stage 9 piece 8c): the + /// frozen probe ray in cyan, and on a hit a small magenta marker at the + /// surface point with a short segment along the surface normal. Misses draw + /// the full ray to its probe distance. The ray lives in world space (the + /// host froze it on a click), so this projects it with the current camera — + /// orbit and the ray reads as a 3D line. Mirrors `paint_collider_gizmos`. + fn paint_raycast_probe(&self, ui: &egui::Ui, tab_rect: egui::Rect) { + let (Some(overlay), Some(viz)) = (self.viewport_overlay, self.raycast_probe_viz) else { + return; + }; + let painter = ui.painter_at(tab_rect); + let cyan = egui::Color32::from_rgb(80, 200, 220); + let magenta = egui::Color32::from_rgb(230, 90, 210); + + // The ray itself. + if let (Some(a), Some(b)) = ( + project(viz.origin, &overlay.view_proj, tab_rect), + project(viz.end, &overlay.view_proj, tab_rect), + ) { + painter.line_segment([a, b], egui::Stroke::new(1.5, cyan)); + } + + // The hit surface: a dot at the contact point + a normal whisker. + if let Some(hit) = viz.hit { + if let Some(p) = project(hit.point, &overlay.view_proj, tab_rect) { + painter.circle_filled(p, 3.5, magenta); + } + // Scale the normal whisker with the gizmo size so it reads at any + // zoom (same trick the gizmo arrows use). + let tip = probe_normal_tip(hit.point, hit.normal, overlay.gizmo_size); + if let (Some(a), Some(b)) = ( + project(hit.point, &overlay.view_proj, tab_rect), + project(tip, &overlay.view_proj, tab_rect), + ) { + painter.line_segment([a, b], egui::Stroke::new(2.0, magenta)); + } + } + } + + fn paint_gizmo_overlay(&self, ui: &egui::Ui, tab_rect: egui::Rect) { + let Some(overlay) = self.viewport_overlay else { + return; + }; + let Some(selected) = self.state.selected else { + return; + }; + let Some(transform) = self.state.scene.world_transform(selected) else { + return; + }; + let origin = transform.translation; + // The engaged handle, if a drag is in flight — highlighted so the + // user sees what they're dragging. + let active = self.state.gizmo.drag.as_ref().map(|d| d.handle); + let painter = ui.painter_at(tab_rect); + + match self.state.gizmo.mode { + GizmoMode::Translate => { + for axis in Axis3::ALL { + let tip = origin + axis.unit() * overlay.gizmo_size; + self.draw_axis_line( + &painter, + tab_rect, + &overlay, + origin, + tip, + axis_color(axis, active == Some(GizmoHandle::TranslateAxis(axis))), + ); + } + for plane in PlaneAxis::ALL { + self.draw_plane_quad( + &painter, + tab_rect, + &overlay, + origin, + plane, + active == Some(GizmoHandle::TranslatePlane(plane)), + ); + } + } + GizmoMode::Rotate => { + for axis in Axis3::ALL { + self.draw_axis_circle( + &painter, + tab_rect, + &overlay, + origin, + axis, + active == Some(GizmoHandle::RotateAxis(axis)), + ); + } + } + GizmoMode::Scale => { + for axis in Axis3::ALL { + let tip = origin + axis.unit() * overlay.gizmo_size; + self.draw_axis_line( + &painter, + tab_rect, + &overlay, + origin, + tip, + axis_color(axis, active == Some(GizmoHandle::ScaleAxis(axis))), + ); + // Solid cube at the tip distinguishes scale from translate + // visually. + if let Some(p) = project(tip, &overlay.view_proj, tab_rect) { + let r = 6.0; + painter.rect_filled( + egui::Rect::from_center_size(p, egui::vec2(r * 2.0, r * 2.0)), + 0.0, + axis_color(axis, active == Some(GizmoHandle::ScaleAxis(axis))), + ); + } + } + // Center uniform handle. + if let Some(p) = project(origin, &overlay.view_proj, tab_rect) { + let r = 6.0; + painter.rect_filled( + egui::Rect::from_center_size(p, egui::vec2(r * 2.0, r * 2.0)), + 2.0, + if active == Some(GizmoHandle::ScaleUniform) { + egui::Color32::WHITE + } else { + egui::Color32::LIGHT_GRAY + }, + ); + } + } + } + } + + fn draw_axis_line( + &self, + painter: &egui::Painter, + tab_rect: egui::Rect, + overlay: &ViewportOverlay, + a: oxide_engine::math::Vec3, + b: oxide_engine::math::Vec3, + color: egui::Color32, + ) { + let (Some(pa), Some(pb)) = ( + project(a, &overlay.view_proj, tab_rect), + project(b, &overlay.view_proj, tab_rect), + ) else { + return; + }; + painter.line_segment([pa, pb], egui::Stroke::new(2.5, color)); + // Arrowhead at the tip — a small filled triangle perpendicular to + // the line direction in screen space. + let dir = (pb - pa).normalized(); + if dir.length_sq() > 0.0 { + let perp = egui::vec2(-dir.y, dir.x); + let base = pb - dir * 8.0; + let p1 = base + perp * 4.0; + let p2 = base - perp * 4.0; + painter.add(egui::Shape::convex_polygon( + vec![pb, p1, p2], + color, + egui::Stroke::NONE, + )); + } + } + + fn draw_plane_quad( + &self, + painter: &egui::Painter, + tab_rect: egui::Rect, + overlay: &ViewportOverlay, + origin: oxide_engine::math::Vec3, + plane: PlaneAxis, + engaged: bool, + ) { + let (a, b) = plane.axes(); + let s = overlay.gizmo_size; + // The quad lives at 0.3..0.7 of the gizmo size along each in-plane + // axis, matching what `gizmo::hit_test` claims for the same handle. + let corners = [ + origin + a * (s * 0.3) + b * (s * 0.3), + origin + a * (s * 0.7) + b * (s * 0.3), + origin + a * (s * 0.7) + b * (s * 0.7), + origin + a * (s * 0.3) + b * (s * 0.7), + ]; + let projected: Vec = corners + .iter() + .filter_map(|p| project(*p, &overlay.view_proj, tab_rect)) + .collect(); + if projected.len() != 4 { + return; + } + let normal_axis = plane.normal(); + let color = if normal_axis == oxide_engine::math::Vec3::Z { + egui::Color32::from_rgba_unmultiplied(80, 120, 255, if engaged { 220 } else { 130 }) + } else if normal_axis == oxide_engine::math::Vec3::Y { + egui::Color32::from_rgba_unmultiplied(120, 255, 120, if engaged { 220 } else { 130 }) + } else { + egui::Color32::from_rgba_unmultiplied(255, 120, 120, if engaged { 220 } else { 130 }) + }; + let stroke = egui::Stroke::new(1.5, color); + painter.add(egui::Shape::convex_polygon(projected, color, stroke)); + } + + fn draw_axis_circle( + &self, + painter: &egui::Painter, + tab_rect: egui::Rect, + overlay: &ViewportOverlay, + origin: oxide_engine::math::Vec3, + axis: Axis3, + engaged: bool, + ) { + // Pick two in-plane unit vectors orthogonal to the axis. + let n = axis.unit(); + let (u, v) = orthonormal_basis(n); + let r = overlay.gizmo_size; + let segs = 48; + let mut pts: Vec = Vec::with_capacity(segs + 1); + for i in 0..=segs { + let t = (i as f32) * std::f32::consts::TAU / (segs as f32); + let world = origin + u * (r * t.cos()) + v * (r * t.sin()); + if let Some(p) = project(world, &overlay.view_proj, tab_rect) { + pts.push(p); + } + } + if pts.len() < 2 { + return; + } + let color = axis_color(axis, engaged); + painter.add(egui::Shape::line(pts, egui::Stroke::new(2.0, color))); + } + + fn hierarchy(&mut self, ui: &mut egui::Ui) { + // Snapshot the prefab names once so the add-menus can list them without + // borrowing the registry inside the UI closures. + let prefab_names: Vec = self + .state + .prefab_registry + .names() + .map(String::from) + .collect(); + ui.horizontal(|ui| { + ui.menu_button("➕ Root", |ui| { + for name in &prefab_names { + if ui.button(name).clicked() { + self.pending + .push(PendingAction::AddRootPrefab(name.clone())); + ui.close(); + } + } + }); + let has_sel = self.state.selected.is_some(); + ui.add_enabled_ui(has_sel, |ui| { + ui.menu_button("➕ Child", |ui| { + if let Some(sel) = self.state.selected { + for name in &prefab_names { + if ui.button(name).clicked() { + self.pending + .push(PendingAction::AddChildPrefab(sel, name.clone())); + ui.close(); + } + } + } + }); + }); + if ui + .add_enabled(has_sel, egui::Button::new("🗑 Delete")) + .clicked() + { + if let Some(sel) = self.state.selected { + self.pending.push(PendingAction::Delete(sel)); + } + } + }); + ui.separator(); + + ui.weak("Drag a node to move it — nest by dropping on a node, reorder by dropping between nodes. Right-click for actions."); + + let rows = snapshot(&self.state.scene); + let selected_entity = self.state.selected; + + // Drag state for the insertion indicator: which entity is being + // dragged, where the pointer is, and whether it was released this frame. + let dragged = egui::DragAndDrop::payload::(ui.ctx()).map(|a| *a); + let pointer = ui.ctx().pointer_interact_pos(); + let released = ui.input(|i| i.pointer.any_released()); + + // Collect interactions into locals so the deeply-nested drag/menu + // closures never borrow `self`; apply them after the ScrollArea. + let mut queued: Vec = Vec::new(); + let mut new_selection: Option<(Entity, String)> = None; + // The drop chosen on release: (row index, zone) — `usize::MAX` row with + // `RootEnd` means "append to the root level". + let mut drop_action: Option<(usize, DropZone)> = None; + + egui::ScrollArea::vertical().show(ui, |ui| { + if rows.is_empty() { + ui.weak("(empty scene — right-click to add a root)"); + } + + for (i, row) in rows.iter().enumerate() { + let row_resp = ui + .horizontal(|ui| { + ui.add_space(row.depth as f32 * 16.0); + let mut enabled = row.enabled; + if ui.checkbox(&mut enabled, "").changed() { + queued.push(PendingAction::SetEnabled(row.entity, enabled)); + } + let label = if row.name.is_empty() { + "(unnamed)".to_owned() + } else { + row.name.clone() + }; + // Grey the whole subtree when an ancestor is disabled, + // not just the node whose own flag is off. + let text = if row.effective_enabled { + egui::RichText::new(label) + } else { + egui::RichText::new(label).weak().italics() + }; + let selected = selected_entity == Some(row.entity); + // One widget senses everything: click (select), + // secondary-click (context menu), AND drag (move). The + // earlier `dnd_drag_source` wrapper claimed the press + // for itself and the inner label never saw clicks, so + // selection and the right-click menu silently broke. + // Doing it as one click_and_drag widget keeps the + // press attributed to *this* response: a still + // press+release is a click, a press+move is a drag, + // and a secondary-click is the context menu — no + // ambiguity between layered widgets. + let label_resp = ui + .selectable_label(selected, text) + .interact(egui::Sense::click_and_drag()); + if label_resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), row.entity); + } + if label_resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + if label_resp.clicked() { + new_selection = Some((row.entity, row.name.clone())); + } + label_resp.context_menu(|ui| { + ui.menu_button("➕ Add Child", |ui| { + for name in &prefab_names { + if ui.button(name).clicked() { + queued.push(PendingAction::AddChildPrefab( + row.entity, + name.clone(), + )); + ui.close(); + } + } + }); + if ui.button("Duplicate").clicked() { + queued.push(PendingAction::Duplicate(row.entity)); + ui.close(); + } + ui.separator(); + if ui.button("🗑 Delete").clicked() { + queued.push(PendingAction::Delete(row.entity)); + ui.close(); + } + }); + }) + .response; + + // Insertion indicator: while dragging some *other* node over + // this row, split it into before / nest / after zones, draw the + // hint, and capture the drop on release. + if let (Some(drag_e), Some(pos)) = (dragged, pointer) { + let r = row_resp.rect; + if drag_e != row.entity && r.y_range().contains(pos.y) && pos.x >= r.left() { + let t = (pos.y - r.top()) / r.height().max(1.0); + let zone = if t < 0.25 { + DropZone::Before + } else if t > 0.75 { + DropZone::After + } else { + DropZone::Child + }; + let accent = ui.visuals().selection.bg_fill; + let line = egui::Stroke::new(2.0, accent); + match zone { + DropZone::Before => { + ui.painter().line_segment( + [ + egui::pos2(r.left(), r.top()), + egui::pos2(r.right(), r.top()), + ], + line, + ); + } + DropZone::After => { + ui.painter().line_segment( + [ + egui::pos2(r.left(), r.bottom()), + egui::pos2(r.right(), r.bottom()), + ], + line, + ); + } + DropZone::Child => { + let fill = egui::Color32::from_rgba_unmultiplied( + accent.r(), + accent.g(), + accent.b(), + 60, + ); + ui.painter().rect_filled(r, 2.0, fill); + } + DropZone::RootEnd => {} + } + if released { + drop_action = Some((i, zone)); + } + } + } + } + + // Empty area below the rows: right-click to add a root, or drop a + // dragged node here to move it to the end of the root level. + let empty = ui.allocate_response(ui.available_size(), egui::Sense::click()); + empty.context_menu(|ui| { + ui.menu_button("➕ Add Root", |ui| { + for name in &prefab_names { + if ui.button(name).clicked() { + queued.push(PendingAction::AddRootPrefab(name.clone())); + ui.close(); + } + } + }); + }); + if let (Some(_), Some(pos)) = (dragged, pointer) { + if released && empty.rect.contains(pos) { + drop_action = Some((usize::MAX, DropZone::RootEnd)); + } + } + }); + + // Resolve the chosen drop into a single reorder (parent + before). + if let (Some(drag_e), Some((i, zone))) = (dragged, drop_action) { + let (new_parent, before) = match zone { + DropZone::RootEnd => (None, None), + DropZone::Child => (Some(rows[i].entity), None), + DropZone::Before => ( + self.state.scene.parent(rows[i].entity), + Some(rows[i].entity), + ), + DropZone::After => { + let target = rows[i].entity; + let parent = self.state.scene.parent(target); + let siblings: Vec = match parent { + Some(p) => self.state.scene.children(p).to_vec(), + None => self.state.scene.roots().to_vec(), + }; + let before = siblings + .iter() + .position(|&e| e == target) + .and_then(|idx| siblings.get(idx + 1).copied()); + (parent, before) + } + }; + if let Err(err) = self.state.scene.reorder(drag_e, new_parent, before) { + log::debug!("reorder rejected: {err}"); + } + } + + if let Some((entity, name)) = new_selection { + self.state.selected = Some(entity); + *self.rename_buf = name; + *self.euler_for = None; + } + self.pending.extend(queued); + } + + fn inspector(&mut self, ui: &mut egui::Ui) { + let Some(selected) = self.state.selected else { + ui.weak("Select a node to inspect it."); + return; + }; + if !self.state.scene.contains(selected) { + self.state.selected = None; + return; + } + + ui.horizontal(|ui| { + ui.label("Name"); + let edited = ui.text_edit_singleline(self.rename_buf); + if edited.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + let cmd = RenameCmd::new(self.state, selected, self.rename_buf.clone()); + self.commands.push(cmd, self.state); + } + }); + + let mut enabled = self.state.scene.is_enabled(selected).unwrap_or(true); + if ui.checkbox(&mut enabled, "Enabled").changed() { + self.pending + .push(PendingAction::SetEnabled(selected, enabled)); + } + + // Parent is shown read-only here — reparenting is done by drag-and-drop + // in the Hierarchy panel (a dropdown listing every entity doesn't scale + // to large scenes). + let parent_label = match self.state.scene.parent(selected) { + None => "(root)".to_owned(), + Some(p) => self + .state + .scene + .name(p) + .unwrap_or_else(|| "(unnamed)".into()), + }; + ui.horizontal(|ui| { + ui.label("Parent"); + ui.weak(parent_label); + }); + + // Two sections: **node-baked** components (Layer + Transform — always + // present on every entity, single-instance, no controls), then + // **modular** components (the user's choice: drag-reorder, disable, + // remove, add). + self.essential_components(ui, selected); + self.reflected_components(ui, selected); + + ui.separator(); + self.add_component_menu(ui, selected); + } + + /// Renders the node-baked components in their fixed canonical order. These + /// are inherent to every entity — auto-attached on `Scene::spawn` — so + /// they don't carry the drag handle / enable checkbox / remove button that + /// modular components do. + /// + /// Layer is shown above Transform per the maintainer's preference: "which + /// world is this entity in?" precedes "where in that world is it?". Node's + /// own fields are already shown by the Name / Enabled header above, so it + /// isn't repeated here. + fn essential_components(&mut self, ui: &mut egui::Ui, selected: Entity) { + ui.separator(); + ui.heading("Layer"); + self.layers_widget(ui, selected); + + ui.separator(); + ui.heading("Groups"); + self.groups_widget(ui, selected); + + ui.separator(); + ui.heading("Transform"); + self.essential_fields(ui, selected, "Transform"); + } + + /// Renders one essential component's reflected fields with no extras — + /// no checkbox, no remove, no drag. Edits route through `SetFieldCmd` + /// like modular fields do. + fn essential_fields(&mut self, ui: &mut egui::Ui, selected: Entity, type_name: &'static str) { + // Snapshot the field rows up front so we can drop the registry/world + // borrow before calling field_widget (which borrows &mut self for the + // euler buffer). + struct Row { + info: FieldInfo, + variants: Option<&'static [&'static str]>, + value: String, + } + let rows: Vec = { + let world = self.state.scene.world(); + let Ok(infos) = self.state.registry.field_infos(world, selected, type_name) else { + return; + }; + infos + .iter() + .filter_map(|info| { + self.state + .registry + .get_field(world, selected, type_name, info.name) + .ok() + .map(|value| Row { + info: *info, + variants: self.state.registry.enum_variants(info.type_name), + value, + }) + }) + .collect() + }; + + let mut edits: Vec<(&'static str, String)> = Vec::new(); + // Namespace by component so a field name shared with another component + // can't produce a duplicate egui widget id (see the modular loop). + ui.push_id(type_name, |ui| { + for row in &rows { + if let Some(new_ron) = + self.field_widget(ui, selected, type_name, &row.info, row.variants, &row.value) + { + edits.push((row.info.name, new_ron)); + } + } + }); + for (field, ron) in edits { + if let Some(cmd) = SetFieldCmd::new(self.state, selected, type_name, field, ron) { + self.commands.push(cmd, self.state); + } + } + } + + /// The `Layer` component widget: a single-choice dropdown of layer names. + /// + /// Each entity belongs to exactly one layer (the Unity model — per-entity + /// membership is single, filters are masks). The dropdown shows every + /// named layer from [`LayerRegistry`](oxide_engine::layer::LayerRegistry) + /// plus a `"Layer N"` fallback for unnamed slots, and an + /// **"Edit names…"** entry that opens the layer-editor modal so a project + /// can define names like `"Player"`, `"Enemy"`, `"World"`. + fn layers_widget(&mut self, ui: &mut egui::Ui, selected: Entity) { + use oxide_engine::layer::{Layer, MAX_LAYERS}; + let current_index: u32 = match self.state.scene.world().get::<&Layer>(selected) { + Ok(l) => l.index, + Err(_) => { + ui.weak("(no Layer — internal error: should be auto-attached)"); + return; + } + }; + let label_for = |i: u32, reg: &oxide_engine::layer::LayerRegistry| -> String { + reg.name(i) + .map(String::from) + .unwrap_or_else(|| format!("Layer {i}")) + }; + let current_label = label_for(current_index, &self.state.layer_registry); + + let mut chosen: Option = None; + let mut open_editor = false; + ui.horizontal(|ui| { + ui.label("Layer"); + egui::ComboBox::from_id_salt(("oxide.layer.dropdown", selected)) + .selected_text(current_label) + .show_ui(ui, |ui| { + for i in 0..MAX_LAYERS { + let name = label_for(i, &self.state.layer_registry); + if ui.selectable_label(i == current_index, name).clicked() { + chosen = Some(i); + } + } + ui.separator(); + if ui.button("✏ Edit names…").clicked() { + open_editor = true; + } + }); + }); + + if let Some(new_index) = chosen { + if new_index != current_index { + let new = Layer::on(new_index); + if let Ok(ron) = ron::to_string(&new_index) { + if let Some(cmd) = SetFieldCmd::new(self.state, selected, "Layer", "index", ron) + { + self.commands.push(cmd, self.state); + } else { + // Fall back to a direct mutation if the registry path + // somehow refuses (shouldn't happen for the auto- + // attached Layer component). + if let Ok(mut l) = self.state.scene.world_mut().get::<&mut Layer>(selected) + { + *l = new; + } + } + } + } + } + if open_editor { + *self.show_layer_editor = true; + } + } + + /// The `Groups` widget: a **multi-select** dropdown of the project's defined + /// group names, each a checkbox toggling this entity's membership. + /// + /// Groups are the multi-valued counterpart to the single-select `Layer` + /// above — an entity is on one layer but in any number of groups. Membership + /// lives in the entity's [`Tags`](oxide_engine::layer::Tags) component + /// (created lazily on first add); the dropdown's vocabulary comes from + /// [`group_registry`](EditorState::group_registry). An **"Edit groups…"** + /// entry opens the Groups editor to define new groups. Any tag an entity + /// carries that is *not* a defined group is surfaced below as an + /// "ungrouped tag" so it stays visible (e.g. one a script set, or a group + /// later deleted from the project). + fn groups_widget(&mut self, ui: &mut egui::Ui, selected: Entity) { + use oxide_engine::layer::Tags; + + // Snapshot the entity's current group membership and the project's + // defined groups, so the egui closures don't borrow the world/registry + // while we also need &mut self for the mutation below. + let current: Vec = self + .state + .scene + .world() + .get::<&Tags>(selected) + .map(|t| t.iter().map(String::from).collect()) + .unwrap_or_default(); + let defined: Vec = self.state.group_registry.iter().map(String::from).collect(); + // Defined groups the node isn't in yet — the "add" menu's contents. + let available: Vec = defined + .iter() + .filter(|d| !current.iter().any(|c| c == *d)) + .cloned() + .collect(); + + // (group name, now a member?) — applied after the UI closures so the + // world isn't borrowed while egui is mid-render. + let mut change: Option<(String, bool)> = None; + let mut open_editor = false; + + ui.horizontal(|ui| { + ui.label("Groups"); + // "+" opens a menu of defined groups not yet on this node. + ui.menu_button("➕", |ui| { + if available.is_empty() { + if defined.is_empty() { + ui.weak("(no groups defined)"); + } else { + ui.weak("(already in every group)"); + } + } + for name in &available { + if ui.button(name).clicked() { + change = Some((name.clone(), true)); + ui.close(); + } + } + ui.separator(); + if ui.button("✏ Edit groups…").clicked() { + open_editor = true; + ui.close(); + } + }); + }); + + // Each group the node is in renders as its own removable row — so a + // group can always be removed, including one whose definition was later + // deleted from the project (shown as "(undefined)" but still removable). + if current.is_empty() { + ui.weak("(not in any group)"); + } + for name in ¤t { + ui.horizontal(|ui| { + if ui + .small_button("🗑") + .on_hover_text("Remove from this group") + .clicked() + { + change = Some((name.clone(), false)); + } + ui.label(name); + if !defined.iter().any(|d| d == name) { + ui.weak("(undefined)"); + } + }); + } + + if let Some((name, member)) = change { + apply_group_membership(self.state.scene.world_mut(), selected, &name, member); + } + if open_editor { + *self.show_group_editor = true; + } + } + + /// "Add Component ▾" menu. Lists every addable type registered in the + /// reflection registry. For each: + /// + /// - If the entity **doesn't** already carry it → "*Name*" attaches it. + /// - If it **does** → "*Name* (as child)" spawns a new child entity with + /// the component already attached and selects it. This is Oxide's + /// answer to "multiple of the same type": archetypal ECS allows one + /// per type per entity, so a second mesh / collider / etc. lives on a + /// child entity (Bevy's pattern). The user still gets the workflow + /// from a single menu click. + fn add_component_menu(&mut self, ui: &mut egui::Ui, selected: Entity) { + enum AddChoice { + HereIfAbsent(&'static str), + AsChild(&'static str), + } + + // Snapshot per-type presence before opening the menu so the closure + // doesn't have to borrow self twice. + let present_set: Vec<(&'static str, bool)> = { + let world = self.state.scene.world(); + self.state + .registry + .addable_names() + .map(|n| { + ( + n, + self.state.registry.has(world, selected, n).unwrap_or(false), + ) + }) + .collect() + }; + + let mut chosen: Option = None; + ui.menu_button("➕ Add Component ▾", |ui| { + if present_set.is_empty() { + ui.weak("(no addable components registered)"); + } + for (name, present) in &present_set { + if !present { + if ui.button(*name).clicked() { + chosen = Some(AddChoice::HereIfAbsent(name)); + ui.close(); + } + } else { + // Already present: offer it as a child instead, so the + // user can still "add another mesh / collider / …" from + // one menu without breaking the ECS one-per-type rule. + if ui + .button(format!("{name} (as child)")) + .on_hover_text("Spawns a new child entity carrying this component") + .clicked() + { + chosen = Some(AddChoice::AsChild(name)); + ui.close(); + } + } + } + }); + + match chosen { + None => {} + Some(AddChoice::HereIfAbsent(name)) => { + let added = matches!( + self.state + .registry + .add_default(self.state.scene.world_mut(), selected, name), + Ok(true) + ); + if added { + self.state + .component_order + .entry(selected) + .or_default() + .push(name); + } + } + Some(AddChoice::AsChild(name)) => { + // Spawn a child entity with the component attached and select + // it. Name reflects the type so the hierarchy shows what it is. + let child_name = name.to_string(); + let child = + self.state + .scene + .spawn_child(selected, child_name.clone(), Transform::IDENTITY); + let _ = self + .state + .registry + .add_default(self.state.scene.world_mut(), child, name); + self.state + .component_order + .entry(child) + .or_default() + .push(name); + self.state.selected = Some(child); + *self.rename_buf = child_name; + *self.euler_for = None; + } + } + } + + /// Renders every *reflected* component on the entity by walking the + /// reflection registry — one heading per component, one widget per field — + /// instead of a hand-written panel per type. This is what lets a brand-new + /// component type appear in the inspector with no editor code: derive + /// `Reflect`, register it, done. + /// + /// `Node` is skipped because its fields (`name`, `enabled`) are already + /// shown by the bespoke header above. + fn reflected_components(&mut self, ui: &mut egui::Ui, selected: Entity) { + /// One field's descriptor + its current value as RON, plus the enum + /// variant list when the field's type is a registered enum (so phase 2 + /// can render a dropdown). Snapshotted so we don't hold a registry/world + /// borrow across the egui render + the command push that follows. + struct FieldRow { + info: FieldInfo, + variants: Option<&'static [&'static str]>, + value: String, + } + struct CompRows { + name: &'static str, + /// Whether the component is currently in the entity's + /// `DisabledComponents` set — drives the per-component checkbox. + disabled: bool, + fields: Vec, + } + + // Phase 1: gather metadata + current values (immutable borrows only). + let mut comps: Vec = Vec::new(); + { + let world = self.state.scene.world(); + let present: Vec<&'static str> = self + .state + .registry + .components_on(world, selected) + .into_iter() + .filter(|n| !is_essential_component(n)) + .collect(); + let saved = self + .state + .component_order + .get(&selected) + .map(Vec::as_slice) + .unwrap_or(&[]); + for name in inspector_component_order(&present, saved) { + let Ok(infos) = self.state.registry.field_infos(world, selected, name) else { + continue; + }; + let fields = infos + .iter() + .filter_map(|info| { + self.state + .registry + .get_field(world, selected, name, info.name) + .ok() + .map(|value| FieldRow { + info: *info, + variants: self.state.registry.enum_variants(info.type_name), + value, + }) + }) + .collect(); + let disabled = self.state.scene.is_component_disabled(selected, name); + comps.push(CompRows { + name, + disabled, + fields, + }); + } + } + + // Phase 2: render, collecting field edits + removals + per-component + // disable toggles + drag-reorder. The euler buffer for quat fields + // needs &mut self, so this can't hold the phase-1 borrows. + let mut edits: Vec<(&'static str, &'static str, String)> = Vec::new(); + let mut remove: Option<&'static str> = None; + let mut toggle: Option<(&'static str, bool)> = None; + + // Drag-reorder state: payload is the component name being dragged, + // pointer + released drive the drop decision. + let dragged = egui::DragAndDrop::payload::<&'static str>(ui.ctx()).map(|a| *a); + let pointer = ui.ctx().pointer_interact_pos(); + let released = ui.input(|i| i.pointer.any_released()); + // (dragged, target, place_before) on release. + let mut reorder: Option<(&'static str, &'static str, bool)> = None; + + for comp in &comps { + ui.separator(); + let row_resp = ui + .horizontal(|ui| { + // Enable/disable checkbox. + let mut enabled = !comp.disabled; + if ui + .checkbox(&mut enabled, "") + .on_hover_text("Enable / disable this component") + .changed() + { + toggle = Some((comp.name, !enabled)); + } + // Visually dim a disabled component's heading so it's clear + // systems will skip it. (No leading grip glyph — the hover + // Grab cursor + tooltip signal the drag affordance, and the + // braille grip rendered as a tofu box in egui's font.) + let title = if comp.disabled { + egui::RichText::new(comp.name).heading().weak().italics() + } else { + egui::RichText::new(comp.name).heading().strong() + }; + // The title **is** the drag handle. It must be a + // *non-selectable* Label: `ui.heading()` builds a selectable + // label, so a press-drag highlighted the text instead of + // starting a drag. `Label::selectable(false)` + a + // click-and-drag sense fixes that while keeping a hit rect + // big enough for egui's drag detection to fire. + let heading = ui + .add( + egui::Label::new(title) + .selectable(false) + .sense(egui::Sense::click_and_drag()), + ) + .on_hover_cursor(egui::CursorIcon::Grab) + .on_hover_text("Drag the title to reorder this component"); + if heading.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), comp.name); + } + if heading.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + if ui + .small_button("🗑") + .on_hover_text("Remove component") + .clicked() + { + remove = Some(comp.name); + } + }) + .response; + + // Drop indicator + capture for drag-reorder. Transform is never a + // valid drop target (it's pinned first); a component never drops + // onto itself. + if let (Some(drag_name), Some(pos)) = (dragged, pointer) { + if drag_name != comp.name && comp.name != "Transform" { + let r = row_resp.rect; + if r.contains(pos) { + let above = (pos.y - r.top()) < r.height() / 2.0; + let accent = ui.visuals().selection.bg_fill; + let line = egui::Stroke::new(2.0, accent); + let y = if above { r.top() } else { r.bottom() }; + ui.painter().line_segment( + [egui::pos2(r.left(), y), egui::pos2(r.right(), y)], + line, + ); + if released { + reorder = Some((drag_name, comp.name, above)); + } + } + } + } + + // Namespace every field widget by the component name. Two different + // components can share a field name (e.g. both `MeshRenderer` and + // `Collider` have a `shape`), and some field widgets derive a stable + // egui id from `(entity, field_name)` only — without this scope those + // ids collide and egui flags a duplicate-id error, blocking edits. + ui.push_id(comp.name, |ui| { + for row in &comp.fields { + if let Some(new_ron) = self.field_widget( + ui, + selected, + comp.name, + &row.info, + row.variants, + &row.value, + ) { + edits.push((comp.name, row.info.name, new_ron)); + } + } + }); + } + + // Phase 3a: apply field edits through the undo stack. SetFieldCmd's + // merge hook coalesces a drag into one undo entry. + for (type_name, field, ron) in edits { + if let Some(cmd) = SetFieldCmd::new(self.state, selected, type_name, field, ron) { + self.commands.push(cmd, self.state); + } + } + // Phase 3b: component removal. Not undoable yet — like Add/Delete in + // the hierarchy, structural changes bypass the command stack. + if let Some(type_name) = remove { + let _ = self + .state + .registry + .remove(self.state.scene.world_mut(), selected, type_name); + // Drop it from the inspector order too so it doesn't reappear if + // it's later re-added (we want re-adds at the bottom, fresh). + if let Some(order) = self.state.component_order.get_mut(&selected) { + order.retain(|n| *n != type_name); + } + } + // Phase 3c: per-component enable/disable. Toggling rewrites the + // entity's DisabledComponents set (inserted if absent, cleared from + // the entity when no components remain disabled). + if let Some((type_name, disabled)) = toggle { + apply_component_disable(self.state.scene.world_mut(), selected, type_name, disabled); + } + // Phase 3d: drag-reorder. Sync the saved order with anything currently + // displayed (so components that arrived via spawn / set_ron / etc. are + // reorderable too), then move the dragged entry to its drop position. + if let Some((dragged, target, place_before)) = reorder { + let order_entry = self.state.component_order.entry(selected).or_default(); + for comp in &comps { + if comp.name != "Transform" && !order_entry.contains(&comp.name) { + order_entry.push(comp.name); + } + } + let new_order = + reorder_component_list(std::mem::take(order_entry), dragged, target, place_before); + *order_entry = new_order; + } + } + + /// Renders one reflected field as a typed widget chosen from its + /// `type_name`, returning the field's new RON if the user changed it. + /// Unknown types fall back to an editable RON text box, so the inspector is + /// fully generic even for types it has no bespoke widget for. + fn field_widget( + &mut self, + ui: &mut egui::Ui, + entity: Entity, + type_name: &'static str, + info: &FieldInfo, + variants: Option<&'static [&'static str]>, + current: &str, + ) -> Option { + // Enum-typed fields with a registered variant list become a dropdown, + // regardless of the syntactic type_name. The chosen name is already + // valid RON for the unit variant (see ReflectEnum docs). + if let Some(variants) = variants { + let mut current_owned = current.to_owned(); + let mut chosen: Option = None; + ui.horizontal(|ui| { + ui.label(info.name); + egui::ComboBox::from_id_salt(("oxide.field.enum", entity, info.name)) + .selected_text(¤t_owned) + .show_ui(ui, |ui| { + for v in variants { + if ui.selectable_label(current_owned == *v, *v).clicked() { + current_owned = (*v).to_owned(); + chosen = Some((*v).to_owned()); + } + } + }); + }); + return chosen; + } + // Asset-reference fields (`AssetRef` / `Handle`) get a picker that + // lists the project's assets of the matching kind, filtered by the + // field's target type. Recognised by the field's syntactic type name. + if let Some(target) = asset_ref_target(info.type_name) { + return self.asset_ref_field_widget(ui, entity, info, target, current); + } + match info.type_name { + "f32" => { + let mut v: f32 = ron::from_str(current).ok()?; + let changed = ui + .horizontal(|ui| { + ui.label(info.name); + // `#[reflect(min, max)]` → slider; otherwise unbounded drag. + if let Some((min, max)) = info.range { + ui.add(egui::Slider::new(&mut v, min..=max)).changed() + } else { + ui.add(egui::DragValue::new(&mut v).speed(0.05)).changed() + } + }) + .inner; + changed.then(|| ron::to_string(&v).ok()).flatten() + } + "Color" => { + let c: Color = ron::from_str(current).ok()?; + let mut rgba = [c.r, c.g, c.b, c.a]; + let changed = ui + .horizontal(|ui| { + ui.label(info.name); + ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() + }) + .inner; + if changed { + ron::to_string(&Color::rgba(rgba[0], rgba[1], rgba[2], rgba[3])).ok() + } else { + None + } + } + "Material" => { + // The basic PBR material renders as one composite block: a + // color picker for albedo + sliders for the normalized + // metallic / roughness factors — the widgets the user expects + // for a surface, instead of a raw RON string. (A future + // recursive struct inspector based on `Reflect` would replace + // this special case generically.) + let mut m: Material = ron::from_str(current).ok()?; + let mut changed = false; + ui.label(info.name); + ui.indent(("oxide.field.material", entity, info.name), |ui| { + let mut rgba = [m.albedo.r, m.albedo.g, m.albedo.b, m.albedo.a]; + ui.horizontal(|ui| { + ui.label("albedo"); + if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() { + m.albedo = Color::rgba(rgba[0], rgba[1], rgba[2], rgba[3]); + changed = true; + } + }); + ui.horizontal(|ui| { + ui.label("metallic"); + if ui + .add(egui::Slider::new(&mut m.metallic, 0.0..=1.0)) + .changed() + { + changed = true; + } + }); + ui.horizontal(|ui| { + ui.label("roughness"); + if ui + .add(egui::Slider::new(&mut m.roughness, 0.0..=1.0)) + .changed() + { + changed = true; + } + }); + }); + if changed { + ron::to_string(&m).ok() + } else { + None + } + } + "bool" => { + let mut v: bool = ron::from_str(current).ok()?; + let changed = ui + .horizontal(|ui| { + ui.label(info.name); + ui.checkbox(&mut v, "").changed() + }) + .inner; + changed.then(|| v.to_string()) + } + "Vec3" => { + let mut v: Vec3 = ron::from_str(current).ok()?; + vec3_drag(ui, info.name, &mut v, 0.05) + .then(|| ron::to_string(&v).ok()) + .flatten() + } + "Quat" => self.quat_field_widget(ui, entity, type_name, info, current), + "String" => { + let mut s: String = ron::from_str(current).ok()?; + let changed = ui + .horizontal(|ui| { + ui.label(info.name); + ui.text_edit_singleline(&mut s).changed() + }) + .inner; + changed.then(|| ron::to_string(&s).ok()).flatten() + } + "LayerMask" => { + // A multi-select dropdown of named layers — the right control + // for a *filter* mask (e.g. a Camera's visibility), which is + // multi-valued even though an entity's own `Layer` is single. + let mut mask: LayerMask = ron::from_str(current).ok()?; + // Snapshot labels so the combo closure doesn't borrow the + // registry while we mutate `mask`. + let labels: Vec<(u32, String)> = (0..oxide_engine::layer::MAX_LAYERS) + .map(|i| (i, layer_label(i, &self.state.layer_registry))) + .collect(); + let summary = mask_summary(mask, &self.state.layer_registry); + let mut changed = false; + ui.horizontal(|ui| { + ui.label(info.name); + egui::ComboBox::from_id_salt(("oxide.field.layermask", entity, info.name)) + .selected_text(summary) + .show_ui(ui, |ui| { + for (i, name) in &labels { + let mut on = mask.contains_layer(*i); + if ui.checkbox(&mut on, name).changed() { + mask = mask.toggled(*i); + changed = true; + } + } + }); + }); + changed.then(|| ron::to_string(&mask).ok()).flatten() + } + // Fallback: edit the raw RON. A bad edit is simply rejected by + // `set_field` (the value stays unchanged), so this is safe. + _ => { + let mut text = current.to_owned(); + let changed = ui + .horizontal(|ui| { + ui.label(info.name); + ui.text_edit_singleline(&mut text).changed() + }) + .inner; + changed.then_some(text) + } + } + } + + /// Renders an asset-reference field as a picker: a dropdown of the project's + /// assets whose [`AssetKind`] matches the field's `target` type, plus a + /// "(none)" entry to clear it. The field's stored value is the asset's + /// stable `AssetUid` (an `AssetRef` serializes as `Option`), so + /// the returned RON is `Some((uid))` / `None` — independent of the project's + /// location on disk. + fn asset_ref_field_widget( + &self, + ui: &mut egui::Ui, + entity: Entity, + info: &FieldInfo, + target: &str, + current: &str, + ) -> Option { + // The current value, if any. A malformed value is treated as empty. + let cur: Option = ron::from_str(current).unwrap_or(None); + let filter = AssetKind::for_handle_target(target); + + let Some(db) = &self.state.asset_db else { + ui.horizontal(|ui| { + ui.label(info.name); + ui.weak("(open a project to pick assets)"); + }); + return None; + }; + + // Snapshot the pickable assets (uid + leaf name) so the combo closure + // doesn't borrow `db` while we mutate the selection. + let mut options: Vec<(AssetUid, String)> = db + .entries() + .filter(|e| filter.map_or(true, |k| e.kind == k)) + .map(|e| { + let leaf = e.path.rsplit('/').next().unwrap_or(&e.path).to_owned(); + (e.uid, leaf) + }) + .collect(); + options.sort_by(|a, b| a.1.cmp(&b.1)); + + let selected_text = match cur { + Some(uid) => db + .relative_path(uid) + .map(|p| p.rsplit('/').next().unwrap_or(p).to_owned()) + .unwrap_or_else(|| "(missing)".to_owned()), + None => "(none)".to_owned(), + }; + + let mut chosen: Option> = None; + ui.horizontal(|ui| { + ui.label(info.name); + egui::ComboBox::from_id_salt(("oxide.field.assetref", entity, info.name)) + .selected_text(selected_text) + .show_ui(ui, |ui| { + if ui.selectable_label(cur.is_none(), "(none)").clicked() { + chosen = Some(None); + } + for (uid, leaf) in &options { + if ui.selectable_label(cur == Some(*uid), leaf).clicked() { + chosen = Some(Some(*uid)); + } + } + }); + }); + chosen.and_then(|value| ron::to_string(&value).ok()) + } + + /// Edits a `Quat` field as Euler degrees — raw quaternion components are + /// not hand-editable. Uses the [`euler_for`](Shell::euler_for) buffer so + /// the displayed angles stay stable across frames instead of jumping from + /// lossy quat↔euler round-trips. + fn quat_field_widget( + &mut self, + ui: &mut egui::Ui, + entity: Entity, + type_name: &'static str, + info: &FieldInfo, + current: &str, + ) -> Option { + let q: Quat = ron::from_str(current).ok()?; + let key = (entity, type_name, info.name); + // Re-sync the buffer from the stored quaternion only when we switch to + // a different field, so an in-progress edit isn't perturbed. + if *self.euler_for != Some(key) { + let (rx, ry, rz) = q.to_euler(EulerRot::XYZ); + *self.rot_euler = Vec3::new(rx.to_degrees(), ry.to_degrees(), rz.to_degrees()); + *self.euler_for = Some(key); + } + let mut euler = *self.rot_euler; + if vec3_drag(ui, info.name, &mut euler, 0.5) { + *self.rot_euler = euler; + let rotated = Quat::from_euler( + EulerRot::XYZ, + euler.x.to_radians(), + euler.y.to_radians(), + euler.z.to_radians(), + ); + ron::to_string(&rotated).ok() + } else { + None + } + } + + fn project_panel(&mut self, ui: &mut egui::Ui) { + let Some(project) = &self.state.project else { + ui.weak("No project open."); + ui.label("Open or create one from the File menu."); + return; + }; + ui.heading(project.name()); + ui.monospace(project.root().display().to_string()); + ui.separator(); + + // Asset browser: typed folders grouped by kind, listed from the + // database (the source of truth for what the picker can reference). + // "Importing" an asset is just dropping the file into the right folder; + // the file watcher rescans, or the user can rescan on demand. + ui.horizontal(|ui| { + ui.label("Assets"); + if ui + .small_button("↺ Rescan") + .on_hover_text("Re-read assets/ from disk") + .clicked() + { + if let Some(db) = &mut self.state.asset_db { + db.scan(); + let _ = db.save(); + } + } + }); + match &self.state.asset_db { + Some(db) => asset_browser(ui, db), + None => { + ui.weak("(asset database unavailable)"); + } + } + + ui.separator(); + let project = self.state.project.as_ref().unwrap(); + show_project_tree(ui, "scenes/", project.scenes_dir()); + show_project_tree(ui, "scripts/", project.scripts_dir()); + } + + /// The UI canvas: edit a [`UiPanel`] document — widget tree, live preview, + /// and a property panel (including the font-asset picker) — saved as a + /// `ui/` asset. Structural and property edits route through + /// [`SetUiPanelCmd`](crate::commands::SetUiPanelCmd) so they undo. + fn ui_canvas_panel(&mut self, ui: &mut egui::Ui) { + if self.state.ui_doc.is_none() { + ui.weak("No UI document open."); + if ui.button("➕ New UI Document").clicked() { + self.state.ui_doc = Some(crate::state::UiDoc::new()); + } + self.ui_canvas_open_list(ui); + return; + } + + // Render from clones so we can mutate state / push commands afterwards + // without holding a borrow of `self.state.ui_doc`. + let doc = self.state.ui_doc.as_ref().unwrap(); + let panel = doc.panel.clone(); + let selected = doc.selected.clone(); + let dirty = doc.dirty; + + let mut new_selection: Option = None; + let mut new_panel: Option<(UiPanel, String)> = None; + let mut do_save = false; + let mut do_close = false; + + // --- Toolbar -------------------------------------------------------- + ui.horizontal(|ui| { + if ui.button("💾 Save").clicked() { + do_save = true; + } + // Add a widget as a child of the selection (if it's a container), + // else as a child of the root. + ui.menu_button("➕ Add ▾", |ui| { + for name in ["Leaf", "Row", "Column", "Grid", "Anchor"] { + if ui.button(name).clicked() { + let widget = match name { + "Row" => Widget::row(), + "Column" => Widget::column(), + "Grid" => Widget::grid(2, 2), + "Anchor" => Widget::anchor(), + _ => Widget::leaf(Vec2::new(120.0, 32.0)), + }; + let parent = if panel + .root + .get_path(&selected) + .is_some_and(Widget::is_container) + { + selected.clone() + } else { + WidgetPath::root() + }; + let mut next = panel.clone(); + if next.root.push_child_at(&parent, widget) { + let count = next + .root + .get_path(&parent) + .map_or(0, |w| w.children().len()); + new_selection = Some(parent.child(count.saturating_sub(1))); + new_panel = Some((next, format!("Add {name}"))); + } + ui.close(); + } + } + }); + if !selected.is_root() && ui.button("🗑 Remove").clicked() { + let mut next = panel.clone(); + if next.root.remove_path(&selected).is_some() { + new_selection = Some(WidgetPath::root()); + new_panel = Some((next, "Remove widget".to_owned())); + } + } + if dirty { + ui.weak("● unsaved"); + } + if ui.button("Close").clicked() { + do_close = true; + } + }); + ui.separator(); + + // Everything below scrolls, so the property panel is always reachable + // even when the canvas tab is short. + egui::ScrollArea::vertical().show(ui, |ui| { + // --- Widget tree ------------------------------------------------ + egui::CollapsingHeader::new("Widget tree") + .default_open(true) + .show(ui, |ui| { + ui_widget_tree( + ui, + &panel.root, + WidgetPath::root(), + &selected, + &mut new_selection, + ); + }); + ui.separator(); + + // --- Preview ---------------------------------------------------- + ui.label("Preview"); + draw_ui_preview(ui, &panel); + ui.separator(); + + // --- Properties of the selected widget -------------------------- + if let Some(widget) = panel.root.get_path(&selected) { + let mut edited = widget.clone(); + ui.label(format!("Properties — {}", widget_label(widget, &selected))); + if self.ui_widget_properties(ui, &mut edited, &selected) { + let mut next = panel.clone(); + if let Some(slot) = next.root.get_path_mut(&selected) { + *slot = edited; + new_panel = Some((next, "Edit widget".to_owned())); + } + } + } + }); + + // --- Apply collected actions --------------------------------------- + if let Some(sel) = new_selection { + if let Some(doc) = &mut self.state.ui_doc { + doc.selected = sel; + } + } + if let Some((after, label)) = new_panel { + let before = self.state.ui_doc.as_ref().unwrap().panel.clone(); + self.commands.push( + SetUiPanelCmd { + before, + after, + label, + }, + self.state, + ); + } + if do_save { + self.save_ui_doc(); + } + if do_close { + self.state.ui_doc = None; + } + } + + /// Lists the project's `ui/` documents (when a project is open) as buttons + /// that open them into the canvas. + fn ui_canvas_open_list(&mut self, ui: &mut egui::Ui) { + let Some(db) = &self.state.asset_db else { + return; + }; + let mut docs: Vec<(AssetUid, String)> = db + .entries_of_kind(AssetKind::Ui) + .map(|e| (e.uid, e.path.clone())) + .collect(); + docs.sort_by(|a, b| a.1.cmp(&b.1)); + if docs.is_empty() { + return; + } + ui.separator(); + ui.label("Open a UI document:"); + let mut to_open: Option<(AssetUid, PathBuf)> = None; + for (uid, path) in &docs { + if ui.button(path).clicked() { + if let Some(abs) = db.absolute_path(*uid) { + to_open = Some((*uid, abs)); + } + } + } + if let Some((uid, abs)) = to_open { + match std::fs::read_to_string(&abs) + .ok() + .and_then(|t| ron::from_str::(&t).ok()) + { + Some(panel) => { + self.state.ui_doc = Some(crate::state::UiDoc { + panel, + asset: Some(uid), + selected: WidgetPath::root(), + dirty: false, + }); + } + None => log::warn!("could not load UI document {}", abs.display()), + } + } + } + + /// Property editor for the selected widget. Returns whether anything + /// changed (the caller then records one undoable panel edit). Reads the + /// asset database for the font picker. + fn ui_widget_properties( + &self, + ui: &mut egui::Ui, + widget: &mut Widget, + salt: &WidgetPath, + ) -> bool { + let mut changed = false; + let id_salt = ("oxide.uicanvas.props", salt.0.clone()); + + ui.horizontal(|ui| { + ui.label("id"); + let mut id = widget.id.as_str().to_owned(); + if ui.text_edit_singleline(&mut id).changed() { + widget.id = id.into(); + changed = true; + } + }); + + // Text content (empty clears it). + ui.horizontal(|ui| { + ui.label("text"); + let mut text = widget.text.clone().unwrap_or_default(); + if ui.text_edit_singleline(&mut text).changed() { + widget.text = (!text.is_empty()).then_some(text); + changed = true; + } + }); + + // Kind-specific parameters — what makes each widget type distinct. + changed |= kind_properties(ui, &mut widget.kind, salt); + + ui.separator(); + changed |= optional_color(ui, "background", &mut widget.visual.background); + changed |= optional_color(ui, "foreground", &mut widget.visual.foreground); + + // Font size (optional). + ui.horizontal(|ui| { + let mut on = widget.visual.font_size.is_some(); + if ui.checkbox(&mut on, "font size").changed() { + widget.visual.font_size = on.then_some(14.0); + changed = true; + } + if let Some(size) = &mut widget.visual.font_size { + if ui + .add(egui::DragValue::new(size).range(4.0..=200.0)) + .changed() + { + changed = true; + } + } + }); + + // Font asset picker — the AssetRef end-to-end target. + changed |= self.font_asset_picker(ui, &id_salt, &mut widget.visual.font_asset); + + // Layout — how this widget is sized and placed within its parent. + ui.separator(); + egui::CollapsingHeader::new("Layout") + .id_salt(("oxide.uicanvas.layout", salt.0.clone())) + .default_open(true) + .show(ui, |ui| { + changed |= sizing_editor(ui, "width", &mut widget.style.width, 0); + changed |= sizing_editor(ui, "height", &mut widget.style.height, 1); + changed |= align_combo(ui, "align x", &mut widget.style.align_horizontal, 0); + changed |= align_combo(ui, "align y", &mut widget.style.align_vertical, 1); + changed |= insets_editor(ui, "padding", &mut widget.style.padding, 0); + changed |= insets_editor(ui, "margin", &mut widget.style.margin, 1); + changed |= anchor_combo(ui, &mut widget.style.anchor, salt); + ui.weak( + "Tip: stacks/grids place children automatically; to move a \ + child freely, put it in an Anchor parent and set its anchor.", + ); + }); + + changed + } + + /// A dropdown that sets a [`VisualStyle::font_asset`] from the project's + /// font assets (or clears it to inherit). The picker filters the asset + /// database to [`AssetKind::Font`]. + fn font_asset_picker( + &self, + ui: &mut egui::Ui, + id_salt: &(&str, Vec), + font_asset: &mut Option>, + ) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + ui.label("font asset"); + let Some(db) = &self.state.asset_db else { + ui.weak("(open a project)"); + return; + }; + let mut fonts: Vec<(AssetUid, String)> = db + .entries_of_kind(AssetKind::Font) + .map(|e| { + let leaf = e.path.rsplit('/').next().unwrap_or(&e.path).to_owned(); + (e.uid, leaf) + }) + .collect(); + fonts.sort_by(|a, b| a.1.cmp(&b.1)); + let current_uid = font_asset.and_then(|r| r.uid()); + let selected_text = match current_uid { + Some(uid) => db + .relative_path(uid) + .map(|p| p.rsplit('/').next().unwrap_or(p).to_owned()) + .unwrap_or_else(|| "(missing)".to_owned()), + None => "(inherit)".to_owned(), + }; + egui::ComboBox::from_id_salt(id_salt) + .selected_text(selected_text) + .show_ui(ui, |ui| { + if ui + .selectable_label(current_uid.is_none(), "(inherit)") + .clicked() + { + *font_asset = None; + changed = true; + } + for (uid, leaf) in &fonts { + if ui + .selectable_label(current_uid == Some(*uid), leaf) + .clicked() + { + *font_asset = Some(AssetRef::new(*uid)); + changed = true; + } + } + }); + }); + changed + } + + /// Saves the open UI document as a `ui/` asset (RON). A never-saved + /// document is written to `ui/untitled.ron` and registered in the database. + fn save_ui_doc(&mut self) { + let Some(db) = &self.state.asset_db else { + log::warn!("cannot save UI document without an open project"); + return; + }; + let doc = self.state.ui_doc.as_ref().unwrap(); + let rel = match doc.asset.and_then(|uid| db.relative_path(uid)) { + Some(rel) => rel.to_owned(), + None => "ui/untitled.ron".to_owned(), + }; + let abs = db.assets_dir().join(&rel); + let Ok(text) = ron::ser::to_string_pretty(&doc.panel, ron::ser::PrettyConfig::default()) + else { + log::warn!("failed to serialize UI document"); + return; + }; + if let Some(parent) = abs.parent() { + if let Err(err) = std::fs::create_dir_all(parent) { + log::warn!("could not create {}: {err}", parent.display()); + return; + } + } + if let Err(err) = std::fs::write(&abs, text) { + log::warn!("could not write {}: {err}", abs.display()); + return; + } + // Register a newly-saved document and remember its uid. + if let Some(db) = &mut self.state.asset_db { + let uid = db.register(&rel); + let _ = db.save(); + if let Some(doc) = &mut self.state.ui_doc { + doc.asset = Some(uid); + doc.dirty = false; + } + } + } + + fn console(&mut self, ui: &mut egui::Ui) { + let Some(buffer) = crate::console::log_buffer() else { + ui.weak("Console: logging is not initialised."); + return; + }; + + // Toolbar: line count + Clear. + ui.horizontal(|ui| { + let count = buffer.lock().map(|b| b.len()).unwrap_or(0); + ui.weak(format!("{count} line(s)")); + if ui.button("Clear").clicked() { + if let Ok(mut b) = buffer.lock() { + b.clear(); + } + } + ui.weak("· script print/errors appear here"); + }); + + // Command prompt: runs a shell command in the project root, streaming + // its output into this same panel. + ui.horizontal(|ui| { + ui.label("$"); + let input = egui::TextEdit::singleline(self.terminal_input) + .hint_text("run a command…") + .desired_width(f32::INFINITY) + .font(egui::TextStyle::Monospace); + let resp = ui.add(input); + let submitted = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if submitted && !self.terminal_input.trim().is_empty() { + let command = std::mem::take(self.terminal_input); + let cwd = self + .state + .asset_db + .as_ref() + .map(|db| db.root().to_path_buf()) + .unwrap_or_else(|| { + std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")) + }); + crate::terminal::run(&command, &cwd); + resp.request_focus(); // keep typing without re-clicking + } + }); + ui.separator(); + + // The log lines, newest pinned to the bottom, coloured by severity. + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .stick_to_bottom(true) + .show(ui, |ui| { + let Ok(b) = buffer.lock() else { + return; + }; + if b.is_empty() { + ui.weak("(no output yet)"); + return; + } + for line in b.iter() { + let color = level_color(ui, line.level); + // Compact one-line entry: [LEVEL target] message. + let head = format!("[{} {}]", line.level, line.target); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + ui.colored_label(color, head); + ui.label(&line.message); + }); + } + }); + } + + /// The interactive PTY terminal panel: a tab strip of sessions plus the + /// active session's screen grid; keystrokes route to the active terminal. + fn terminal_panel(&mut self, ui: &mut egui::Ui) { + // Auto-close sessions whose program has exited, then keep the active + // index in range — so typing `exit` (or the agent finishing) drops the + // tab instead of leaving a dead one behind. + self.terminals.retain_mut(|t| !t.has_exited()); + if *self.active_terminal >= self.terminals.len() { + *self.active_terminal = self.terminals.len().saturating_sub(1); + } + + // Tab strip: one selectable label per session (with a close button) and + // a "+" to open another shell. + let mut to_close: Option = None; + ui.horizontal(|ui| { + for idx in 0..self.terminals.len() { + let selected = idx == *self.active_terminal; + let label = format!("{} {}", self.terminals[idx].title, idx + 1); + if ui.selectable_label(selected, label).clicked() { + *self.active_terminal = idx; + } + if ui.small_button("x").on_hover_text("Close").clicked() { + to_close = Some(idx); + } + ui.separator(); + } + if ui.button("+ Shell").clicked() { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); + self.launch_terminal("shell", &shell, &[]); + } + }); + if let Some(idx) = to_close { + if idx < self.terminals.len() { + self.terminals.remove(idx); // Drop kills the child. + if *self.active_terminal >= self.terminals.len() { + *self.active_terminal = self.terminals.len().saturating_sub(1); + } + } + } + ui.separator(); + + if self.terminals.is_empty() { + ui.weak( + "No terminal. Click + Shell to start one — it runs in the project directory \ + and supports interactive / full-screen programs (a shell, a REPL, vim, an \ + AI-agent CLI like claude).", + ); + return; + } + let term = &mut self.terminals[*self.active_terminal]; + + // A live session: keep repainting so streamed output shows promptly. + ui.ctx().request_repaint(); + + // Size the grid to the panel using the monospace cell metrics. + let font = egui::FontId::monospace(13.0); + let (cell_w, cell_h) = ui + .ctx() + .fonts_mut(|f| (f.glyph_width(&font, 'M'), f.row_height(&font))); + let avail = ui.available_size(); + let cols = ((avail.x / cell_w).floor() as u16).max(1); + let rows = ((avail.y / cell_h).floor() as u16).max(1); + term.resize(rows, cols); + + // Build the grid as a monospace LayoutJob and paint it into a focusable + // region so the panel can capture keystrokes. + let default_fg = ui.visuals().text_color(); + let bg = ui.visuals().extreme_bg_color; + let job = term.with_screen(|screen| build_terminal_job(screen, &font, default_fg)); + let galley = ui.painter().layout_job(job); + + let (rect, response) = ui.allocate_exact_size(avail, egui::Sense::click()); + if response.clicked() { + response.request_focus(); + } + // Deliver Tab / arrows / Escape to the terminal instead of letting egui + // use them to move focus — a terminal program needs all of them (Tab + // completion, arrow navigation, vim's Esc). + ui.memory_mut(|m| { + m.set_focus_lock_filter( + response.id, + egui::EventFilter { + tab: true, + horizontal_arrows: true, + vertical_arrows: true, + escape: true, + }, + ) + }); + ui.painter().rect_filled(rect, 0.0, bg); + ui.painter().galley(rect.min, galley, default_fg); + + // Route input only while focused, so typing elsewhere isn't captured. + if response.has_focus() { + let events = ui.input(|i| i.events.clone()); + for event in events { + match event { + egui::Event::Text(text) => term.send_input(text.as_bytes()), + egui::Event::Paste(text) => term.send_input(text.as_bytes()), + egui::Event::Key { + key, + pressed: true, + modifiers, + .. + } => { + if let Some(bytes) = crate::pty::encode_key(key, modifiers) { + term.send_input(&bytes); + } + } + _ => {} + } + } + } else { + ui.weak("(click to focus and type)"); + } + } + + /// Spawns a terminal session running `program` (with `args`) in the project + /// directory, appends it as a new tab, and makes it active. + fn launch_terminal(&mut self, title: &str, program: &str, args: &[&str]) { + let cwd = self + .state + .asset_db + .as_ref() + .map(|db| db.root().to_path_buf()) + .unwrap_or_else(|| { + std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")) + }); + match crate::pty::PtyTerminal::spawn(title, program, args, &cwd, 24, 80) { + Ok(term) => { + self.terminals.push(term); + *self.active_terminal = self.terminals.len() - 1; + } + Err(err) => { + log::error!(target: "terminal", "failed to launch {program}: {err}"); + } + } + } + + fn custom_panel(&mut self, ui: &mut egui::Ui, name: &str) { + for panel in self.extensions.iter_panels_mut() { + if panel.name == name { + (panel.render)(ui); + return; + } + } + ui.weak(format!("Panel '{name}' is not registered.")); + } +} + +// ---- helpers ----------------------------------------------------------- + +/// Returns whether the rendered control changed any axis this frame. +/// Projects a world-space point through `view_proj` to screen pixels +/// inside `tab_rect` (egui logical points). Returns `None` when the point +/// is behind the camera (negative-w) so callers can skip painting that +/// vertex / handle. +fn project( + world: oxide_engine::math::Vec3, + view_proj: &Mat4, + tab_rect: egui::Rect, +) -> Option { + let clip: Vec4 = *view_proj * world.extend(1.0); + if clip.w <= 0.0 { + return None; + } + let x_ndc = clip.x / clip.w; + let y_ndc = clip.y / clip.w; + let x = tab_rect.min.x + (x_ndc * 0.5 + 0.5) * tab_rect.width(); + // Y flips: NDC up = screen down. + let y = tab_rect.min.y + (1.0 - (y_ndc * 0.5 + 0.5)) * tab_rect.height(); + Some(egui::Pos2::new(x, y)) +} + +/// The end of the raycast-probe normal whisker: `point` plus `normal` +/// (re-normalized) scaled to `len`. Factored out so the geometry is unit-tested +/// and `paint_raycast_probe` only does the projection. A zero/degenerate normal +/// collapses to `point` (no whisker drawn). +fn probe_normal_tip( + point: oxide_engine::math::Vec3, + normal: oxide_engine::math::Vec3, + len: f32, +) -> oxide_engine::math::Vec3 { + point + normal.normalize_or_zero() * len +} + +/// Two unit vectors orthogonal to `n` (and to each other), forming a +/// right-handed basis with `n`. Used to parameterize the rotate-axis +/// circle in 3D. +fn orthonormal_basis( + n: oxide_engine::math::Vec3, +) -> (oxide_engine::math::Vec3, oxide_engine::math::Vec3) { + use oxide_engine::math::Vec3; + // Pick a reference axis that isn't (near-) collinear with `n`. + let reference = if n.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }; + let u = n.cross(reference).normalize(); + let v = n.cross(u); + (u, v) +} + +/// Local-space line segments that outline a physics +/// [`Collider`](oxide_physics::Collider)'s shape, as `(start, end)` pairs in +/// the collider's local frame (the caller applies the world pose). Used by the +/// viewport collider gizmos (Stage 9 piece 8b). Conventions match the +/// simulation: capsule/cylinder axes are local `+Y`; sphere/capsule/cylinder +/// use `radius`, box uses `half_extents`, capsule/cylinder add `half_height`. +fn collider_wire_segments( + col: &oxide_physics::Collider, +) -> Vec<(oxide_engine::math::Vec3, oxide_engine::math::Vec3)> { + use oxide_engine::math::Vec3; + use oxide_physics::ColliderShape; + use std::f32::consts::{PI, TAU}; + + let mut segs: Vec<(Vec3, Vec3)> = Vec::new(); + match col.shape { + ColliderShape::Box => { + let h = col.half_extents; + // 8 corners: indices 0..3 bottom (-Y), 4..7 top (+Y). + let c = [ + 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), + 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), + ]; + const EDGES: [(usize, usize); 12] = [ + (0, 1), + (1, 2), + (2, 3), + (3, 0), // bottom face + (4, 5), + (5, 6), + (6, 7), + (7, 4), // top face + (0, 4), + (1, 5), + (2, 6), + (3, 7), // verticals + ]; + for (a, b) in EDGES { + segs.push((c[a], c[b])); + } + } + ColliderShape::Sphere => { + let r = col.radius; + // Three great circles, one per coordinate plane. + push_arc(&mut segs, Vec3::ZERO, Vec3::X, Vec3::Y, r, (0.0, TAU), 24); + push_arc(&mut segs, Vec3::ZERO, Vec3::X, Vec3::Z, r, (0.0, TAU), 24); + push_arc(&mut segs, Vec3::ZERO, Vec3::Y, Vec3::Z, r, (0.0, TAU), 24); + } + ColliderShape::Capsule => { + let (r, hh) = (col.radius, col.half_height); + let (top, bot) = (Vec3::Y * hh, Vec3::Y * -hh); + // Seam rings where the hemispheres meet the cylinder. + push_arc(&mut segs, top, Vec3::X, Vec3::Z, r, (0.0, TAU), 24); + push_arc(&mut segs, bot, Vec3::X, Vec3::Z, r, (0.0, TAU), 24); + // Cylinder side lines on the four cardinal directions. + for d in [Vec3::X, -Vec3::X, Vec3::Z, -Vec3::Z] { + segs.push((bot + d * r, top + d * r)); + } + // Hemisphere cap profiles (over the top, under the bottom). + push_arc(&mut segs, top, Vec3::X, Vec3::Y, r, (0.0, PI), 12); + push_arc(&mut segs, top, Vec3::Z, Vec3::Y, r, (0.0, PI), 12); + push_arc(&mut segs, bot, Vec3::X, Vec3::Y, r, (PI, TAU), 12); + push_arc(&mut segs, bot, Vec3::Z, Vec3::Y, r, (PI, TAU), 12); + } + ColliderShape::Cylinder => { + let (r, hh) = (col.radius, col.half_height); + let (top, bot) = (Vec3::Y * hh, Vec3::Y * -hh); + push_arc(&mut segs, top, Vec3::X, Vec3::Z, r, (0.0, TAU), 24); + push_arc(&mut segs, bot, Vec3::X, Vec3::Z, r, (0.0, TAU), 24); + for d in [Vec3::X, -Vec3::X, Vec3::Z, -Vec3::Z] { + segs.push((bot + d * r, top + d * r)); + } + } + } + segs +} + +/// Appends `segs` line segments tracing the arc of a circle centred at `c`, +/// radius `r`, in the plane spanned by unit vectors `u`/`v`, sweeping the angle +/// `range.0..range.1` (radians). A full `0..TAU` sweep traces a closed circle. +/// Helper for [`collider_wire_segments`]. +fn push_arc( + out: &mut Vec<(oxide_engine::math::Vec3, oxide_engine::math::Vec3)>, + c: oxide_engine::math::Vec3, + u: oxide_engine::math::Vec3, + v: oxide_engine::math::Vec3, + r: f32, + range: (f32, f32), + segs: usize, +) { + let (a0, a1) = range; + let segs = segs.max(1); + let point = |t: f32| c + u * (r * t.cos()) + v * (r * t.sin()); + let mut prev = point(a0); + for i in 1..=segs { + let t = a0 + (a1 - a0) * (i as f32) / (segs as f32); + let p = point(t); + out.push((prev, p)); + prev = p; + } +} + +/// The screen color for an axis handle. Brighter when the handle is the +/// active drag target so the user sees what they're holding. +fn axis_color(axis: gizmo::Axis3, engaged: bool) -> egui::Color32 { + use egui::Color32; + use gizmo::Axis3; + let (r, g, b): (u8, u8, u8) = match axis { + Axis3::X => (210, 60, 60), + Axis3::Y => (60, 200, 60), + Axis3::Z => (60, 110, 230), + }; + if engaged { + // Lerp toward white for the engaged feedback. + Color32::from_rgb( + ((r as u16 + 255) / 2) as u8, + ((g as u16 + 255) / 2) as u8, + ((b as u16 + 255) / 2) as u8, + ) + } else { + Color32::from_rgb(r, g, b) + } +} + +/// A display label for layer `index`: its registered name, or `"Layer N"` for +/// an unnamed slot. Shared by the single-select `Layer` widget and the +/// multi-select `LayerMask` field widget. +fn layer_label(index: u32, reg: &LayerRegistry) -> String { + reg.name(index) + .map(String::from) + .unwrap_or_else(|| format!("Layer {index}")) +} + +/// A short summary of a [`LayerMask`] for a collapsed combo box: `"All"`, +/// `"None"`, or the comma-joined names of its set layers. +fn mask_summary(mask: LayerMask, reg: &LayerRegistry) -> String { + if mask == LayerMask::ALL { + return "All".to_string(); + } + if mask.is_empty() { + return "None".to_string(); + } + mask.iter() + .map(|i| layer_label(i, reg)) + .collect::>() + .join(", ") +} + +/// Adds or removes group `group` from `entity`'s [`Tags`], creating the `Tags` +/// component on first add and dropping it again when the last group is removed +/// (so an entity in no groups carries no empty marker). Structural, so — like +/// component add/remove/disable — it bypasses the undo stack for now. +fn apply_group_membership( + world: &mut oxide_engine::hecs::World, + entity: Entity, + group: &str, + member: bool, +) { + use oxide_engine::layer::Tags; + if member { + // Update in place if the component exists; otherwise create it. The + // borrow guard must be released (the `if let` scope ends) before we can + // `insert_one`, so the existence check returns a bool first. + let updated = if let Ok(mut tags) = world.get::<&mut Tags>(entity) { + tags.insert(group); + true + } else { + false + }; + if !updated { + let mut tags = Tags::new(); + tags.insert(group); + let _ = world.insert_one(entity, tags); + } + } else { + let now_empty = if let Ok(mut tags) = world.get::<&mut Tags>(entity) { + tags.remove(group); + tags.is_empty() + } else { + false + }; + if now_empty { + let _ = world.remove_one::(entity); + } + } +} + +fn vec3_drag(ui: &mut egui::Ui, label: &str, v: &mut Vec3, speed: f64) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + ui.label(label); + for (axis, value) in [("x", &mut v.x), ("y", &mut v.y), ("z", &mut v.z)] { + changed |= ui + .add(egui::DragValue::new(value).speed(speed).prefix(axis)) + .changed(); + } + }); + changed +} + +/// The node-baked component types — every entity inherently carries these, +/// they're single-instance, and the inspector renders them in a fixed +/// canonical order with no enable / remove / drag-reorder controls. +/// +/// Maintainer's distinction: these are part of *being a node*, not a feature +/// you add or remove. Modular components (`MeshRenderer`, future colliders / +/// scripts / …) are everything else. +const ESSENTIAL_COMPONENTS: &[&str] = &["Node", "Transform", "Layer"]; + +fn is_essential_component(name: &str) -> bool { + ESSENTIAL_COMPONENTS.contains(&name) +} + +/// Moves `dragged` to be just before (`place_before = true`) or just after +/// (`place_before = false`) `target` in the component order. Returns the new +/// order. Extracted from `reflected_components` so the index-juggling can be +/// unit-tested directly. +fn reorder_component_list( + mut order: Vec<&'static str>, + dragged: &'static str, + target: &'static str, + place_before: bool, +) -> Vec<&'static str> { + // Dropping a row onto itself is a no-op (the GUI excludes this anyway; + // guarding here keeps the helper composable for tests + future callers). + if dragged == target { + return order; + } + if let Some(from) = order.iter().position(|n| *n == dragged) { + order.remove(from); + } + let mut to = order + .iter() + .position(|n| *n == target) + .unwrap_or(order.len()); + if !place_before { + to += 1; + } + to = to.min(order.len()); + order.insert(to, dragged); + order +} + +/// Toggles a component's disabled flag on `entity`'s [`DisabledComponents`], +/// creating the component lazily and removing it again when no entries remain +/// (so the entity doesn't carry an empty marker). +fn apply_component_disable( + world: &mut oxide_engine::hecs::World, + entity: Entity, + type_name: &str, + disabled: bool, +) { + let already = world.get::<&DisabledComponents>(entity).is_ok(); + if already { + let mut d = world.get::<&mut DisabledComponents>(entity).unwrap(); + d.set_disabled(type_name, disabled); + if d.is_empty() { + drop(d); + let _ = world.remove_one::(entity); + } + } else if disabled { + let mut d = DisabledComponents::new(); + d.set_disabled(type_name, true); + let _ = world.insert_one(entity, d); + } +} + +/// Orders the components currently on an entity for the inspector: +/// +/// 1. `Transform` always renders first (it's the canonical positional row, and +/// every entity has one). +/// 2. Then any components named in `saved` order, in their saved order — +/// skipping any that are no longer present. +/// 3. Then any present component that isn't in `saved` yet, appended in +/// whatever order the registry returned it (so components inserted outside +/// the inspector — e.g. by a script or `set_ron` — still show up). +/// +/// Extracted so it's straightforwardly unit-testable; the inspector calls this +/// with the registry's `components_on` list (minus `Node`) and the entity's +/// saved order from [`EditorState::component_order`]. +fn inspector_component_order( + present: &[&'static str], + saved: &[&'static str], +) -> Vec<&'static str> { + let mut remaining: Vec<&'static str> = present.to_vec(); + let mut out: Vec<&'static str> = Vec::with_capacity(remaining.len()); + if let Some(i) = remaining.iter().position(|n| *n == "Transform") { + out.push(remaining.remove(i)); + } + for name in saved { + if let Some(i) = remaining.iter().position(|n| *n == *name) { + out.push(remaining.remove(i)); + } + } + out.extend(remaining); + out +} + +/// Where a hierarchy drag-and-drop will drop, relative to the row under the +/// pointer: above it (reorder before), below it (reorder after), onto it (nest +/// as a child), or past the last row (append to the root level). +#[derive(Clone, Copy)] +enum DropZone { + Before, + After, + Child, + RootEnd, +} + +/// A flattened hierarchy row, snapshotted each frame so the tree can be drawn +/// without holding a borrow on the scene. +struct Row { + entity: Entity, + depth: usize, + name: String, + /// The node's own authored flag (what the checkbox shows/edits). + enabled: bool, + /// Whether the node is enabled *and* every ancestor is — drives the greyed + /// styling so a disabled parent visibly dims its whole subtree. + effective_enabled: bool, +} + +fn snapshot(scene: &Scene) -> Vec { + let mut rows = Vec::with_capacity(scene.len()); + for &root in scene.roots() { + snapshot_node(scene, root, 0, true, &mut rows); + } + rows +} + +fn snapshot_node( + scene: &Scene, + entity: Entity, + depth: usize, + parent_effective: bool, + rows: &mut Vec, +) { + let enabled = scene.is_enabled(entity).unwrap_or(true); + let effective_enabled = parent_effective && enabled; + rows.push(Row { + entity, + depth, + name: scene.name(entity).unwrap_or_default(), + enabled, + effective_enabled, + }); + for &child in scene.children(entity) { + snapshot_node(scene, child, depth + 1, effective_enabled, rows); + } +} + +/// Renders the typed asset folders (one collapsing section per [`AssetKind`]) +/// from the database, listing each asset by its name. Drop a file into the +/// matching folder on disk to import it; the watcher (or the Rescan button) +/// registers it. +/// The colour for a console log line of the given severity: errors red, warnings +/// amber, info the normal text colour, debug/trace dimmed. +fn level_color(ui: &egui::Ui, level: log::Level) -> egui::Color32 { + let visuals = ui.visuals(); + match level { + log::Level::Error => egui::Color32::from_rgb(0xFF, 0x6B, 0x6B), + log::Level::Warn => egui::Color32::from_rgb(0xFF, 0xC1, 0x07), + log::Level::Info => visuals.text_color(), + log::Level::Debug | log::Level::Trace => visuals.weak_text_color(), + } +} + +/// Builds a monospace [`LayoutJob`](egui::text::LayoutJob) for a terminal +/// screen: one glyph per cell with its foreground colour and (non-default) +/// background, rows separated by newlines, and the cursor cell inverted. Column +/// alignment relies on the monospace font. +fn build_terminal_job( + screen: &vt100::Screen, + font: &egui::FontId, + default_fg: egui::Color32, +) -> egui::text::LayoutJob { + use egui::text::{LayoutJob, TextFormat}; + + let (rows, cols) = screen.size(); + let (cur_row, cur_col) = screen.cursor_position(); + let mut job = LayoutJob::default(); + job.wrap.max_width = f32::INFINITY; + + for row in 0..rows { + for col in 0..cols { + let cell = screen.cell(row, col); + let glyph = match cell.map(|c| c.contents()) { + Some(s) if !s.is_empty() => s.to_string(), + _ => " ".to_string(), + }; + let mut fg = cell + .map(|c| crate::pty::vt_color(c.fgcolor(), default_fg)) + .unwrap_or(default_fg); + let mut bg = cell.and_then(|c| match c.bgcolor() { + vt100::Color::Default => None, + other => Some(crate::pty::vt_color(other, egui::Color32::TRANSPARENT)), + }); + // Invert the cursor cell so the caret is visible. + if row == cur_row && col == cur_col { + bg = Some(default_fg); + fg = egui::Color32::BLACK; + } + let mut fmt = TextFormat { + font_id: font.clone(), + color: fg, + ..Default::default() + }; + if let Some(bg) = bg { + fmt.background = bg; + } + job.append(&glyph, 0.0, fmt); + } + job.append( + "\n", + 0.0, + TextFormat { + font_id: font.clone(), + color: default_fg, + ..Default::default() + }, + ); + } + job +} + +fn asset_browser(ui: &mut egui::Ui, db: &AssetDatabase) { + for kind in AssetKind::TYPED { + let mut entries: Vec<&AssetEntry> = db.entries_of_kind(kind).collect(); + entries.sort_by(|a, b| a.path.cmp(&b.path)); + let header = format!("{}/ ({})", kind.folder(), entries.len()); + egui::CollapsingHeader::new(header) + .id_salt(("oxide.assets", kind.folder())) + .show(ui, |ui| { + if entries.is_empty() { + ui.weak("(empty — drop files here)"); + } + for entry in entries { + // Show the leaf name; the full relative path on hover. + let leaf = entry.path.rsplit('/').next().unwrap_or(&entry.path); + ui.label(leaf).on_hover_text(&entry.path); + } + }); + } +} + +/// A short label for a widget in the tree / property header: its id if set, +/// else its kind, with the root marked. +fn widget_label(widget: &Widget, path: &WidgetPath) -> String { + let kind = match &widget.kind { + WidgetKind::Leaf { .. } => "Leaf", + WidgetKind::Stack(s) => match s.direction { + oxide_engine::ui::StackDirection::Row => "Row", + oxide_engine::ui::StackDirection::Column => "Column", + }, + WidgetKind::Grid(_) => "Grid", + WidgetKind::Anchor(_) => "Anchor", + }; + let name = if widget.id.is_empty() { + kind.to_owned() + } else { + format!("{} ({kind})", widget.id.as_str()) + }; + if path.is_root() { + format!("{name} ⌂") + } else { + name + } +} + +/// Renders the widget tree as selectable, indented rows. Sets `selection` to a +/// clicked node's path. +fn ui_widget_tree( + ui: &mut egui::Ui, + widget: &Widget, + path: WidgetPath, + selected: &WidgetPath, + selection: &mut Option, +) { + let label = widget_label(widget, &path); + if ui.selectable_label(*selected == path, label).clicked() { + *selection = Some(path.clone()); + } + ui.indent(("oxide.uitree", path.0.clone()), |ui| { + for (i, child) in widget.children().iter().enumerate() { + ui_widget_tree(ui, child, path.child(i), selected, selection); + } + }); +} + +/// Converts an engine [`Color`] to an egui color. +fn color32(c: Color) -> egui::Color32 { + let to = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + egui::Color32::from_rgba_unmultiplied(to(c.r), to(c.g), to(c.b), to(c.a)) +} + +/// Draws a rectangle outline as four line segments (avoids depending on a +/// specific `rect_stroke` signature across egui versions). +fn stroke_rect(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) { + let tl = rect.left_top(); + let tr = rect.right_top(); + let br = rect.right_bottom(); + let bl = rect.left_bottom(); + for [a, b] in [[tl, tr], [tr, br], [br, bl], [bl, tl]] { + painter.line_segment([a, b], stroke); + } +} + +/// A checkbox + color button for an `Option` visual field. Returns +/// whether it changed. +fn optional_color(ui: &mut egui::Ui, label: &str, slot: &mut Option) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + let mut on = slot.is_some(); + if ui.checkbox(&mut on, label).changed() { + *slot = on.then_some(Color::WHITE); + changed = true; + } + if let Some(c) = slot { + let mut rgba = [c.r, c.g, c.b, c.a]; + if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() { + *c = Color::rgba(rgba[0], rgba[1], rgba[2], rgba[3]); + changed = true; + } + } + }); + changed +} + +/// A `Sizing` editor: a mode dropdown plus a value drag for `Fixed`/`Grow`. +/// Returns whether it changed. +fn sizing_editor(ui: &mut egui::Ui, label: &str, sizing: &mut UiSizing, salt: usize) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + ui.label(label); + let mode = match sizing { + UiSizing::Fixed(_) => "Fixed", + UiSizing::Grow(_) => "Grow", + UiSizing::FitContent => "FitContent", + }; + egui::ComboBox::from_id_salt(("oxide.uicanvas.sizing", label, salt)) + .selected_text(mode) + .show_ui(ui, |ui| { + if ui.selectable_label(mode == "Fixed", "Fixed").clicked() && mode != "Fixed" { + *sizing = UiSizing::Fixed(64.0); + changed = true; + } + if ui.selectable_label(mode == "Grow", "Grow").clicked() && mode != "Grow" { + *sizing = UiSizing::Grow(1.0); + changed = true; + } + if ui + .selectable_label(mode == "FitContent", "FitContent") + .clicked() + && mode != "FitContent" + { + *sizing = UiSizing::FitContent; + changed = true; + } + }); + match sizing { + UiSizing::Fixed(v) => { + if ui + .add(egui::DragValue::new(v).range(0.0..=4096.0)) + .changed() + { + changed = true; + } + } + UiSizing::Grow(w) => { + if ui + .add(egui::DragValue::new(w).speed(0.1).range(0.0..=100.0)) + .changed() + { + changed = true; + } + } + UiSizing::FitContent => {} + } + }); + changed +} + +/// Type-specific parameters for the selected widget's [`WidgetKind`] — this is +/// what distinguishes a Grid from a Stack from a Leaf in the inspector. Returns +/// whether anything changed. +fn kind_properties(ui: &mut egui::Ui, kind: &mut WidgetKind, salt: &WidgetPath) -> bool { + let mut changed = false; + match kind { + WidgetKind::Leaf { intrinsic } => { + ui.horizontal(|ui| { + ui.label("intrinsic size"); + changed |= ui + .add( + egui::DragValue::new(&mut intrinsic.x) + .prefix("w ") + .range(0.0..=4096.0), + ) + .changed(); + changed |= ui + .add( + egui::DragValue::new(&mut intrinsic.y) + .prefix("h ") + .range(0.0..=4096.0), + ) + .changed(); + }); + } + WidgetKind::Stack(s) => { + ui.horizontal(|ui| { + ui.label("direction"); + let text = match s.direction { + UiStackDirection::Row => "Row", + UiStackDirection::Column => "Column", + }; + egui::ComboBox::from_id_salt(("oxide.uicanvas.dir", salt.0.clone())) + .selected_text(text) + .show_ui(ui, |ui| { + for (label, dir) in [ + ("Row", UiStackDirection::Row), + ("Column", UiStackDirection::Column), + ] { + if ui.selectable_label(s.direction == dir, label).clicked() + && s.direction != dir + { + s.direction = dir; + changed = true; + } + } + }); + }); + ui.horizontal(|ui| { + ui.label("gap"); + changed |= ui + .add(egui::DragValue::new(&mut s.gap).range(0.0..=512.0)) + .changed(); + }); + changed |= align_combo(ui, "main align", &mut s.main_align, 2); + } + WidgetKind::Grid(g) => { + ui.horizontal(|ui| { + ui.label("cols"); + changed |= ui + .add(egui::DragValue::new(&mut g.cols).range(1..=64)) + .changed(); + ui.label("rows"); + changed |= ui + .add(egui::DragValue::new(&mut g.rows).range(1..=64)) + .changed(); + }); + ui.horizontal(|ui| { + ui.label("gap"); + changed |= ui + .add( + egui::DragValue::new(&mut g.gap.x) + .prefix("x ") + .range(0.0..=512.0), + ) + .changed(); + changed |= ui + .add( + egui::DragValue::new(&mut g.gap.y) + .prefix("y ") + .range(0.0..=512.0), + ) + .changed(); + }); + } + WidgetKind::Anchor(_) => { + ui.weak("Anchor container: each child is placed by its own anchor."); + } + } + changed +} + +/// A three-way [`Align`](oxide_engine::ui::Align) dropdown (Start/Center/End). +fn align_combo(ui: &mut egui::Ui, label: &str, align: &mut UiAlign, salt: usize) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + ui.label(label); + let text = match align { + UiAlign::Start => "Start", + UiAlign::Center => "Center", + UiAlign::End => "End", + }; + egui::ComboBox::from_id_salt(("oxide.uicanvas.align", label, salt)) + .selected_text(text) + .show_ui(ui, |ui| { + for (name, value) in [ + ("Start", UiAlign::Start), + ("Center", UiAlign::Center), + ("End", UiAlign::End), + ] { + if ui.selectable_label(*align == value, name).clicked() && *align != value { + *align = value; + changed = true; + } + } + }); + }); + changed +} + +/// Editor for the four sides of an [`Insets`](oxide_engine::ui::Insets) value. +fn insets_editor(ui: &mut egui::Ui, label: &str, insets: &mut UiInsets, salt: usize) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + ui.label(label); + for (prefix, value) in [ + ("l", &mut insets.left), + ("r", &mut insets.right), + ("t", &mut insets.top), + ("b", &mut insets.bottom), + ] { + let _ = salt; + changed |= ui + .add( + egui::DragValue::new(value) + .prefix(prefix) + .range(0.0..=512.0), + ) + .changed(); + } + }); + changed +} + +/// A dropdown of the standard [`Anchor`](oxide_engine::ui::Anchor) presets +/// (Fill / corners / edges / center). Shows "Custom" if the current value +/// matches no preset. Anchor placement only takes effect under an Anchor parent. +fn anchor_combo(ui: &mut egui::Ui, anchor: &mut UiAnchor, salt: &WidgetPath) -> bool { + let center = UiAnchor { + min: Vec2::splat(0.5), + max: Vec2::splat(0.5), + offset_min: Vec2::ZERO, + offset_max: Vec2::ZERO, + }; + let presets: [(&str, UiAnchor); 10] = [ + ("Fill", UiAnchor::FILL), + ("Top-Left", UiAnchor::TOP_LEFT), + ("Top", UiAnchor::TOP), + ("Top-Right", UiAnchor::TOP_RIGHT), + ("Left", UiAnchor::LEFT), + ("Center", center), + ("Right", UiAnchor::RIGHT), + ("Bottom-Left", UiAnchor::BOTTOM_LEFT), + ("Bottom", UiAnchor::BOTTOM), + ("Bottom-Right", UiAnchor::BOTTOM_RIGHT), + ]; + let current = presets + .iter() + .find(|(_, a)| a == anchor) + .map(|(n, _)| *n) + .unwrap_or("Custom"); + let mut changed = false; + ui.horizontal(|ui| { + ui.label("anchor"); + egui::ComboBox::from_id_salt(("oxide.uicanvas.anchor", salt.0.clone())) + .selected_text(current) + .show_ui(ui, |ui| { + for (name, value) in presets { + if ui.selectable_label(current == name, name).clicked() { + *anchor = value; + changed = true; + } + } + }); + }); + changed +} + +/// Paints a scaled-to-fit preview of the panel into the available space using +/// egui's painter. Backgrounds/borders are drawn faithfully; text uses egui's +/// font (engine-font-accurate preview via the real `UiOverlayPass` is a noted +/// follow-up). +fn draw_ui_preview(ui: &mut egui::Ui, panel: &UiPanel) { + let avail_w = ui.available_width().max(16.0); + let aspect = if panel.pixel_size.x > 0.0 { + panel.pixel_size.y / panel.pixel_size.x + } else { + 0.5625 + }; + let height = (avail_w * aspect).clamp(120.0, 360.0); + let (resp, painter) = ui.allocate_painter(egui::vec2(avail_w, height), egui::Sense::hover()); + let area = resp.rect; + if panel.pixel_size.x <= 0.0 || panel.pixel_size.y <= 0.0 { + return; + } + let scale = (area.width() / panel.pixel_size.x).min(area.height() / panel.pixel_size.y); + let draw = egui::vec2(panel.pixel_size.x * scale, panel.pixel_size.y * scale); + let origin = egui::pos2( + area.center().x - draw.x / 2.0, + area.center().y - draw.y / 2.0, + ); + // Panel bounds. + stroke_rect( + &painter, + egui::Rect::from_min_size(origin, draw), + egui::Stroke::new(1.0, egui::Color32::DARK_GRAY), + ); + + let theme = UiTheme::new(); + let viewport = Rect::from_min_size(Vec2::ZERO, panel.pixel_size); + let tree = ui_layout(&panel.root, viewport, 1.0); + draw_preview_node(&painter, origin, scale, &theme, &panel.root, &tree, 0); +} + +/// Recursively paints one widget (background, border, text) and its children +/// into the preview, mirroring `oxide_engine::ui::paint`'s structure. +fn draw_preview_node( + painter: &egui::Painter, + origin: egui::Pos2, + scale: f32, + theme: &UiTheme, + widget: &Widget, + tree: &oxide_engine::ui::LayoutTree, + index: usize, +) { + let node = &tree.nodes()[index]; + let resolved = widget.resolve_visual(theme); + let to_screen = |r: Rect| { + egui::Rect::from_min_max( + egui::pos2(origin.x + r.min.x * scale, origin.y + r.min.y * scale), + egui::pos2(origin.x + r.max.x * scale, origin.y + r.max.y * scale), + ) + }; + if let Some(bg) = resolved.background { + if !node.rect.is_empty() { + painter.rect_filled(to_screen(node.rect), 0.0, color32(bg)); + } + } + if let Some(border) = resolved.border { + if border.width > 0.0 { + stroke_rect( + painter, + to_screen(node.rect), + egui::Stroke::new((border.width * scale).max(1.0), color32(border.color)), + ); + } + } + if let Some(text) = &widget.text { + let color = resolved + .foreground + .map(color32) + .unwrap_or(egui::Color32::WHITE); + let size = (resolved.font_size.unwrap_or(14.0) * scale).max(4.0); + let rect = to_screen(node.content_rect); + painter.text( + rect.left_top(), + egui::Align2::LEFT_TOP, + text, + egui::FontId::proportional(size), + color, + ); + } + for (child, &child_index) in widget.children().iter().zip(node.children.iter()) { + draw_preview_node( + painter, + origin, + scale, + theme, + child, + tree, + child_index as usize, + ); + } +} + +fn show_project_tree(ui: &mut egui::Ui, label: &str, root: PathBuf) { + ui.collapsing(label, |ui| { + let Ok(entries) = std::fs::read_dir(&root) else { + ui.weak("(unreadable)"); + return; + }; + let mut names: Vec = entries + .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned())) + .collect(); + names.sort(); + if names.is_empty() { + ui.weak("(empty)"); + } + for name in names { + ui.label(name); + } + }); +} + +/// A small starter scene so the dock has something to show on launch. +fn starter_scene() -> Scene { + let mut scene = Scene::new(); + let world = scene.spawn("world", Transform::IDENTITY); + let player = scene.spawn_child( + world, + "player", + Transform::from_translation(Vec3::new(0.0, 1.0, 0.0)), + ); + scene.spawn_child( + player, + "camera", + Transform::from_translation(Vec3::new(0.0, 1.6, 0.0)), + ); + + let level = scene.spawn_child(world, "level", Transform::IDENTITY); + + let ground = scene.spawn_child( + level, + "ground", + Transform::from_scale(Vec3::new(20.0, 1.0, 20.0)), + ); + attach_mesh( + &mut scene, + ground, + PrimitiveShape::Plane, + Material::diffuse(Color::rgb(0.28, 0.30, 0.33)), + ); + + let crate_e = scene.spawn_child( + level, + "prop_crate", + Transform::from_translation(Vec3::new(2.0, 0.5, 1.0)), + ); + attach_mesh( + &mut scene, + crate_e, + PrimitiveShape::Cube, + Material::diffuse(Color::rgb(0.80, 0.40, 0.15)), + ); + + let ball = scene.spawn_child( + level, + "ball", + Transform::from_trs(Vec3::new(-1.5, 0.6, 0.0), Quat::IDENTITY, Vec3::splat(0.6)), + ); + attach_mesh( + &mut scene, + ball, + PrimitiveShape::Sphere, + Material::metal(Color::rgb(0.55, 0.65, 0.95), 0.3), + ); + + scene +} + +fn attach_mesh(scene: &mut Scene, entity: Entity, shape: PrimitiveShape, material: Material) { + let _ = scene + .world_mut() + .insert_one(entity, MeshRenderer::with_material(shape, material)); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::SetTransformCmd; + + #[test] + fn shell_starts_with_open_project_none() { + let shell = Shell::new(); + assert!(shell.state.project.is_none()); + assert!(!shell.commands.can_undo()); + // Default layout includes the five built-in panel kinds. + let titles: Vec<&str> = shell.dock.iter_all_tabs().map(|(_, t)| t.title()).collect(); + assert!(titles.contains(&"Hierarchy")); + assert!(titles.contains(&"Inspector")); + assert!(titles.contains(&"Viewport")); + assert!(titles.contains(&"Project")); + assert!(titles.contains(&"Console")); + } + + #[test] + fn inspector_order_pins_transform_first_then_saved_order_then_appends_rest() { + // Transform always first, even when not first in `present`. + let present = ["MeshRenderer", "Timer", "Transform"]; + let saved = ["Timer", "MeshRenderer"]; + assert_eq!( + inspector_component_order(&present, &saved), + vec!["Transform", "Timer", "MeshRenderer"] + ); + + // A saved entry that's no longer present is silently skipped. + let saved_stale = ["Timer", "MeshRenderer", "Ghost"]; + assert_eq!( + inspector_component_order(&present, &saved_stale), + vec!["Transform", "Timer", "MeshRenderer"] + ); + + // A present component not in `saved` (e.g. inserted by a script via + // set_ron) is appended after the saved order. + let present_extra = ["Transform", "Timer", "MeshRenderer", "RigidBody"]; + let saved2 = ["Timer", "MeshRenderer"]; + assert_eq!( + inspector_component_order(&present_extra, &saved2), + vec!["Transform", "Timer", "MeshRenderer", "RigidBody"] + ); + + // Empty saved + only Transform present. + assert_eq!( + inspector_component_order(&["Transform"], &[]), + vec!["Transform"] + ); + } + + #[test] + fn duplicate_preserves_inspector_component_order() { + // Source has Transform + MeshRenderer with a specific saved order; + // Duplicate carries that order over so the copy shows components in + // the same arrangement instead of reverting to registry-default. + let mut shell = Shell::from_state(EditorState::new()); + let src = shell.state.scene.spawn("src", Transform::IDENTITY); + shell + .state + .scene + .world_mut() + .insert_one(src, MeshRenderer::new(PrimitiveShape::Cube)) + .unwrap(); + shell + .state + .component_order + .insert(src, vec!["MeshRenderer"]); + + shell.apply(PendingAction::Duplicate(src)); + let new = shell.state.selected.unwrap(); + assert_eq!( + shell.state.component_order.get(&new).map(Vec::as_slice), + Some(["MeshRenderer"].as_slice()) + ); + } + + #[test] + fn essential_component_classifier_pins_node_transform_layers() { + for &name in &["Node", "Transform", "Layer"] { + assert!(is_essential_component(name), "{name} should be essential"); + } + // Modular components — every real one needs to be addable / removable + // / reorderable. + for &name in &["MeshRenderer", "RigidBody", "Tags", "MyScript"] { + assert!( + !is_essential_component(name), + "{name} must not be classified essential" + ); + } + } + + #[test] + fn layer_label_uses_registry_name_or_indexed_fallback() { + let mut reg = LayerRegistry::new(); + reg.set(2, "Player"); + assert_eq!(layer_label(0, ®), "Default"); // seeded by LayerRegistry + assert_eq!(layer_label(2, ®), "Player"); + assert_eq!(layer_label(7, ®), "Layer 7"); // unnamed → indexed + } + + #[test] + fn mask_summary_reports_all_none_and_named_layers() { + let mut reg = LayerRegistry::new(); + reg.set(1, "UI"); + reg.set(2, "Player"); + assert_eq!(mask_summary(LayerMask::ALL, ®), "All"); + assert_eq!(mask_summary(LayerMask::NONE, ®), "None"); + let mask = LayerMask::NONE.with(1).with(2).with(5); + assert_eq!(mask_summary(mask, ®), "UI, Player, Layer 5"); + } + + #[test] + fn group_membership_creates_toggles_and_drops_tags() { + use oxide_engine::layer::Tags; + let mut scene = Scene::new(); + let e = scene.spawn("e", Transform::IDENTITY); + // Tags is lazily attached — absent until the first group is added. + assert!(scene.world().get::<&Tags>(e).is_err()); + + apply_group_membership(scene.world_mut(), e, "Enemies", true); + apply_group_membership(scene.world_mut(), e, "Pickups", true); + { + let tags = scene.world().get::<&Tags>(e).unwrap(); + assert!(tags.contains("Enemies")); + assert!(tags.contains("Pickups")); + } + // Removing one keeps the component; removing the last drops it so an + // entity in no groups carries no empty marker. + apply_group_membership(scene.world_mut(), e, "Enemies", false); + assert!(scene.world().get::<&Tags>(e).unwrap().contains("Pickups")); + apply_group_membership(scene.world_mut(), e, "Pickups", false); + assert!(scene.world().get::<&Tags>(e).is_err()); + } + + #[test] + fn editor_seeds_default_layers_and_several_addable_components() { + let state = EditorState::new(); + // The common starter layer names exist (besides "Default"). + assert_eq!(state.layer_registry.name(1), Some("UI")); + assert_eq!(state.layer_registry.name(2), Some("Player")); + assert_eq!(state.layer_registry.name(3), Some("World")); + // Several distinct modular components are addable, so more than one can + // be attached to a single node and drag-reordered. + let addable: Vec<_> = state.registry.addable_names().collect(); + assert!(addable.contains(&"MeshRenderer")); + assert!(addable.contains(&"Camera")); + assert!(addable.contains(&"DirectionalLight")); + } + + #[test] + fn editor_seeds_builtin_prefabs() { + let state = EditorState::new(); + let names: Vec<_> = state.prefab_registry.names().collect(); + for expected in [ + "Empty", + "Cube", + "Sphere", + "Plane", + "Camera", + "Directional Light", + ] { + assert!(names.contains(&expected), "{expected} prefab missing"); + } + // Built-in prefabs reference only registered component types. + assert!(state + .prefab_registry + .unknown_specs("Cube", &state.registry) + .is_empty()); + assert!(state + .prefab_registry + .unknown_specs("Directional Light", &state.registry) + .is_empty()); + } + + #[test] + fn add_root_prefab_spawns_entity_with_components_and_selects_it() { + let mut shell = Shell::from_state(EditorState::new()); + shell.apply(PendingAction::AddRootPrefab("Cube".to_string())); + let e = shell.state.selected.expect("spawned entity is selected"); + // Carries the prefab's MeshRenderer and takes the prefab's node name. + assert!(shell + .state + .registry + .has(shell.state.scene.world(), e, "MeshRenderer") + .unwrap()); + assert_eq!(shell.state.scene.name(e).as_deref(), Some("Cube")); + } + + #[test] + fn add_child_prefab_parents_under_target() { + let mut shell = Shell::from_state(EditorState::new()); + shell.apply(PendingAction::AddRootPrefab("Empty".to_string())); + let parent = shell.state.selected.unwrap(); + shell.apply(PendingAction::AddChildPrefab(parent, "Camera".to_string())); + let child = shell.state.selected.unwrap(); + assert_eq!(shell.state.scene.parent(child), Some(parent)); + assert!(shell + .state + .registry + .has(shell.state.scene.world(), child, "Camera") + .unwrap()); + } + + #[test] + fn reorder_component_list_handles_all_directions() { + // Move B before A (forward → backward). + assert_eq!( + reorder_component_list(vec!["A", "B", "C"], "B", "A", true), + vec!["B", "A", "C"] + ); + // Move A after C (backward → forward, three-way reshuffle). + assert_eq!( + reorder_component_list(vec!["A", "B", "C"], "A", "C", false), + vec!["B", "C", "A"] + ); + // Move B after A is a no-op (it's already there). + assert_eq!( + reorder_component_list(vec!["A", "B", "C"], "B", "A", false), + vec!["A", "B", "C"] + ); + // Move B before B (dropping on itself, sanity): same list returns. + assert_eq!( + reorder_component_list(vec!["A", "B", "C"], "B", "B", true), + vec!["A", "B", "C"] + ); + } + + #[test] + fn apply_component_disable_creates_and_clears_marker_component() { + let mut shell = Shell::from_state(EditorState::new()); + let e = shell.state.scene.spawn("e", Transform::IDENTITY); + let world = shell.state.scene.world_mut(); + + // Disabling adds the marker component with one entry. + apply_component_disable(world, e, "MeshRenderer", true); + let d = world.get::<&DisabledComponents>(e).unwrap(); + assert!(d.is_disabled("MeshRenderer")); + drop(d); + + // Re-enabling removes the only entry → the marker is dropped entirely + // so the entity isn't carrying empty metadata. + apply_component_disable(world, e, "MeshRenderer", false); + assert!(world.get::<&DisabledComponents>(e).is_err()); + } + + #[test] + fn duplicate_copies_per_component_disable_set() { + let mut shell = Shell::from_state(EditorState::new()); + let src = shell.state.scene.spawn("src", Transform::IDENTITY); + shell + .state + .scene + .world_mut() + .insert_one(src, MeshRenderer::new(PrimitiveShape::Cube)) + .unwrap(); + apply_component_disable(shell.state.scene.world_mut(), src, "MeshRenderer", true); + + shell.apply(PendingAction::Duplicate(src)); + let new = shell.state.selected.unwrap(); + assert!(shell.state.scene.is_component_disabled(new, "MeshRenderer")); + } + + #[test] + fn delete_clears_inspector_component_order() { + let mut shell = Shell::from_state(EditorState::new()); + let e = shell.state.scene.spawn("e", Transform::IDENTITY); + shell.state.component_order.insert(e, vec!["MeshRenderer"]); + + shell.apply(PendingAction::Delete(e)); + assert!(!shell.state.component_order.contains_key(&e)); + } + + #[test] + fn duplicate_copies_registered_components_like_mesh_renderer() { + // Regression: until MeshRenderer was registered in the editor's + // registry, Duplicate produced a "node copy" with no mesh. + let mut shell = Shell::from_state(EditorState::new()); + let cube = shell.state.scene.spawn("cube", Transform::IDENTITY); + shell + .state + .scene + .world_mut() + .insert_one(cube, MeshRenderer::new(PrimitiveShape::Sphere)) + .unwrap(); + + shell.apply(PendingAction::Duplicate(cube)); + + let new = shell.state.selected.expect("duplicate selects the copy"); + assert_eq!(shell.state.scene.name(new).as_deref(), Some("cube copy")); + let mr = shell + .state + .scene + .get::(new) + .expect("MeshRenderer copied"); + assert_eq!(mr.shape, PrimitiveShape::Sphere); + } + + #[test] + fn duplicate_clones_an_entity_as_a_sibling_with_components() { + let mut shell = Shell::from_state(EditorState::new()); + let parent = shell.state.scene.spawn("parent", Transform::IDENTITY); + let child = shell.state.scene.spawn_child( + parent, + "child", + Transform::from_translation(Vec3::new(1.0, 2.0, 3.0)), + ); + let before = shell.state.scene.len(); + + shell.apply(PendingAction::Duplicate(child)); + + // One new entity, selected, named " copy", same parent + transform. + assert_eq!(shell.state.scene.len(), before + 1); + let new = shell.state.selected.expect("duplicate selects the copy"); + assert_ne!(new, child); + assert_eq!(shell.state.scene.name(new).as_deref(), Some("child copy")); + assert_eq!(shell.state.scene.parent(new), Some(parent)); + assert_eq!( + shell.state.scene.local_transform(new).unwrap().translation, + Vec3::new(1.0, 2.0, 3.0) + ); + } + + #[test] + fn ctrl_s_without_project_is_a_no_op_but_handled() { + let mut shell = Shell::new(); + // Handled = true; the shortcut belongs to the shell even when there's + // nothing to save (avoids the OS bell + lets us show a status hint). + assert!(shell.try_consume_shortcut(true, false, Some('s'))); + assert!(shell.state.project.is_none()); + } + + #[test] + fn ctrl_comma_toggles_preferences() { + let mut shell = Shell::new(); + assert!(!shell.show_preferences); + assert!(shell.try_consume_shortcut(true, false, Some(','))); + assert!(shell.show_preferences); + assert!(shell.try_consume_shortcut(true, false, Some(','))); + assert!(!shell.show_preferences); + } + + #[test] + fn take_quit_request_consumes_the_flag_once() { + // The host runner polls this each frame; once observed the flag + // resets, so an accidental double-poll wouldn't exit twice. + let mut shell = Shell::new(); + assert!(!shell.take_quit_request()); + shell.quit_requested = true; + assert!(shell.take_quit_request()); + assert!(!shell.take_quit_request()); + } + + #[test] + fn ctrl_z_y_drive_undo_redo() { + let mut shell = Shell::new(); + let e = shell.state.scene.spawn("x", Transform::IDENTITY); + let cmd = SetTransformCmd::new( + &shell.state, + e, + Transform::from_translation(Vec3::splat(1.0)), + ) + .unwrap(); + shell.commands.push(cmd, &mut shell.state); + assert!(shell.commands.can_undo()); + + assert!(shell.try_consume_shortcut(true, false, Some('z'))); + assert!(!shell.commands.can_undo()); + assert!(shell.commands.can_redo()); + + assert!(shell.try_consume_shortcut(true, false, Some('y'))); + assert!(shell.commands.can_undo()); + } + + #[test] + fn open_project_records_recent_and_clears_undo() { + // Round-trip through a tempdir: create_project then close_project + // exercises the watcher attach/detach path without GUI. + let mut root = std::env::temp_dir(); + root.push(format!("oxide_shell_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + + let mut shell = Shell::new(); + let e = shell.state.scene.spawn("x", Transform::IDENTITY); + let cmd = + SetTransformCmd::new(&shell.state, e, Transform::from_translation(Vec3::ONE)).unwrap(); + shell.commands.push(cmd, &mut shell.state); + assert!(shell.commands.can_undo()); + + shell.create_project(&root, "shell-test").expect("create"); + assert!(shell.state.project.is_some()); + assert_eq!(shell.state.recent.entries().len(), 1); + // Project open clears history — a half-done edit shouldn't be undoable + // across project boundaries. + assert!(!shell.commands.can_undo()); + + shell.close_project(); + assert!(shell.state.project.is_none()); + + std::fs::remove_dir_all(&root).ok(); + } + + // --- Piece 5: input-bindings capture state machine -------------------- + + use oxide_engine::input::ActionOverrides; + use oxide_engine::winit::keyboard::KeyCode as KC; + + #[test] + fn shell_registers_default_bindings_and_settings_section() { + let shell = Shell::new(); + assert!(shell + .state + .actions + .has(crate::bindings::action::TOGGLE_FLYTHROUGH)); + assert!(shell + .state + .actions + .has_axis(crate::bindings::action::MOVE_RIGHT)); + assert!( + shell + .state + .settings + .is_registered(crate::bindings::SETTINGS_SECTION), + "input.bindings settings section must be auto-registered by EditorState" + ); + } + + #[test] + fn capture_replaces_button_binding_and_marks_dirty() { + let mut shell = Shell::new(); + // Sanity: F is the default toggle binding. + assert_eq!( + shell + .state + .actions + .bindings(crate::bindings::action::TOGGLE_FLYTHROUGH), + &[Binding::Key(KC::KeyF)] + ); + assert!(!shell.bindings_dirty); + + shell.begin_capture(CaptureTarget::Button { + action: crate::bindings::action::TOGGLE_FLYTHROUGH.to_string(), + slot: BindingSlot::Replace(0), + }); + assert!(shell.capture_active()); + + // First frame after begin: no key pressed → still waiting. + let mut input = InputState::new(); + assert!(!shell.try_complete_capture(&input)); + assert!(shell.capture_active()); + + // Next frame: user presses Tab. + input.press_key(KC::Tab); + assert!(shell.try_complete_capture(&input)); + assert!(!shell.capture_active()); + assert_eq!( + shell + .state + .actions + .bindings(crate::bindings::action::TOGGLE_FLYTHROUGH), + &[Binding::Key(KC::Tab)] + ); + assert!(shell.bindings_dirty, "capture must flip the dirty flag"); + } + + #[test] + fn escape_cancels_capture_without_binding() { + let mut shell = Shell::new(); + shell.begin_capture(CaptureTarget::Button { + action: crate::bindings::action::TOGGLE_FLYTHROUGH.to_string(), + slot: BindingSlot::Replace(0), + }); + + let mut input = InputState::new(); + input.press_key(KC::Escape); + assert!(shell.try_complete_capture(&input)); + assert!(!shell.capture_active()); + // Original binding intact. + assert_eq!( + shell + .state + .actions + .bindings(crate::bindings::action::TOGGLE_FLYTHROUGH), + &[Binding::Key(KC::KeyF)] + ); + assert!(!shell.bindings_dirty); + } + + #[test] + fn append_capture_adds_binding_to_slot() { + let mut shell = Shell::new(); + shell.begin_capture(CaptureTarget::Button { + action: crate::bindings::action::TOGGLE_FLYTHROUGH.to_string(), + slot: BindingSlot::Append, + }); + + let mut input = InputState::new(); + input.press_key(KC::Tab); + shell.try_complete_capture(&input); + assert_eq!( + shell + .state + .actions + .bindings(crate::bindings::action::TOGGLE_FLYTHROUGH), + &[Binding::Key(KC::KeyF), Binding::Key(KC::Tab)] + ); + } + + #[test] + fn axis_capture_routes_to_the_correct_direction() { + let mut shell = Shell::new(); + shell.begin_capture(CaptureTarget::Axis { + action: crate::bindings::action::MOVE_RIGHT.to_string(), + side: AxisSide::Positive, + slot: BindingSlot::Append, + }); + + let mut input = InputState::new(); + input.press_key(KC::ArrowRight); + shell.try_complete_capture(&input); + + let axis = shell + .state + .actions + .axis_bindings(crate::bindings::action::MOVE_RIGHT) + .unwrap(); + assert!(axis.positive.contains(&Binding::Key(KC::ArrowRight))); + // Negative direction untouched. + assert_eq!(axis.negative, vec![Binding::Key(KC::KeyA)]); + } + + #[test] + fn restore_all_defaults_undoes_remap_and_marks_dirty() { + let mut shell = Shell::new(); + shell.state.actions.set_bindings( + crate::bindings::action::TOGGLE_FLYTHROUGH, + vec![Binding::Key(KC::Tab)], + ); + + shell.restore_default_bindings(); + assert_eq!( + shell + .state + .actions + .bindings(crate::bindings::action::TOGGLE_FLYTHROUGH), + &[Binding::Key(KC::KeyF)] + ); + assert!(shell.take_bindings_dirty()); + assert!(!shell.take_bindings_dirty(), "dirty flag is consume-once"); + } + + #[test] + fn capture_keeps_overrides_settings_section_in_sync() { + let mut shell = Shell::new(); + shell.begin_capture(CaptureTarget::Button { + action: crate::bindings::action::TOGGLE_FLYTHROUGH.to_string(), + slot: BindingSlot::Replace(0), + }); + let mut input = InputState::new(); + input.press_key(KC::Tab); + shell.try_complete_capture(&input); + + // The settings section now reflects the remap, so a Settings::export + // immediately after a capture captures the user's choice. + let overrides = shell + .state + .settings + .get::(crate::bindings::SETTINGS_SECTION) + .expect("overrides section is registered"); + assert_eq!( + overrides.get(crate::bindings::action::TOGGLE_FLYTHROUGH), + &[Binding::Key(KC::Tab)] + ); + } + + #[test] + fn play_controls_drive_play_state_and_clear_undo() { + let mut shell = Shell::from_state(EditorState::new()); + let e = shell.state.scene.spawn("thing", Transform::IDENTITY); + // Put something on the undo stack so we can prove play clears it. + let cmd = RenameCmd::new(&shell.state, e, "renamed".to_string()); + shell.push_command(cmd); + assert!(shell.commands.can_undo()); + + shell.play(); + assert_eq!(shell.state.play, PlayState::Playing); + assert!(shell.state.play_snapshot.is_some()); + // Entering play wipes the undo history (play edits never leak to edit). + assert!(!shell.commands.can_undo()); + + // Step is ignored while playing. + shell.request_step(); + assert!(!shell.take_step_request()); + + // Pause, then Step queues exactly one request. + shell.toggle_pause(); + assert_eq!(shell.state.play, PlayState::Paused); + shell.request_step(); + assert!(shell.take_step_request()); + assert!(!shell.take_step_request(), "step request is one-shot"); + + // The Play/Resume button resumes from pause (regression: it used to call + // play(), which no-ops while in play, leaving it stuck on Paused). + shell.play_or_resume(); + assert_eq!(shell.state.play, PlayState::Playing); + // While playing it's a no-op (the button is disabled in the UI too). + shell.play_or_resume(); + assert_eq!(shell.state.play, PlayState::Playing); + } + + #[test] + fn stop_restores_the_scene_and_returns_to_editing() { + let mut shell = Shell::from_state(EditorState::new()); + let e = shell.state.scene.spawn("thing", Transform::IDENTITY); + let before = shell.state.scene.to_ron().unwrap(); + + shell.play(); + // Mutate as a tick would, then Stop. + shell + .state + .scene + .set_local_transform(e, Transform::from_translation(Vec3::new(3.0, 0.0, 0.0))); + shell.stop(); + + assert_eq!(shell.state.play, PlayState::Editing); + assert!(shell.state.play_snapshot.is_none()); + assert_eq!(shell.state.scene.to_ron().unwrap(), before); + } + + #[test] + fn ctrl_p_plays_then_toggles_pause() { + let mut shell = Shell::from_state(EditorState::new()); + shell.state.scene.spawn("thing", Transform::IDENTITY); + // Ctrl+P from editing → Play. + assert!(shell.try_consume_shortcut(true, false, Some('p'))); + assert_eq!(shell.state.play, PlayState::Playing); + // Ctrl+P while running → Pause. + assert!(shell.try_consume_shortcut(true, false, Some('p'))); + assert_eq!(shell.state.play, PlayState::Paused); + // Ctrl+. steps while paused. + assert!(shell.try_consume_shortcut(true, false, Some('.'))); + assert!(shell.take_step_request()); + } + + #[test] + fn box_collider_wireframe_has_twelve_edges() { + let col = oxide_physics::Collider::cuboid(oxide_engine::math::Vec3::new(1.0, 2.0, 3.0)); + let segs = collider_wire_segments(&col); + assert_eq!(segs.len(), 12, "a box outline is its 12 edges"); + // Every vertex sits on the half-extent box corner (|x|=1,|y|=2,|z|=3). + for (a, b) in segs { + for p in [a, b] { + assert!((p.x.abs() - 1.0).abs() < 1e-5); + assert!((p.y.abs() - 2.0).abs() < 1e-5); + assert!((p.z.abs() - 3.0).abs() < 1e-5); + } + } + } + + #[test] + fn probe_normal_tip_extends_along_the_unit_normal() { + use oxide_engine::math::Vec3; + let point = Vec3::new(1.0, 2.0, 3.0); + // A non-unit normal is renormalized, then scaled to `len`. + let tip = probe_normal_tip(point, Vec3::new(0.0, 5.0, 0.0), 2.0); + assert!((tip - Vec3::new(1.0, 4.0, 3.0)).length() < 1e-5); + // A degenerate normal collapses to the point (no whisker). + let tip0 = probe_normal_tip(point, Vec3::ZERO, 2.0); + assert!((tip0 - point).length() < 1e-6); + } + + #[test] + fn sphere_collider_wireframe_stays_on_the_radius() { + let r = 2.5; + let col = oxide_physics::Collider::ball(r); + let segs = collider_wire_segments(&col); + // Three 24-segment great circles. + assert_eq!(segs.len(), 24 * 3); + for (a, _) in &segs { + assert!( + (a.length() - r).abs() < 1e-4, + "every point is on the sphere" + ); + } + } + + #[test] + fn capsule_wireframe_spans_the_full_height() { + // Half-height 1.0, radius 0.5 → the cap poles reach +/-1.5 in Y. + let col = oxide_physics::Collider::capsule(0.5, 1.0); + let segs = collider_wire_segments(&col); + let max_y = segs + .iter() + .flat_map(|(a, b)| [a.y, b.y]) + .fold(f32::MIN, f32::max); + let min_y = segs + .iter() + .flat_map(|(a, b)| [a.y, b.y]) + .fold(f32::MAX, f32::min); + assert!((max_y - 1.5).abs() < 1e-4, "top hemisphere pole at +1.5"); + assert!((min_y + 1.5).abs() < 1e-4, "bottom hemisphere pole at -1.5"); + } + + #[test] + fn push_arc_closes_a_full_circle() { + use oxide_engine::math::Vec3; + let mut segs = Vec::new(); + push_arc( + &mut segs, + Vec3::ZERO, + Vec3::X, + Vec3::Z, + 1.0, + (0.0, std::f32::consts::TAU), + 8, + ); + assert_eq!(segs.len(), 8); + // The chain is closed: the last segment's end equals the first's start. + let first_start = segs.first().unwrap().0; + let last_end = segs.last().unwrap().1; + assert!((first_start - last_end).length() < 1e-5); + } +} diff --git a/editor/src/state.rs b/editor/src/state.rs new file mode 100644 index 0000000..51a4d38 --- /dev/null +++ b/editor/src/state.rs @@ -0,0 +1,593 @@ +//! 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` 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, + /// 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, + /// The open project's asset database — the bridge between stable asset + /// references (`AssetUid`/[`AssetRef`](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, + /// 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>, + /// 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, + /// 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, +} + +/// 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, +} + +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, + /// 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() + } +} + +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::(bindings::SETTINGS_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::(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::(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"); + registry.register_reflected::("Node"); + registry.register_reflected::("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::("MeshRenderer"); + registry.register_enum::("PrimitiveShape"); + registry.register_addable::("Camera"); + registry.register_addable::("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::("RigidBody"); + registry.register_enum::("RigidBodyKind"); + registry.register_addable::("Collider"); + registry.register_enum::("ColliderShape"); + registry.register_addable::("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`, which the inspector shows + // as a picker filtered to the `scripts/` folder. + registry.register_addable::("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::(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::(restored) + .expect("the Collider component should be restored"); + assert_eq!(collider.radius, 0.5); + assert!(state + .scene + .get::(restored) + .is_some()); + } +} diff --git a/editor/src/terminal.rs b/editor/src/terminal.rs new file mode 100644 index 0000000..286bd33 --- /dev/null +++ b/editor/src/terminal.rs @@ -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 (`$ `); 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(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, + } + } +} diff --git a/editor/src/viewport.rs b/editor/src/viewport.rs new file mode 100644 index 0000000..3806f38 --- /dev/null +++ b/editor/src/viewport.rs @@ -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, + 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, + ) { + // 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::(*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, + ) -> 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, + 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, + ) -> Option { + 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))) +} diff --git a/engine-derive/Cargo.toml b/engine-derive/Cargo.toml new file mode 100644 index 0000000..55d237a --- /dev/null +++ b/engine-derive/Cargo.toml @@ -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 diff --git a/engine-derive/src/lib.rs b/engine-derive/src/lib.rs new file mode 100644 index 0000000..6153c4e --- /dev/null +++ b/engine-derive/src/lib.rs @@ -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::>(), + Fields::Unnamed(unnamed) => unnamed.unnamed.iter().collect::>(), + 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 = None; + let mut max: Option = 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::()?); + } else if meta.path.is_ident("max") { + let lit: syn::LitFloat = meta.value()?.parse()?; + max = Some(lit.base10_parse::()?); + } + 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() +} diff --git a/engine/Cargo.toml b/engine/Cargo.toml new file mode 100644 index 0000000..54856dd --- /dev/null +++ b/engine/Cargo.toml @@ -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 diff --git a/engine/benches/app.rs b/engine/benches/app.rs new file mode 100644 index 0000000..dc89d6a --- /dev/null +++ b/engine/benches/app.rs @@ -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); diff --git a/engine/benches/scene.rs b/engine/benches/scene.rs new file mode 100644 index 0000000..15ce7fc --- /dev/null +++ b/engine/benches/scene.rs @@ -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 = 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); diff --git a/engine/benches/transform.rs b/engine/benches/transform.rs new file mode 100644 index 0000000..a5c91f7 --- /dev/null +++ b/engine/benches/transform.rs @@ -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); diff --git a/engine/src/app/mod.rs b/engine/src/app/mod.rs new file mode 100644 index 0000000..2dc7177 --- /dev/null +++ b/engine/src/app/mod.rs @@ -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>, + 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>, + module_resources: HashMap<&'static str, Vec>, + + 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(&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(&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 + '_ { + 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(&mut self, name: &'static str) -> &mut Self + where + T: hecs::Component + serde::Serialize + serde::de::DeserializeOwned, + { + self.types.register::(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(&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(&mut self, value: T) -> &mut Self { + let id = TypeId::of::(); + 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(&self) -> Option<&T> { + self.resources + .get(&TypeId::of::()) + .and_then(|b| b.downcast_ref::()) + } + + /// Mutably borrows a resource of type `T`, or `None` if absent. + pub fn get_resource_mut(&mut self) -> Option<&mut T> { + self.resources + .get_mut(&TypeId::of::()) + .and_then(|b| b.downcast_mut::()) + } + + /// 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(&mut self) -> Option { + self.resources + .remove(&TypeId::of::()) + .and_then(|b| b.downcast::().ok()) + .map(|b| *b) + } + + /// Whether a resource of type `T` is present. + pub fn has_resource(&self) -> bool { + self.resources.contains_key(&TypeId::of::()) + } + + // --- 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 ModuleBundle for (A,) { + fn add_to(self, app: &mut App) { + app.add_module(self.0); + } +} + +impl ModuleBundle for (A, B) { + fn add_to(self, app: &mut App) { + app.add_module(self.0); + app.add_module(self.1); + } +} + +impl 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::(), Some(&42)); + *app.get_resource_mut::().unwrap() += 1; + assert_eq!(app.get_resource::(), Some(&43)); + assert!(app.get_resource::().is_none()); + + // remove_resource takes ownership and clears the slot. + assert_eq!(app.remove_resource::(), Some(43)); + assert!(!app.has_resource::()); + assert_eq!(app.remove_resource::(), 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::(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); + } +} diff --git a/engine/src/app/module.rs b/engine/src/app/module.rs new file mode 100644 index 0000000..6e8cfea --- /dev/null +++ b/engine/src/app/module.rs @@ -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"); + app.register_type::("Node"); + app.register_type::("Layer"); + app.register_type::("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"); + // 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!["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>); + 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")); + } +} diff --git a/engine/src/app/schedule.rs b/engine/src/app/schedule.rs new file mode 100644 index 0000000..db514e6 --- /dev/null +++ b/engine/src/app/schedule.rs @@ -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, +} + +/// The collection of systems, grouped by phase, in registration order. +#[derive(Default)] +pub(crate) struct Systems { + first: Vec, + input: Vec, + pre_update: Vec, + fixed_update: Vec, + update: Vec, + post_update: Vec, + render: Vec, + last: Vec, +} + +impl Systems { + fn phase_mut(&mut self, phase: Schedule) -> &mut Vec { + 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); + } + } +} diff --git a/engine/src/asset/database.rs b/engine/src/asset/database.rs new file mode 100644 index 0000000..9087714 --- /dev/null +++ b/engine/src/asset/database.rs @@ -0,0 +1,766 @@ +//! [`AssetDatabase`]: stable, project-relative asset references. +//! +//! The [`AssetServer`](super::AssetServer) loads assets by *path*, but a scene +//! or UI document must not bake **absolute** system paths into its saved data — +//! that would break the moment the project is moved to another machine or +//! directory, and it is the chief obstacle to a clean game export (Stage 16). +//! +//! The asset database is the bridge. It assigns every imported asset a stable +//! [`AssetUid`] and records, per project, the mapping +//! **`AssetUid` ↔ assets-relative path** (e.g. `"fonts/Inter-Regular.ttf"`). +//! Saved documents reference assets by `AssetUid`; resolving a uid yields the +//! relative path, which combined with the (possibly new) project root gives an +//! absolute path the [`AssetServer`](super::AssetServer) loads and deduplicates. +//! Because the stored mapping is purely relative, a reference resolves to the +//! same [`Handle`] across save/load **and** after the whole project directory +//! moves. +//! +//! The uid layer (rather than referencing by relative path directly) means an +//! asset can later be *renamed or moved within* the project without breaking +//! references — the uid travels with the file in the manifest. +//! +//! Assets live under typed subfolders of the project's `assets/` directory +//! ([`AssetKind`] → folder), so the database (and the editor's browser) can +//! present and filter them by type without inspecting file contents. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::{AssetServer, Handle}; +use crate::project::ASSETS_DIR; + +/// The manifest file (RON) at the project root recording the uid ↔ path map. +/// +/// It sits at the root rather than inside `assets/` so a scan of the typed +/// asset folders never treats the manifest itself as an asset. +pub const ASSET_MANIFEST_FILE: &str = "assets.manifest"; + +/// The typed category of a project asset. +/// +/// A kind fixes the asset's subfolder under `assets/` and the file extensions +/// that belong to it, letting the database classify files by where they live +/// (with extension as a fallback for files dropped directly in `assets/`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AssetKind { + /// Text rendering fonts (`fonts/`): `.ttf`, `.otf`. + Font, + /// Images and textures (`textures/`): `.png`, `.jpg`, … + Texture, + /// 3D models (`models/`): `.gltf`, `.glb`, `.obj`. + Model, + /// Sound and music (`audio/`): `.wav`, `.ogg`, … + Audio, + /// Serialized UI documents (`ui/`). + Ui, + /// Game-logic scripts (`scripts/`): `.rhai` (Stage 10). + Script, + /// Anything that does not fall into a known typed folder or extension. + Other, +} + +impl AssetKind { + /// The typed kinds in their canonical order (excludes [`Other`](Self::Other), + /// which has no folder of its own). + pub const TYPED: [AssetKind; 6] = [ + AssetKind::Font, + AssetKind::Texture, + AssetKind::Model, + AssetKind::Audio, + AssetKind::Ui, + AssetKind::Script, + ]; + + /// The subfolder name under `assets/` for this kind (empty for + /// [`Other`](Self::Other), which has no dedicated folder). + pub fn folder(self) -> &'static str { + match self { + AssetKind::Font => "fonts", + AssetKind::Texture => "textures", + AssetKind::Model => "models", + AssetKind::Audio => "audio", + AssetKind::Ui => "ui", + AssetKind::Script => "scripts", + AssetKind::Other => "", + } + } + + /// The lower-case file extensions (without the dot) that belong to this + /// kind. [`Other`](Self::Other) claims none. + pub fn extensions(self) -> &'static [&'static str] { + match self { + AssetKind::Font => &["ttf", "otf"], + AssetKind::Texture => &["png", "jpg", "jpeg", "tga", "bmp", "dds", "ktx2"], + AssetKind::Model => &["gltf", "glb", "obj"], + AssetKind::Audio => &["wav", "ogg", "mp3", "flac"], + // UI documents share the `.ron` extension with scenes, so a UI asset + // is recognised by its `ui/` folder rather than its extension. + AssetKind::Ui => &[], + AssetKind::Script => &["rhai"], + AssetKind::Other => &[], + } + } + + /// The kind owning the typed `folder` name, if any. + pub fn from_folder(folder: &str) -> Option { + AssetKind::TYPED.into_iter().find(|k| k.folder() == folder) + } + + /// The kind that claims `extension` (without the dot, any case), if any. + pub fn from_extension(extension: &str) -> Option { + let ext = extension.to_lowercase(); + AssetKind::TYPED + .into_iter() + .find(|k| k.extensions().contains(&ext.as_str())) + } + + /// Classifies an assets-relative path. The leading folder wins (so a file in + /// `ui/` is [`Ui`](Self::Ui) regardless of extension); files outside a typed + /// folder fall back to their extension, else [`Other`](Self::Other). + pub fn classify(relative_path: &str) -> AssetKind { + if let Some((head, _)) = relative_path.split_once('/') { + if let Some(kind) = AssetKind::from_folder(head) { + return kind; + } + } + Path::new(relative_path) + .extension() + .and_then(|e| e.to_str()) + .and_then(AssetKind::from_extension) + .unwrap_or(AssetKind::Other) + } + + /// The kind an asset reference of target type `target` refers to, used to + /// filter an asset picker. `target` is the inner type of an `AssetRef` + /// (or `Handle`) field (see [`asset_ref_target`]); unknown targets yield + /// `None` so the picker can offer every kind. + pub fn for_handle_target(target: &str) -> Option { + match target { + "Font" | "UiFont" => Some(AssetKind::Font), + "GltfModel" | "Model" | "Mesh" => Some(AssetKind::Model), + "Texture" | "Image" => Some(AssetKind::Texture), + "AudioClip" | "Sound" | "Audio" => Some(AssetKind::Audio), + "UiPanel" | "UiDocument" => Some(AssetKind::Ui), + "ScriptAsset" | "Script" => Some(AssetKind::Script), + _ => None, + } + } +} + +/// If `type_name` is an asset-reference field spelling — [`AssetRef`] (the +/// serializable reference components store) or a bare [`Handle`] — returns +/// the inner target type's short name; otherwise `None`. +/// +/// Reflection records a field's *syntactic* type name (see +/// [`FieldInfo::type_name`](crate::reflect::FieldInfo::type_name)), which for an +/// asset-reference field is something like `"AssetRef < Font >"` or +/// `"Handle"`. This normalizes whitespace, unwraps the single +/// generic argument, and strips any module path, yielding e.g. `"Font"`. The +/// editor uses it to recognise such fields and pick the right asset filter via +/// [`AssetKind::for_handle_target`]. +pub fn asset_ref_target(type_name: &str) -> Option<&str> { + // Peel the wrapper structurally, trimming whitespace at each step, so both + // `"AssetRef < Font >"` and `"AssetRef"` parse and we return a borrow + // of the original string. + let t = type_name.trim(); + let inner = t + .strip_prefix("AssetRef") + .or_else(|| t.strip_prefix("Handle"))? + .trim_start(); + let inner = inner.strip_prefix('<')?.trim(); + let inner = inner.strip_suffix('>')?.trim(); + // Reject nested generics / multiple args we don't understand. + if inner.contains('<') || inner.contains(',') { + return None; + } + // Strip any module path (`crate::ui::Font` -> `Font`). + Some(inner.rsplit("::").next().unwrap_or(inner).trim()) +} + +/// A stable, per-project identifier for one asset. +/// +/// Unlike [`AssetId`](super::AssetId) — which is process-unique and changes +/// every run — an `AssetUid` is persisted in the project's manifest and stays +/// attached to its asset across sessions, so saved references keep resolving. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct AssetUid(pub u64); + +impl AssetUid { + /// The raw numeric value. + pub fn value(self) -> u64 { + self.0 + } +} + +/// A typed, serializable reference to a project asset. +/// +/// This is what a **component** stores when it points at an asset (a UI label's +/// font, a renderer's mesh, …). A live [`Handle`] is not serializable and is +/// tied to one process run, so persisting it would be wrong; an `AssetRef` +/// instead holds the stable [`AssetUid`] and resolves to a handle on demand via +/// [`resolve`](Self::resolve) (database → relative path → server → handle). +/// +/// Being a thin wrapper over `Option`, it serializes compactly and +/// round-trips through reflection's RON path, so an asset-reference field is +/// editable in the inspector with no per-type code. The phantom `T` records the +/// target asset type, which the editor reads from the field's spelling +/// (`"AssetRef < Font >"`) via [`asset_ref_target`] to filter the picker. +pub struct AssetRef { + uid: Option, + _marker: std::marker::PhantomData T>, +} + +impl AssetRef { + /// An empty reference, pointing at no asset. + pub const fn none() -> Self { + Self { + uid: None, + _marker: std::marker::PhantomData, + } + } + + /// A reference to the asset with stable id `uid`. + pub const fn new(uid: AssetUid) -> Self { + Self { + uid: Some(uid), + _marker: std::marker::PhantomData, + } + } + + /// The referenced asset's stable id, or `None` if empty. + pub fn uid(self) -> Option { + self.uid + } + + /// Whether this reference points at an asset. + pub fn is_some(self) -> bool { + self.uid.is_some() + } + + /// Points the reference at `uid` (or clears it with `None`). + pub fn set(&mut self, uid: Option) { + self.uid = uid; + } + + /// Resolves to a loaded [`Handle`] via `db` + `server`, or `None` if the + /// reference is empty or its uid is unknown to the database. + pub fn resolve(self, db: &AssetDatabase, server: &AssetServer) -> Option> + where + T: Send + Sync + 'static, + { + db.load::(server, self.uid?) + } +} + +// Hand-written trait impls: deriving would wrongly require `T: Clone`/`Default` +// etc., but an `AssetRef` carries no `T` value — only a uid + phantom marker. +impl Clone for AssetRef { + fn clone(&self) -> Self { + *self + } +} +impl Copy for AssetRef {} +impl Default for AssetRef { + fn default() -> Self { + Self::none() + } +} +impl PartialEq for AssetRef { + fn eq(&self, other: &Self) -> bool { + self.uid == other.uid + } +} +impl Eq for AssetRef {} +impl std::fmt::Debug for AssetRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("AssetRef").field(&self.uid).finish() + } +} +// Serialize transparently as the inner `Option` so saved data is just +// the uid (or unit `None`) and stays independent of `T`. +impl Serialize for AssetRef { + fn serialize(&self, serializer: S) -> Result { + self.uid.serialize(serializer) + } +} +impl<'de, T> Deserialize<'de> for AssetRef { + fn deserialize>(deserializer: D) -> Result { + Ok(Self { + uid: Option::::deserialize(deserializer)?, + _marker: std::marker::PhantomData, + }) + } +} + +/// One asset's record in the database: its stable id, kind, and the path it +/// lives at *relative to the project's `assets/` directory* (forward slashes). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetEntry { + /// The stable identifier saved references use. + pub uid: AssetUid, + /// The asset's typed category. + pub kind: AssetKind, + /// Path relative to `assets/`, e.g. `"fonts/Inter-Regular.ttf"`. + pub path: String, +} + +/// The on-disk manifest: the uid allocator plus every known entry. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct Manifest { + /// The next uid to hand out; persisted so a deleted asset's uid is never + /// reused by a freshly imported one. + next_uid: u64, + /// Every recorded asset (sorted by uid when written, for stable diffs). + entries: Vec, +} + +/// Maps stable asset ids to project-relative paths and back, and resolves them +/// to [`Handle`]s through an [`AssetServer`](super::AssetServer). +/// +/// Construct it for a project root with [`new`](Self::new) (empty) or +/// [`open`](Self::open) (reading any existing manifest), then [`scan`](Self::scan) +/// the asset folders or [`register`](Self::register) individual imports. The +/// root may be changed with [`set_root`](Self::set_root) — e.g. after opening +/// the same project from a new location — without disturbing the uid mapping. +#[derive(Debug, Clone)] +pub struct AssetDatabase { + root: PathBuf, + by_uid: HashMap, + by_path: HashMap, + next_uid: u64, +} + +impl AssetDatabase { + /// An empty database for the project rooted at `root`. + pub fn new(root: impl AsRef) -> Self { + Self { + root: root.as_ref().to_path_buf(), + by_uid: HashMap::new(), + by_path: HashMap::new(), + next_uid: 1, + } + } + + /// Opens the database for the project at `root`, reading its manifest if + /// present. A missing or unreadable manifest yields an empty database (a + /// later [`scan`](Self::scan) repopulates it from disk). + pub fn open(root: impl AsRef) -> Self { + let root = root.as_ref().to_path_buf(); + let mut db = Self::new(&root); + let manifest_path = root.join(ASSET_MANIFEST_FILE); + if let Ok(text) = std::fs::read_to_string(&manifest_path) { + if let Ok(manifest) = ron::from_str::(&text) { + for entry in manifest.entries { + db.by_path.insert(entry.path.clone(), entry.uid); + db.by_uid.insert(entry.uid, entry); + } + db.next_uid = manifest.next_uid.max(db.highest_uid() + 1); + } + } + db + } + + /// Writes the manifest to `/assets.manifest`. + pub fn save(&self) -> std::io::Result<()> { + let mut entries: Vec = self.by_uid.values().cloned().collect(); + entries.sort_by_key(|e| e.uid); + let manifest = Manifest { + next_uid: self.next_uid, + entries, + }; + let text = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()) + .map_err(|e| std::io::Error::other(e.to_string()))?; + std::fs::write(self.manifest_path(), text) + } + + /// The project root the database resolves paths against. + pub fn root(&self) -> &Path { + &self.root + } + + /// Points the database at a new project root (e.g. after the project + /// directory moved). The uid ↔ relative-path mapping is unaffected, so all + /// existing references keep resolving — now against the new location. + pub fn set_root(&mut self, root: impl AsRef) { + self.root = root.as_ref().to_path_buf(); + } + + /// The `assets/` directory under the project root. + pub fn assets_dir(&self) -> PathBuf { + self.root.join(ASSETS_DIR) + } + + /// The manifest file path. + pub fn manifest_path(&self) -> PathBuf { + self.root.join(ASSET_MANIFEST_FILE) + } + + /// Records the asset at `relative_path` (relative to `assets/`), returning + /// its uid — the existing one if already known, else a freshly allocated + /// one. The kind is inferred from the path. Idempotent for a given path. + pub fn register(&mut self, relative_path: impl AsRef) -> AssetUid { + let path = normalize_relative(relative_path.as_ref()); + if let Some(&uid) = self.by_path.get(&path) { + return uid; + } + let uid = AssetUid(self.next_uid); + self.next_uid += 1; + let entry = AssetEntry { + uid, + kind: AssetKind::classify(&path), + path: path.clone(), + }; + self.by_path.insert(path, uid); + self.by_uid.insert(uid, entry); + uid + } + + /// Scans the typed asset folders under `assets/` and reconciles the + /// database with what is on disk: existing files keep their uid, new files + /// are [registered](Self::register), and entries whose files no longer exist + /// are dropped. Returns the number of newly registered assets. + /// + /// Call [`save`](Self::save) afterwards to persist any new uids. + pub fn scan(&mut self) -> usize { + let assets_dir = self.assets_dir(); + let mut found: Vec = Vec::new(); + for kind in AssetKind::TYPED { + collect_files(&assets_dir.join(kind.folder()), &assets_dir, &mut found); + } + + // Drop entries whose backing file disappeared. + let present: std::collections::HashSet<&String> = found.iter().collect(); + let removed: Vec<(AssetUid, String)> = self + .by_uid + .values() + .filter(|e| !present.contains(&e.path)) + .map(|e| (e.uid, e.path.clone())) + .collect(); + for (uid, path) in removed { + self.by_uid.remove(&uid); + self.by_path.remove(&path); + } + + // Register anything new. + let before = self.by_uid.len(); + for path in found { + self.register(path); + } + self.by_uid.len().saturating_sub(before) + } + + /// The entry for `uid`, if known. + pub fn entry(&self, uid: AssetUid) -> Option<&AssetEntry> { + self.by_uid.get(&uid) + } + + /// The uid recorded for an assets-relative path, if any. + pub fn uid_of(&self, relative_path: impl AsRef) -> Option { + self.by_path + .get(&normalize_relative(relative_path.as_ref())) + .copied() + } + + /// The assets-relative path for `uid`, if known. + pub fn relative_path(&self, uid: AssetUid) -> Option<&str> { + self.by_uid.get(&uid).map(|e| e.path.as_str()) + } + + /// The absolute filesystem path for `uid` under the current root, if known. + pub fn absolute_path(&self, uid: AssetUid) -> Option { + self.by_uid.get(&uid).map(|e| { + self.assets_dir() + .join(e.path.replace('/', std::path::MAIN_SEPARATOR_STR)) + }) + } + + /// Every entry, in unspecified order. + pub fn entries(&self) -> impl Iterator { + self.by_uid.values() + } + + /// Entries of a given kind, in unspecified order. + pub fn entries_of_kind(&self, kind: AssetKind) -> impl Iterator { + self.by_uid.values().filter(move |e| e.kind == kind) + } + + /// The number of recorded assets. + pub fn len(&self) -> usize { + self.by_uid.len() + } + + /// Whether the database has no entries. + pub fn is_empty(&self) -> bool { + self.by_uid.is_empty() + } + + /// Resolves `uid` to a loaded [`Handle`] via `server`, or `None` if the + /// uid is unknown. The handle is deduplicated by the server, so resolving + /// the same uid (even after the project moved) yields the same asset. + pub fn load( + &self, + server: &AssetServer, + uid: AssetUid, + ) -> Option> { + let path = self.absolute_path(uid)?; + Some(server.load::(path)) + } + + // --- internals --------------------------------------------------------- + + fn highest_uid(&self) -> u64 { + self.by_uid.keys().map(|u| u.0).max().unwrap_or(0) + } +} + +/// Normalizes a path to the database's canonical relative form: forward slashes, +/// no leading `./` or separator. +fn normalize_relative(path: &str) -> String { + let trimmed = path.replace('\\', "/"); + let trimmed = trimmed.strip_prefix("./").unwrap_or(&trimmed); + trimmed.trim_start_matches('/').to_string() +} + +/// Recursively collects files under `dir`, pushing each one's path relative to +/// `base` (forward slashes) into `out`. A missing `dir` is silently skipped. +fn collect_files(dir: &Path, base: &Path, out: &mut Vec) { + let Ok(read) = std::fs::read_dir(dir) else { + return; + }; + for entry in read.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files(&path, base, out); + } else if let Ok(rel) = path.strip_prefix(base) { + out.push(rel.to_string_lossy().replace('\\', "/")); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + static COUNTER: AtomicU32 = AtomicU32::new(0); + + fn temp_root(tag: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!( + "oxide_assetdb_test_{}_{}_{tag}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::SeqCst), + )); + path + } + + /// Creates `assets/` under `root` with placeholder contents. + fn touch_asset(root: &Path, rel: &str) { + let full = root.join(ASSETS_DIR).join(rel); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, b"x").unwrap(); + } + + #[test] + fn classify_by_folder_then_extension() { + assert_eq!(AssetKind::classify("fonts/Inter.ttf"), AssetKind::Font); + assert_eq!(AssetKind::classify("ui/menu.ron"), AssetKind::Ui); + assert_eq!(AssetKind::classify("textures/wall.png"), AssetKind::Texture); + // No typed folder → fall back to extension. + assert_eq!(AssetKind::classify("loose.glb"), AssetKind::Model); + assert_eq!(AssetKind::classify("notes.md"), AssetKind::Other); + } + + #[test] + fn asset_ref_target_parses_and_maps_to_kind() { + // The derive's spelling (spaces around the generic args), for both the + // serializable `AssetRef` and a bare `Handle`. + assert_eq!(asset_ref_target("AssetRef < Font >"), Some("Font")); + assert_eq!(asset_ref_target("Handle < Font >"), Some("Font")); + // Compact and module-qualified spellings. + assert_eq!(asset_ref_target("AssetRef"), Some("GltfModel")); + assert_eq!(asset_ref_target("Handle"), Some("Font")); + // Non-reference and unsupported (nested / multi-arg) fields. + assert_eq!(asset_ref_target("f32"), None); + assert_eq!(asset_ref_target("Vec>"), None); + assert_eq!(asset_ref_target("HashMap"), None); + + // Target type -> picker filter kind. + assert_eq!(AssetKind::for_handle_target("Font"), Some(AssetKind::Font)); + assert_eq!( + AssetKind::for_handle_target("GltfModel"), + Some(AssetKind::Model) + ); + assert_eq!(AssetKind::for_handle_target("Whatever"), None); + } + + #[test] + fn asset_ref_serializes_as_uid_and_resolves() { + // Empty and populated references round-trip through RON as just the uid. + let empty = AssetRef::::none(); + assert!(!empty.is_some()); + let ron_empty = ron::to_string(&empty).unwrap(); + assert_eq!( + ron::from_str::>(&ron_empty).unwrap(), + empty + ); + + let r = AssetRef::::new(AssetUid(7)); + let round: AssetRef = ron::from_str(&ron::to_string(&r).unwrap()).unwrap(); + assert_eq!(round.uid(), Some(AssetUid(7))); + + // resolve() goes ref -> db -> server -> handle. + let root = temp_root("assetref"); + let full = root.join(ASSETS_DIR).join("textures"); + std::fs::create_dir_all(&full).unwrap(); + std::fs::write(full.join("a.txt"), "hi").unwrap(); + let mut db = AssetDatabase::new(&root); + let uid = db.register("textures/a.txt"); + let server = AssetServer::empty(); + server.register_loader(TxtLoader); + + let reference = AssetRef::::new(uid); + let handle = reference.resolve(&db, &server).unwrap(); + assert_eq!(handle.get().unwrap().as_str(), "hi"); + // An empty ref resolves to nothing. + assert!(AssetRef::::none().resolve(&db, &server).is_none()); + + std::fs::remove_dir_all(root).ok(); + } + + #[test] + fn register_is_idempotent_and_infers_kind() { + let mut db = AssetDatabase::new(temp_root("register")); + let a = db.register("fonts/Inter-Regular.ttf"); + let b = db.register("fonts/Inter-Regular.ttf"); + assert_eq!(a, b, "same path returns same uid"); + assert_eq!(db.len(), 1); + assert_eq!(db.entry(a).unwrap().kind, AssetKind::Font); + // Path normalization: a `./`-prefixed, back-slashed spelling collapses. + assert_eq!(db.uid_of(".\\fonts\\Inter-Regular.ttf"), Some(a)); + } + + #[test] + fn scan_picks_up_typed_folders_and_prunes_missing() { + let root = temp_root("scan"); + touch_asset(&root, "fonts/Inter.ttf"); + touch_asset(&root, "textures/wall.png"); + touch_asset(&root, "models/cube.glb"); + + let mut db = AssetDatabase::new(&root); + assert_eq!(db.scan(), 3); + assert_eq!(db.len(), 3); + assert_eq!(db.entries_of_kind(AssetKind::Font).count(), 1); + + // Remove one file and rescan: it is pruned, the rest keep their uids. + let font_uid = db.uid_of("fonts/Inter.ttf").unwrap(); + let wall_uid = db.uid_of("textures/wall.png").unwrap(); + std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap(); + assert_eq!(db.scan(), 0); + assert_eq!(db.len(), 2); + assert!(db.entry(font_uid).is_none()); + assert_eq!(db.uid_of("textures/wall.png"), Some(wall_uid)); + + std::fs::remove_dir_all(root).ok(); + } + + #[test] + fn manifest_round_trips_uids() { + let root = temp_root("manifest"); + std::fs::create_dir_all(&root).unwrap(); + touch_asset(&root, "fonts/Inter.ttf"); + touch_asset(&root, "audio/click.wav"); + + let mut db = AssetDatabase::new(&root); + db.scan(); + let font_uid = db.uid_of("fonts/Inter.ttf").unwrap(); + let click_uid = db.uid_of("audio/click.wav").unwrap(); + let next = db.next_uid; + db.save().unwrap(); + + // Reload from the manifest: every uid is preserved, and the allocator + // does not reuse a freed id. + let reloaded = AssetDatabase::open(&root); + assert_eq!(reloaded.uid_of("fonts/Inter.ttf"), Some(font_uid)); + assert_eq!(reloaded.uid_of("audio/click.wav"), Some(click_uid)); + assert_eq!(reloaded.next_uid, next); + + std::fs::remove_dir_all(root).ok(); + } + + #[test] + fn reference_survives_save_load_and_moved_project() { + // A reference (uid) saved with the project must resolve to the same + // handle after reload AND after the whole project directory moves. + let root = temp_root("move_src"); + touch_asset(&root, "fonts/Inter.ttf"); + let mut db = AssetDatabase::open(&root); + db.scan(); + db.save().unwrap(); + let uid = db.uid_of("fonts/Inter.ttf").unwrap(); + + // Simulate moving the project to a new directory on disk. + let moved = temp_root("move_dst"); + std::fs::create_dir_all(&moved).unwrap(); + copy_dir(&root, &moved); + + // Open the database from the new location: same uid, new absolute path. + let moved_db = AssetDatabase::open(&moved); + assert_eq!(moved_db.uid_of("fonts/Inter.ttf"), Some(uid)); + let abs = moved_db.absolute_path(uid).unwrap(); + assert!(abs.starts_with(&moved)); + assert!(abs.exists()); + + std::fs::remove_dir_all(root).ok(); + std::fs::remove_dir_all(moved).ok(); + } + + #[test] + fn load_dedups_through_the_server() { + // Use a tiny custom loader so we don't need a real asset format. + let root = temp_root("load"); + let full = root.join(ASSETS_DIR).join("textures"); + std::fs::create_dir_all(&full).unwrap(); + std::fs::write(full.join("a.txt"), "hi").unwrap(); + + let mut db = AssetDatabase::new(&root); + let uid = db.register("textures/a.txt"); + + let server = AssetServer::empty(); + server.register_loader(TxtLoader); + let h1 = db.load::(&server, uid).unwrap(); + let h2 = db.load::(&server, uid).unwrap(); + assert_eq!(h1.id(), h2.id(), "same uid resolves to one shared asset"); + assert_eq!(h1.get().unwrap().as_str(), "hi"); + assert!(db.load::(&server, AssetUid(999)).is_none()); + + std::fs::remove_dir_all(root).ok(); + } + + struct TxtLoader; + impl crate::asset::AssetLoader for TxtLoader { + type Asset = String; + fn extensions(&self) -> &'static [&'static str] { + &["txt"] + } + fn load(&self, path: &Path) -> Result { + std::fs::read_to_string(path).map_err(|e| crate::asset::AssetError::Load { + path: path.to_path_buf(), + message: e.to_string(), + }) + } + } + + fn copy_dir(from: &Path, to: &Path) { + for entry in std::fs::read_dir(from).unwrap().flatten() { + let dst = to.join(entry.file_name()); + if entry.path().is_dir() { + std::fs::create_dir_all(&dst).unwrap(); + copy_dir(&entry.path(), &dst); + } else { + std::fs::copy(entry.path(), dst).unwrap(); + } + } + } +} diff --git a/engine/src/asset/gltf.rs b/engine/src/asset/gltf.rs new file mode 100644 index 0000000..0dab41f --- /dev/null +++ b/engine/src/asset/gltf.rs @@ -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, + /// 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, +} + +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) -> Result { + 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::("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 { + 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 { + 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 { + 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, +) -> 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 { + 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> = reader.read_normals().map(|n| n.collect()); + let uvs: Option> = reader.read_tex_coords(0).map(|tc| tc.into_f32().collect()); + + let indices: Vec = 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(), + } +} diff --git a/engine/src/asset/handle.rs b/engine/src/asset/handle.rs new file mode 100644 index 0000000..f917082 --- /dev/null +++ b/engine/src/asset/handle.rs @@ -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` 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 { + Loading, + Loaded(Arc), + Failed(Arc), +} + +/// The shared, reference-counted storage for one asset. +/// +/// Handles hold an `Arc>`; the server's cache holds a +/// `Weak` to the same allocation for deduplication only. +pub(crate) struct AssetCell { + id: AssetId, + source: Option, + state: Mutex>, + ready: Condvar, +} + +impl AssetCell { + pub(crate) fn new_loading(id: AssetId, source: Option) -> Arc { + Arc::new(Self { + id, + source, + state: Mutex::new(CellState::Loading), + ready: Condvar::new(), + }) + } + + pub(crate) fn new_loaded(id: AssetId, source: Option, value: T) -> Arc { + 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, message: String) -> Arc { + 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 { + cell: Arc>, +} + +impl Handle { + pub(crate) fn from_cell(cell: Arc>) -> 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` clone, or `None` if it is still + /// loading or failed. + pub fn get(&self) -> Option> { + 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 { + 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> { + 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 Clone for Handle { + fn clone(&self) -> Self { + Self { + cell: self.cell.clone(), + } + } +} + +impl fmt::Debug for Handle { + 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() + } +} diff --git a/engine/src/asset/mod.rs b/engine/src/asset/mod.rs new file mode 100644 index 0000000..9244de1 --- /dev/null +++ b/engine/src/asset/mod.rs @@ -0,0 +1,32 @@ +//! Asset loading and management. +//! +//! Stage 4 introduced the first importer: a static-mesh [`glTF`](gltf) loader +//! that turns a `.gltf`/`.glb` file into engine [`Mesh`](crate::render::Mesh)es, +//! [`Material`](crate::render::Material)s, and placement [`Transform`](crate::math::Transform)s. +//! +//! Stage 5 adds the [`AssetServer`]: a central registry that loads assets through +//! pluggable [`AssetLoader`]s, deduplicates by path+type, and hands out +//! reference-counted [`Handle`]s (an asset lives as long as a handle to it does). +//! It supports synchronous and background loading and in-place [reload](AssetServer::reload), +//! the foundation later stages build live reload, streaming, and export packing +//! on. The standalone [`load_gltf`] importer stays available; the server reaches +//! it through the built-in [`GltfLoader`]. + +mod database; +mod gltf; +mod handle; +mod server; + +pub use database::{ + asset_ref_target, AssetDatabase, AssetEntry, AssetKind, AssetRef, AssetUid, ASSET_MANIFEST_FILE, +}; +pub use gltf::{load_gltf, load_gltf_slice, GltfError, GltfLoader, GltfMesh, GltfModel}; +pub use handle::{AssetId, Handle, LoadState}; +pub use server::{AssetError, AssetLoader, AssetServer}; + +/// Registers the engine's built-in asset loaders on `server`. Called by +/// [`AssetServer::new`]. +pub(crate) fn register_default_loaders(server: &AssetServer) { + server.register_loader(GltfLoader); + server.register_loader(crate::ui::FontLoader); +} diff --git a/engine/src/asset/server.rs b/engine/src/asset/server.rs new file mode 100644 index 0000000..d06ab8d --- /dev/null +++ b/engine/src/asset/server.rs @@ -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; +} + +/// 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, AssetError>; +} + +impl ErasedLoader for L { + fn output_type(&self) -> TypeId { + TypeId::of::() + } + + fn load(&self, path: &Path) -> Result, AssetError> { + Ok(Box::new(::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::` +/// captures `T` into the function pointer. +#[derive(Clone)] +struct CacheEntry { + weak: Weak, + reload_in_place: fn(&AssetServer, &Path), +} + +struct Inner { + loaders: RwLock>>, + /// Dedup cache: weak references, so a cached asset with no live handles is + /// collected and reloaded fresh next time. + cache: Mutex>, + 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::("assets/models/cube.gltf"); +/// if let Some(model) = model.get() { +/// println!("{} meshes", model.meshes.len()); +/// } +/// ``` +#[derive(Clone)] +pub struct AssetServer { + inner: Arc, +} + +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(&self, loader: L) { + let exts: Vec = loader + .extensions() + .iter() + .map(|e| e.to_lowercase()) + .collect(); + let erased: Arc = 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(&self, path: impl AsRef) -> Handle { + let path = path.as_ref().to_path_buf(); + let key = (TypeId::of::(), path.clone()); + if let Some(handle) = self.cached::(&key) { + return handle; + } + match self.run_loader::(&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(&self, path: impl AsRef) -> Handle { + let path = path.as_ref().to_path_buf(); + let key = (TypeId::of::(), path.clone()); + if let Some(handle) = self.cached::(&key) { + return handle; + } + // Insert the loading cell up front so concurrent requests dedup onto it. + let cell = AssetCell::::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::(&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(&self, value: T) -> Handle { + 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(&self, path: impl AsRef) -> Option> { + let key = (TypeId::of::(), path.as_ref().to_path_buf()); + self.cached::(&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(&self, path: impl AsRef) -> Handle { + let path = path.as_ref().to_path_buf(); + let key = (TypeId::of::(), path.clone()); + let existing = self.cached::(&key); + match self.run_loader::(&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 = { + 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(&self, key: &CacheKey) -> Option> { + let cache = self.inner.cache.lock().unwrap(); + let arc = cache.get(key)?.weak.upgrade()?; + let cell = arc.downcast::>().ok()?; + Some(Handle::from_cell(cell)) + } + + fn insert_cache(&self, key: CacheKey, cell: &Arc>) { + let erased: Arc = 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::(path); + }, + }; + self.inner.cache.lock().unwrap().insert(key, entry); + } + + fn run_loader(&self, path: &Path) -> Result { + 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::() { + return Err(AssetError::TypeMismatch { ext }); + } + let boxed = loader.load(path)?; + Ok(*boxed + .downcast::() + .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); + + #[derive(Debug, PartialEq, Eq)] + struct TextAsset(String); + + struct TextLoader(Arc); + impl AssetLoader for TextLoader { + type Asset = TextAsset; + fn extensions(&self) -> &'static [&'static str] { + &["txt"] + } + fn load(&self, path: &Path) -> Result { + 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::(&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::(&path); + let b = server.load::(&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::(&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::(&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::(&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::(&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::(&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::(&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::(&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::(&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()); + } +} diff --git a/engine/src/input/action.rs b/engine/src/input/action.rs new file mode 100644 index 0000000..bbb2b8b --- /dev/null +++ b/engine/src/input/action.rs @@ -0,0 +1,1029 @@ +//! [`ActionMap`] — named actions ↔ physical [`Binding`]s, with defaults, +//! runtime remapping, and RON-persistable user overrides. +//! +//! Game code addresses actions by name (`"Jump"`, `"Fire"`, …) and never +//! the physical key, so the user-facing settings screen can rebind any +//! action without touching gameplay code. Each action carries: +//! +//! - **`defaults`** — the code-defined initial bindings registered when +//! the action is created. They never change after registration. +//! - **`current`** — the bindings actually queried each frame, initially a +//! clone of `defaults`. The "Restore defaults" button copies `defaults` +//! back over `current`. +//! +//! Persistence saves only `current`. On load, the program first registers +//! actions with their defaults from code, then applies the loaded overrides +//! on top — unknown actions in the saved file are skipped (so removing an +//! action in code never breaks an old settings file). +//! +//! # Multi-bind and one-key-many-actions +//! +//! An action can list more than one binding (e.g. `Jump` → `[Space, Mouse4]`) +//! and one physical input can drive more than one action (e.g. `Space` → +//! `Jump` and `Confirm` simultaneously). Edge semantics combine bindings +//! with OR logic but with hysteresis applied at the action level, not the +//! binding level: an already-engaged multi-bind action does not re-fire +//! `action_pressed` when a *second* binding goes down, and does not fire +//! `action_released` until **every** binding has been released. See +//! [`ActionMap::action_pressed`] / [`action_released`](ActionMap::action_released) +//! for the precise definition. +//! +//! ``` +//! use oxide_engine::input::{ActionMap, Binding, InputState}; +//! use oxide_engine::winit::keyboard::KeyCode; +//! +//! let mut actions = ActionMap::new(); +//! actions.register("Jump", [Binding::Key(KeyCode::Space)]); +//! +//! let mut input = InputState::new(); +//! input.press_key(KeyCode::Space); +//! assert!(actions.action_pressed("Jump", &input)); +//! assert!(actions.action_held("Jump", &input)); +//! +//! // Runtime remap. Game code keeps querying "Jump" and is unaffected. +//! actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]); +//! assert!(!actions.action_held("Jump", &input)); // Space is no longer "Jump" +//! ``` + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::{Axis2DBinding, AxisBinding, Binding, InputState}; +use crate::math::Vec2; + +/// One named action and its two binding lists (immutable defaults + +/// runtime-mutable current). +#[derive(Debug, Clone)] +struct Action { + defaults: Vec, + current: Vec, +} + +/// One named 1D axis with default + current bindings. +#[derive(Debug, Clone)] +struct AxisAction { + defaults: AxisBinding, + current: AxisBinding, +} + +/// One named 2D axis with default + current bindings. +#[derive(Debug, Clone)] +struct Axis2DAction { + defaults: Axis2DBinding, + current: Axis2DBinding, +} + +/// Maps named actions to physical [`Binding`]s, with separate default and +/// current binding lists per action and RON-persistable user overrides. +/// +/// Three action kinds live in the same map under disjoint name spaces: +/// **buttons** (one-shot events, [`register`](Self::register) / +/// [`action_pressed`](Self::action_pressed)), **1D axes** (float values +/// composed from + / − binding sets, [`register_axis`](Self::register_axis) / +/// [`axis`](Self::axis)), and **2D axes** (a pair of 1D axes returning a +/// [`Vec2`], [`register_axis_2d`](Self::register_axis_2d) / +/// [`axis_2d`](Self::axis_2d)). The same string name can be reused across +/// kinds without conflict — `Move` can be both a 2D axis and a button if +/// that's what a project wants. +/// +/// See the [module docs](self) for the rationale behind defaults vs current +/// bindings and the action-level edge hysteresis. +#[derive(Debug, Default, Clone)] +pub struct ActionMap { + actions: BTreeMap, + axes: BTreeMap, + axes_2d: BTreeMap, +} + +impl ActionMap { + /// A new empty map. Register actions with [`register`](Self::register). + pub fn new() -> Self { + Self::default() + } + + /// Registers an action `name` with its code-defined default bindings. + /// The current bindings start as a clone of the defaults. + /// + /// If `name` is already registered, the existing `current` bindings are + /// preserved (so a programmer adding a new default binding mid-project + /// does not overwrite a user's remap), but the `defaults` list is + /// replaced — restoring defaults from this point on uses the new list. + pub fn register(&mut self, name: impl Into, defaults: I) -> &mut Self + where + I: IntoIterator, + { + let name = name.into(); + let defaults: Vec = defaults.into_iter().collect(); + self.actions + .entry(name) + .and_modify(|a| a.defaults = defaults.clone()) + .or_insert_with(|| Action { + current: defaults.clone(), + defaults, + }); + self + } + + /// Removes the action. Returns `true` if it existed. + pub fn unregister(&mut self, name: &str) -> bool { + self.actions.remove(name).is_some() + } + + /// `true` if `name` is registered. + pub fn has(&self, name: &str) -> bool { + self.actions.contains_key(name) + } + + /// Iterates the registered action names in sorted order. + pub fn actions(&self) -> impl Iterator + '_ { + self.actions.keys().map(String::as_str) + } + + /// The current bindings driving `name`, or `&[]` if unregistered. + pub fn bindings(&self, name: &str) -> &[Binding] { + self.actions + .get(name) + .map(|a| a.current.as_slice()) + .unwrap_or(&[]) + } + + /// The code-defined default bindings for `name`, or `&[]` if unregistered. + pub fn defaults(&self, name: &str) -> &[Binding] { + self.actions + .get(name) + .map(|a| a.defaults.as_slice()) + .unwrap_or(&[]) + } + + /// Replaces the current bindings for `name`. No-op if unregistered. + pub fn set_bindings(&mut self, name: &str, bindings: Vec) { + if let Some(action) = self.actions.get_mut(name) { + action.current = bindings; + } + } + + /// Appends one binding to `name`'s current list (no-op if unregistered; + /// duplicates are skipped so adding the same binding twice is idempotent). + pub fn add_binding(&mut self, name: &str, binding: Binding) { + if let Some(action) = self.actions.get_mut(name) { + if !action.current.contains(&binding) { + action.current.push(binding); + } + } + } + + /// Removes one binding from `name`'s current list. Returns whether it was + /// present. No-op (and `false`) if the action is unregistered. + pub fn remove_binding(&mut self, name: &str, binding: Binding) -> bool { + let Some(action) = self.actions.get_mut(name) else { + return false; + }; + let before = action.current.len(); + action.current.retain(|b| *b != binding); + action.current.len() != before + } + + /// Empties `name`'s current list (so the action becomes unbindable until + /// new bindings are set or defaults are restored). + pub fn clear_bindings(&mut self, name: &str) { + if let Some(action) = self.actions.get_mut(name) { + action.current.clear(); + } + } + + /// Resets `name`'s current bindings back to its defaults. + pub fn restore_defaults(&mut self, name: &str) { + if let Some(action) = self.actions.get_mut(name) { + action.current = action.defaults.clone(); + } + } + + /// Resets every action's current bindings back to its defaults — across + /// all three action kinds (buttons, 1D axes, 2D axes). + pub fn restore_all_defaults(&mut self) { + for action in self.actions.values_mut() { + action.current = action.defaults.clone(); + } + for axis in self.axes.values_mut() { + axis.current = axis.defaults.clone(); + } + for axis in self.axes_2d.values_mut() { + axis.current = axis.defaults.clone(); + } + } + + // --- 1D axes ---------------------------------------------------------- + + /// Registers a 1D axis `name` with code-defined default bindings. As + /// with buttons, re-registering preserves the user's current bindings + /// but updates the defaults list. + pub fn register_axis(&mut self, name: impl Into, defaults: AxisBinding) -> &mut Self { + let name = name.into(); + self.axes + .entry(name) + .and_modify(|a| a.defaults = defaults.clone()) + .or_insert_with(|| AxisAction { + current: defaults.clone(), + defaults, + }); + self + } + + /// Removes the 1D axis `name`. Returns whether it existed. + pub fn unregister_axis(&mut self, name: &str) -> bool { + self.axes.remove(name).is_some() + } + + /// `true` if a 1D axis `name` is registered. + pub fn has_axis(&self, name: &str) -> bool { + self.axes.contains_key(name) + } + + /// Iterates registered 1D axis names in sorted order. + pub fn axes(&self) -> impl Iterator + '_ { + self.axes.keys().map(String::as_str) + } + + /// The current bindings for `name`, or `None` if unregistered. + pub fn axis_bindings(&self, name: &str) -> Option<&AxisBinding> { + self.axes.get(name).map(|a| &a.current) + } + + /// The default bindings for `name`, or `None` if unregistered. + pub fn axis_defaults(&self, name: &str) -> Option<&AxisBinding> { + self.axes.get(name).map(|a| &a.defaults) + } + + /// Replaces the current bindings for the 1D axis `name`. No-op if + /// unregistered. + pub fn set_axis_bindings(&mut self, name: &str, bindings: AxisBinding) { + if let Some(axis) = self.axes.get_mut(name) { + axis.current = bindings; + } + } + + /// Resets axis `name`'s current bindings back to its defaults. + pub fn restore_axis_defaults(&mut self, name: &str) { + if let Some(axis) = self.axes.get_mut(name) { + axis.current = axis.defaults.clone(); + } + } + + /// Evaluates 1D axis `name` against `input`. Returns 0.0 for + /// unregistered axes. + pub fn axis(&self, name: &str, input: &InputState) -> f32 { + self.axes + .get(name) + .map(|a| a.current.value(input)) + .unwrap_or(0.0) + } + + // --- 2D axes ---------------------------------------------------------- + + /// Registers a 2D axis `name` with code-defined default bindings. + pub fn register_axis_2d( + &mut self, + name: impl Into, + defaults: Axis2DBinding, + ) -> &mut Self { + let name = name.into(); + self.axes_2d + .entry(name) + .and_modify(|a| a.defaults = defaults.clone()) + .or_insert_with(|| Axis2DAction { + current: defaults.clone(), + defaults, + }); + self + } + + /// Removes the 2D axis `name`. Returns whether it existed. + pub fn unregister_axis_2d(&mut self, name: &str) -> bool { + self.axes_2d.remove(name).is_some() + } + + /// `true` if a 2D axis `name` is registered. + pub fn has_axis_2d(&self, name: &str) -> bool { + self.axes_2d.contains_key(name) + } + + /// Iterates registered 2D axis names in sorted order. + pub fn axes_2d(&self) -> impl Iterator + '_ { + self.axes_2d.keys().map(String::as_str) + } + + /// The current bindings for `name`, or `None` if unregistered. + pub fn axis_2d_bindings(&self, name: &str) -> Option<&Axis2DBinding> { + self.axes_2d.get(name).map(|a| &a.current) + } + + /// The default bindings for `name`, or `None` if unregistered. + pub fn axis_2d_defaults(&self, name: &str) -> Option<&Axis2DBinding> { + self.axes_2d.get(name).map(|a| &a.defaults) + } + + /// Replaces the current bindings for the 2D axis `name`. No-op if + /// unregistered. + pub fn set_axis_2d_bindings(&mut self, name: &str, bindings: Axis2DBinding) { + if let Some(axis) = self.axes_2d.get_mut(name) { + axis.current = bindings; + } + } + + /// Resets 2D axis `name`'s current bindings back to its defaults. + pub fn restore_axis_2d_defaults(&mut self, name: &str) { + if let Some(axis) = self.axes_2d.get_mut(name) { + axis.current = axis.defaults.clone(); + } + } + + /// Evaluates 2D axis `name` against `input`. Returns [`Vec2::ZERO`] for + /// unregistered axes. Diagonals are unnormalized — see [`Axis2DBinding`]. + pub fn axis_2d(&self, name: &str, input: &InputState) -> Vec2 { + self.axes_2d + .get(name) + .map(|a| a.current.value(input)) + .unwrap_or(Vec2::ZERO) + } + + // --- Action edge semantics -------------------------------------------- + + /// `true` if `name`'s `pressed` edge fired this frame. + /// + /// Defined so the edge fires only when the action *transitions* from + /// not-held to held: any current binding became pressed this frame and + /// no current binding was held going into the frame. A second binding + /// going down while the action is already engaged does **not** retrigger + /// the edge. + /// + /// Returns `false` for unregistered actions. + pub fn action_pressed(&self, name: &str, input: &InputState) -> bool { + let Some(action) = self.actions.get(name) else { + return false; + }; + let any_pressed = action.current.iter().any(|b| b.pressed(input)); + let any_held_before = action.current.iter().any(|b| b.held_before_frame(input)); + any_pressed && !any_held_before + } + + /// `true` if `name`'s `released` edge fired this frame. + /// + /// Defined so the edge fires only when the action *transitions* from + /// held to not-held: any current binding was released this frame and no + /// current binding remains held. Releasing one binding of a multi-bind + /// action while another is still held does **not** trigger the edge. + /// + /// Returns `false` for unregistered actions. + pub fn action_released(&self, name: &str, input: &InputState) -> bool { + let Some(action) = self.actions.get(name) else { + return false; + }; + let any_released = action.current.iter().any(|b| b.released(input)); + let any_held_now = action.current.iter().any(|b| b.held(input)); + any_released && !any_held_now + } + + /// `true` if any current binding of `name` is held right now. + /// + /// Returns `false` for unregistered actions. + pub fn action_held(&self, name: &str, input: &InputState) -> bool { + let Some(action) = self.actions.get(name) else { + return false; + }; + action.current.iter().any(|b| b.held(input)) + } + + // --- Persistence ------------------------------------------------------ + + /// Captures the **current** (possibly remapped) bindings as a + /// serializable [`ActionOverrides`] across all three action kinds. + /// + /// Defaults are deliberately excluded: defaults live in code (the + /// program calls [`register`](Self::register) / + /// [`register_axis`](Self::register_axis) / + /// [`register_axis_2d`](Self::register_axis_2d) at startup), so on + /// load the program re-registers actions and then applies the saved + /// overrides on top. That keeps the saved file small and lets the + /// program evolve its default bindings without invalidating user data. + pub fn overrides(&self) -> ActionOverrides { + ActionOverrides { + bindings: self + .actions + .iter() + .map(|(name, a)| (name.clone(), a.current.clone())) + .collect(), + axes: self + .axes + .iter() + .map(|(name, a)| (name.clone(), a.current.clone())) + .collect(), + axes_2d: self + .axes_2d + .iter() + .map(|(name, a)| (name.clone(), a.current.clone())) + .collect(), + } + } + + /// Applies saved [`ActionOverrides`] on top of the currently-registered + /// actions. Unknown action names (in any kind) are skipped — removing + /// an action from code never breaks an old settings file — and + /// registered actions absent from `overrides` keep whatever current + /// bindings they already have. + pub fn apply_overrides(&mut self, overrides: &ActionOverrides) { + for (name, bindings) in &overrides.bindings { + if let Some(action) = self.actions.get_mut(name) { + action.current = bindings.clone(); + } + } + for (name, axis) in &overrides.axes { + if let Some(entry) = self.axes.get_mut(name) { + entry.current = axis.clone(); + } + } + for (name, axis) in &overrides.axes_2d { + if let Some(entry) = self.axes_2d.get_mut(name) { + entry.current = axis.clone(); + } + } + } +} + +/// A serializable snapshot of an [`ActionMap`]'s current bindings — across +/// all three action kinds (buttons, 1D axes, 2D axes). +/// +/// Round-trips through RON for storage in the settings framework, but is +/// also useful as a standalone copy/paste payload (export bindings from one +/// install, import on another). +/// +/// Missing kind sub-maps deserialize as empty (via `#[serde(default)]`), so +/// an older settings file that only stored button overrides still loads +/// cleanly after axes are added to a project. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActionOverrides { + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + bindings: BTreeMap>, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + axes: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + axes_2d: BTreeMap, +} + +impl ActionOverrides { + /// A new empty overrides set. + pub fn new() -> Self { + Self::default() + } + + /// `true` if no overrides are stored in any kind. + pub fn is_empty(&self) -> bool { + self.bindings.is_empty() && self.axes.is_empty() && self.axes_2d.is_empty() + } + + /// Total number of override entries across all kinds. + pub fn len(&self) -> usize { + self.bindings.len() + self.axes.len() + self.axes_2d.len() + } + + /// The override button bindings for `name`, or `&[]` if none. + pub fn get(&self, name: &str) -> &[Binding] { + self.bindings.get(name).map(Vec::as_slice).unwrap_or(&[]) + } + + /// The override 1D axis bindings for `name`, or `None`. + pub fn get_axis(&self, name: &str) -> Option<&AxisBinding> { + self.axes.get(name) + } + + /// The override 2D axis bindings for `name`, or `None`. + pub fn get_axis_2d(&self, name: &str) -> Option<&Axis2DBinding> { + self.axes_2d.get(name) + } + + /// Iterates `(button action name, bindings)` pairs in name-sorted order. + pub fn iter(&self) -> impl Iterator { + self.bindings + .iter() + .map(|(k, v)| (k.as_str(), v.as_slice())) + } + + /// Iterates `(1D axis name, bindings)` pairs in name-sorted order. + pub fn iter_axes(&self) -> impl Iterator { + self.axes.iter().map(|(k, v)| (k.as_str(), v)) + } + + /// Iterates `(2D axis name, bindings)` pairs in name-sorted order. + pub fn iter_axes_2d(&self) -> impl Iterator { + self.axes_2d.iter().map(|(k, v)| (k.as_str(), v)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use winit::event::MouseButton; + use winit::keyboard::KeyCode; + + fn jump_only() -> ActionMap { + let mut m = ActionMap::new(); + m.register("Jump", [Binding::Key(KeyCode::Space)]); + m + } + + #[test] + fn default_binding_drives_action() { + let actions = jump_only(); + let mut input = InputState::new(); + + input.press_key(KeyCode::Space); + assert!(actions.action_pressed("Jump", &input)); + assert!(actions.action_held("Jump", &input)); + assert!(!actions.action_released("Jump", &input)); + + input.end_frame(); + assert!(!actions.action_pressed("Jump", &input)); + assert!(actions.action_held("Jump", &input)); + + input.release_key(KeyCode::Space); + assert!(actions.action_released("Jump", &input)); + assert!(!actions.action_held("Jump", &input)); + } + + #[test] + fn unregistered_action_returns_false() { + let actions = ActionMap::new(); + let input = InputState::new(); + assert!(!actions.action_pressed("Nope", &input)); + assert!(!actions.action_held("Nope", &input)); + assert!(!actions.action_released("Nope", &input)); + assert_eq!(actions.bindings("Nope"), &[] as &[Binding]); + } + + #[test] + fn multi_bind_first_press_fires_edge_second_does_not() { + let mut actions = ActionMap::new(); + actions.register( + "Jump", + [Binding::Key(KeyCode::Space), Binding::Key(KeyCode::KeyJ)], + ); + let mut input = InputState::new(); + + // Press Space — fires pressed edge. + input.press_key(KeyCode::Space); + assert!(actions.action_pressed("Jump", &input)); + assert!(actions.action_held("Jump", &input)); + input.end_frame(); + + // Now press J while Space still held — must NOT re-fire pressed. + input.press_key(KeyCode::KeyJ); + assert!( + !actions.action_pressed("Jump", &input), + "already-engaged multi-bind must not re-fire pressed" + ); + assert!(actions.action_held("Jump", &input)); + } + + #[test] + fn multi_bind_release_one_keeps_action_held() { + let mut actions = ActionMap::new(); + actions.register( + "Jump", + [Binding::Key(KeyCode::Space), Binding::Key(KeyCode::KeyJ)], + ); + let mut input = InputState::new(); + input.press_key(KeyCode::Space); + input.press_key(KeyCode::KeyJ); + input.end_frame(); + + input.release_key(KeyCode::Space); + assert!( + !actions.action_released("Jump", &input), + "another binding is still held — must not fire released" + ); + assert!(actions.action_held("Jump", &input)); + + input.end_frame(); + input.release_key(KeyCode::KeyJ); + assert!(actions.action_released("Jump", &input)); + assert!(!actions.action_held("Jump", &input)); + } + + #[test] + fn one_key_drives_multiple_actions_simultaneously() { + let mut actions = ActionMap::new(); + actions.register("Jump", [Binding::Key(KeyCode::Space)]); + actions.register("Confirm", [Binding::Key(KeyCode::Space)]); + + let mut input = InputState::new(); + input.press_key(KeyCode::Space); + + assert!(actions.action_pressed("Jump", &input)); + assert!(actions.action_pressed("Confirm", &input)); + assert!(actions.action_held("Jump", &input)); + assert!(actions.action_held("Confirm", &input)); + } + + #[test] + fn runtime_remap_changes_behavior_without_renaming_action() { + let mut actions = jump_only(); + let mut input = InputState::new(); + + // Initially Space → Jump. + input.press_key(KeyCode::Space); + assert!(actions.action_pressed("Jump", &input)); + + // Remap Jump to W. Game code still queries "Jump". + input.end_frame(); + actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]); + assert!( + !actions.action_held("Jump", &input), + "Space must no longer drive Jump after remap" + ); + + input.press_key(KeyCode::KeyW); + assert!(actions.action_pressed("Jump", &input)); + } + + #[test] + fn add_binding_is_idempotent_and_remove_binding_returns_presence() { + let mut actions = jump_only(); + actions.add_binding("Jump", Binding::Key(KeyCode::KeyJ)); + actions.add_binding("Jump", Binding::Key(KeyCode::KeyJ)); // dup ignored + assert_eq!( + actions.bindings("Jump"), + &[Binding::Key(KeyCode::Space), Binding::Key(KeyCode::KeyJ)] + ); + + assert!(actions.remove_binding("Jump", Binding::Key(KeyCode::KeyJ))); + assert!(!actions.remove_binding("Jump", Binding::Key(KeyCode::KeyJ))); + assert_eq!(actions.bindings("Jump"), &[Binding::Key(KeyCode::Space)]); + } + + #[test] + fn restore_defaults_undoes_runtime_remap() { + let mut actions = jump_only(); + actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]); + assert_eq!(actions.bindings("Jump"), &[Binding::Key(KeyCode::KeyW)]); + + actions.restore_defaults("Jump"); + assert_eq!(actions.bindings("Jump"), &[Binding::Key(KeyCode::Space)]); + } + + #[test] + fn re_registering_preserves_remap_but_updates_defaults() { + let mut actions = jump_only(); + actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]); + + // A later code change adds a second default binding. + actions.register( + "Jump", + [ + Binding::Key(KeyCode::Space), + Binding::Mouse(MouseButton::Other(4)), + ], + ); + + // Current bindings (the user's remap) are preserved… + assert_eq!(actions.bindings("Jump"), &[Binding::Key(KeyCode::KeyW)]); + // …but restoring defaults now picks up the new code-defined list. + actions.restore_defaults("Jump"); + assert_eq!( + actions.bindings("Jump"), + &[ + Binding::Key(KeyCode::Space), + Binding::Mouse(MouseButton::Other(4)), + ] + ); + } + + #[test] + fn overrides_round_trip_through_ron() { + let mut actions = ActionMap::new(); + actions.register("Jump", [Binding::Key(KeyCode::Space)]); + actions.register("Fire", [Binding::Mouse(MouseButton::Left)]); + actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]); + + let overrides = actions.overrides(); + let serialized = ron::to_string(&overrides).unwrap(); + let parsed: ActionOverrides = ron::from_str(&serialized).unwrap(); + assert_eq!(parsed, overrides); + + // Applying the round-tripped overrides on a freshly-registered map + // recreates the remap — and unknown actions in the file are skipped. + let mut fresh = ActionMap::new(); + fresh.register("Jump", [Binding::Key(KeyCode::Space)]); + // Note: "Fire" is intentionally NOT registered in `fresh`. + fresh.apply_overrides(&parsed); + assert_eq!(fresh.bindings("Jump"), &[Binding::Key(KeyCode::KeyW)]); + assert!(!fresh.has("Fire"), "unknown action stayed unregistered"); + } + + #[test] + fn apply_overrides_skips_unregistered_actions() { + let mut actions = jump_only(); + let mut overrides = ActionOverrides::new(); + overrides + .bindings + .insert("Phantom".into(), vec![Binding::Key(KeyCode::KeyX)]); + overrides + .bindings + .insert("Jump".into(), vec![Binding::Key(KeyCode::KeyW)]); + + actions.apply_overrides(&overrides); + assert_eq!(actions.bindings("Jump"), &[Binding::Key(KeyCode::KeyW)]); + assert!(!actions.has("Phantom")); + } + + #[test] + fn unregister_drops_the_action() { + let mut actions = jump_only(); + assert!(actions.unregister("Jump")); + assert!(!actions.has("Jump")); + assert!(!actions.unregister("Jump")); + } + + #[test] + fn clear_bindings_makes_action_unfireable_until_restored() { + let mut actions = jump_only(); + actions.clear_bindings("Jump"); + + let mut input = InputState::new(); + input.press_key(KeyCode::Space); + assert!(!actions.action_pressed("Jump", &input)); + assert!(!actions.action_held("Jump", &input)); + + actions.restore_defaults("Jump"); + assert!(actions.action_pressed("Jump", &input)); + } + + // --- Piece 3: 1D/2D axes through ActionMap ---------------------------- + + fn move_x() -> ActionMap { + let mut m = ActionMap::new(); + m.register_axis( + "MoveX", + AxisBinding::new([Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)]), + ); + m + } + + #[test] + fn axis_returns_zero_when_unregistered_or_idle() { + let map = ActionMap::new(); + let input = InputState::new(); + assert_eq!(map.axis("MoveX", &input), 0.0); + assert_eq!(map.axis_2d("Move", &input), Vec2::ZERO); + + let map = move_x(); + let input = InputState::new(); + assert_eq!(map.axis("MoveX", &input), 0.0); + } + + #[test] + fn axis_resolves_positive_and_negative_directions() { + let map = move_x(); + let mut input = InputState::new(); + + input.press_key(KeyCode::KeyD); + assert_eq!(map.axis("MoveX", &input), 1.0); + + input.release_key(KeyCode::KeyD); + input.press_key(KeyCode::KeyA); + assert_eq!(map.axis("MoveX", &input), -1.0); + } + + #[test] + fn axis_remap_changes_binding_without_renaming() { + let mut map = move_x(); + map.set_axis_bindings( + "MoveX", + AxisBinding::new( + [Binding::Key(KeyCode::ArrowRight)], + [Binding::Key(KeyCode::ArrowLeft)], + ), + ); + + let mut input = InputState::new(); + input.press_key(KeyCode::KeyD); + assert_eq!( + map.axis("MoveX", &input), + 0.0, + "old binding no longer drives the axis" + ); + + input.press_key(KeyCode::ArrowRight); + assert_eq!(map.axis("MoveX", &input), 1.0); + + map.restore_axis_defaults("MoveX"); + let mut input = InputState::new(); + input.press_key(KeyCode::KeyD); + assert_eq!(map.axis("MoveX", &input), 1.0); + } + + #[test] + fn axis_2d_resolves_wasd() { + let mut map = ActionMap::new(); + map.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); + assert_eq!(map.axis_2d("Move", &input), Vec2::new(-1.0, 1.0)); + } + + #[test] + fn restore_all_defaults_covers_buttons_and_axes() { + let mut map = ActionMap::new(); + map.register("Jump", [Binding::Key(KeyCode::Space)]); + map.register_axis( + "MoveX", + AxisBinding::new([Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)]), + ); + map.register_axis_2d( + "Move", + Axis2DBinding::new( + [Binding::Key(KeyCode::KeyD)], + [Binding::Key(KeyCode::KeyA)], + [Binding::Key(KeyCode::KeyW)], + [Binding::Key(KeyCode::KeyS)], + ), + ); + + // Remap all three kinds. + map.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyJ)]); + map.set_axis_bindings( + "MoveX", + AxisBinding::new( + [Binding::Key(KeyCode::ArrowRight)], + [Binding::Key(KeyCode::ArrowLeft)], + ), + ); + map.set_axis_2d_bindings( + "Move", + Axis2DBinding::new( + [Binding::Key(KeyCode::ArrowRight)], + [Binding::Key(KeyCode::ArrowLeft)], + [Binding::Key(KeyCode::ArrowUp)], + [Binding::Key(KeyCode::ArrowDown)], + ), + ); + + map.restore_all_defaults(); + + assert_eq!(map.bindings("Jump"), &[Binding::Key(KeyCode::Space)]); + assert_eq!( + map.axis_bindings("MoveX").unwrap().positive, + vec![Binding::Key(KeyCode::KeyD)] + ); + assert_eq!( + map.axis_2d_bindings("Move").unwrap().y.positive, + vec![Binding::Key(KeyCode::KeyW)] + ); + } + + #[test] + fn three_action_kinds_share_a_name_without_conflict() { + // Buttons, 1D axes, and 2D axes have disjoint namespaces. + let mut map = ActionMap::new(); + map.register("Move", [Binding::Key(KeyCode::KeyM)]); + map.register_axis( + "Move", + AxisBinding::new([Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)]), + ); + map.register_axis_2d( + "Move", + Axis2DBinding::new( + [Binding::Key(KeyCode::ArrowRight)], + [Binding::Key(KeyCode::ArrowLeft)], + [Binding::Key(KeyCode::ArrowUp)], + [Binding::Key(KeyCode::ArrowDown)], + ), + ); + + let mut input = InputState::new(); + input.press_key(KeyCode::KeyM); + input.press_key(KeyCode::KeyD); + input.press_key(KeyCode::ArrowUp); + + assert!(map.action_held("Move", &input)); + assert_eq!(map.axis("Move", &input), 1.0); + assert_eq!(map.axis_2d("Move", &input), Vec2::new(0.0, 1.0)); + } + + #[test] + fn overrides_round_trip_includes_axes() { + let mut map = ActionMap::new(); + map.register("Jump", [Binding::Key(KeyCode::Space)]); + map.register_axis( + "MoveX", + AxisBinding::new([Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)]), + ); + map.register_axis_2d( + "Move", + Axis2DBinding::new( + [Binding::Key(KeyCode::KeyD)], + [Binding::Key(KeyCode::KeyA)], + [Binding::Key(KeyCode::KeyW)], + [Binding::Key(KeyCode::KeyS)], + ), + ); + + // Remap each kind. + map.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyJ)]); + map.set_axis_bindings( + "MoveX", + AxisBinding::new( + [Binding::Key(KeyCode::ArrowRight)], + [Binding::Key(KeyCode::ArrowLeft)], + ), + ); + map.set_axis_2d_bindings( + "Move", + Axis2DBinding::new( + [Binding::Key(KeyCode::ArrowRight)], + [Binding::Key(KeyCode::ArrowLeft)], + [Binding::Key(KeyCode::ArrowUp)], + [Binding::Key(KeyCode::ArrowDown)], + ), + ); + + let overrides = map.overrides(); + let s = ron::to_string(&overrides).unwrap(); + let parsed: ActionOverrides = ron::from_str(&s).unwrap(); + assert_eq!(parsed, overrides); + assert_eq!(overrides.len(), 3); + assert!(!overrides.is_empty()); + + // Replay on a freshly-registered map. + let mut fresh = ActionMap::new(); + fresh.register("Jump", [Binding::Key(KeyCode::Space)]); + fresh.register_axis( + "MoveX", + AxisBinding::new([Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)]), + ); + fresh.register_axis_2d( + "Move", + Axis2DBinding::new( + [Binding::Key(KeyCode::KeyD)], + [Binding::Key(KeyCode::KeyA)], + [Binding::Key(KeyCode::KeyW)], + [Binding::Key(KeyCode::KeyS)], + ), + ); + fresh.apply_overrides(&parsed); + + assert_eq!(fresh.bindings("Jump"), &[Binding::Key(KeyCode::KeyJ)]); + assert_eq!( + fresh.axis_bindings("MoveX").unwrap().positive, + vec![Binding::Key(KeyCode::ArrowRight)] + ); + assert_eq!( + fresh.axis_2d_bindings("Move").unwrap().x.negative, + vec![Binding::Key(KeyCode::ArrowLeft)] + ); + } + + #[test] + fn old_settings_file_without_axes_loads_cleanly() { + // A legacy file that only stored button overrides — no `axes` or + // `axes_2d` fields. Must still load, leaving registered axes at + // their defaults. + let legacy_ron = r#"(bindings: {"Jump": [Key(KeyW)]})"#; + let parsed: ActionOverrides = ron::from_str(legacy_ron).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed.get_axis("MoveX"), None); + + let mut map = ActionMap::new(); + map.register("Jump", [Binding::Key(KeyCode::Space)]); + map.register_axis( + "MoveX", + AxisBinding::new([Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)]), + ); + map.apply_overrides(&parsed); + assert_eq!(map.bindings("Jump"), &[Binding::Key(KeyCode::KeyW)]); + // Axis bindings untouched — still defaults. + assert_eq!( + map.axis_bindings("MoveX").unwrap().positive, + vec![Binding::Key(KeyCode::KeyD)] + ); + } + + #[test] + fn unregister_axis_drops_each_kind_independently() { + let mut map = ActionMap::new(); + map.register_axis("MoveX", AxisBinding::new([Binding::Key(KeyCode::KeyD)], [])); + assert!(map.has_axis("MoveX")); + assert!(map.unregister_axis("MoveX")); + assert!(!map.has_axis("MoveX")); + assert!(!map.unregister_axis("MoveX")); + } +} diff --git a/engine/src/input/axis.rs b/engine/src/input/axis.rs new file mode 100644 index 0000000..07b23a2 --- /dev/null +++ b/engine/src/input/axis.rs @@ -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, + /// Bindings that pull the axis toward -1. + pub negative: Vec, +} + +impl AxisBinding { + /// A new axis with the given direction binding lists. + pub fn new( + positive: impl IntoIterator, + negative: impl IntoIterator, + ) -> 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, + left: impl IntoIterator, + up: impl IntoIterator, + down: impl IntoIterator, + ) -> 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); + } +} diff --git a/engine/src/input/binding.rs b/engine/src/input/binding.rs new file mode 100644 index 0000000..255f4f7 --- /dev/null +++ b/engine/src/input/binding.rs @@ -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` 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 = ron::from_str(&s).unwrap(); + assert_eq!(parsed, bindings); + } +} diff --git a/engine/src/input/mod.rs b/engine/src/input/mod.rs new file mode 100644 index 0000000..82b26ac --- /dev/null +++ b/engine/src/input/mod.rs @@ -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; diff --git a/engine/src/input/state.rs b/engine/src/input/state.rs new file mode 100644 index 0000000..05b28f3 --- /dev/null +++ b/engine/src/input/state.rs @@ -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, + keys_pressed: HashSet, + keys_released: HashSet, + + mouse_held: HashSet, + mouse_pressed: HashSet, + mouse_released: HashSet, + + cursor: Option, + 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 + '_ { + 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 + '_ { + 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 { + 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 = input.keys_held().collect(); + assert_eq!(held, HashSet::from([KeyCode::KeyW])); + } +} diff --git a/engine/src/layer/components.rs b/engine/src/layer/components.rs new file mode 100644 index 0000000..0e49ef7 --- /dev/null +++ b/engine/src/layer/components.rs @@ -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); + +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) -> 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) -> 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 { + 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> FromIterator for Tags { + fn from_iter>(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!["Apple", "Mango", "Zebra"] + ); + let ron = ron::to_string(&tags).unwrap(); + let back: Tags = ron::from_str(&ron).unwrap(); + assert_eq!(tags, back); + } +} diff --git a/engine/src/layer/groups.rs b/engine/src/layer/groups.rs new file mode 100644 index 0000000..f3bfd83 --- /dev/null +++ b/engine/src/layer/groups.rs @@ -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, +} + +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) -> 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 { + 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> FromIterator for GroupRegistry { + fn from_iter>(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!["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); + } +} diff --git a/engine/src/layer/mask.rs b/engine/src/layer/mask.rs new file mode 100644 index 0000000..7109c35 --- /dev/null +++ b/engine/src/layer/mask.rs @@ -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 { + (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 for LayerMask { + /// Builds a mask from layer indices. Each index must be `0..32`. + fn from_iter>(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![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); + } +} diff --git a/engine/src/layer/mod.rs b/engine/src/layer/mod.rs new file mode 100644 index 0000000..bb560e2 --- /dev/null +++ b/engine/src/layer/mod.rs @@ -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; diff --git a/engine/src/layer/registry.rs b/engine/src/layer/registry.rs new file mode 100644 index 0000000..7673554 --- /dev/null +++ b/engine/src/layer/registry.rs @@ -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>, +} + +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) { + 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 { + 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, + { + 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 { + 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); + } +} diff --git a/engine/src/lib.rs b/engine/src/lib.rs new file mode 100644 index 0000000..510ddd3 --- /dev/null +++ b/engine/src/lib.rs @@ -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}; +} diff --git a/engine/src/math/aabb.rs b/engine/src/math/aabb.rs new file mode 100644 index 0000000..e44a7ef --- /dev/null +++ b/engine/src/math/aabb.rs @@ -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) -> 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 { + 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); + } +} diff --git a/engine/src/math/color.rs b/engine/src/math/color.rs new file mode 100644 index 0000000..05cdf7b --- /dev/null +++ b/engine/src/math/color.rs @@ -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); + } +} diff --git a/engine/src/math/frustum.rs b/engine/src/math/frustum.rs new file mode 100644 index 0000000..6b3e1c5 --- /dev/null +++ b/engine/src/math/frustum.rs @@ -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))); + } +} diff --git a/engine/src/math/mod.rs b/engine/src/math/mod.rs new file mode 100644 index 0000000..d36d020 --- /dev/null +++ b/engine/src/math/mod.rs @@ -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}; diff --git a/engine/src/math/plane.rs b/engine/src/math/plane.rs new file mode 100644 index 0000000..b26e781 --- /dev/null +++ b/engine/src/math/plane.rs @@ -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 { + 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); + } +} diff --git a/engine/src/math/range3.rs b/engine/src/math/range3.rs new file mode 100644 index 0000000..5bfc370 --- /dev/null +++ b/engine/src/math/range3.rs @@ -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))); + } +} diff --git a/engine/src/math/ray.rs b/engine/src/math/ray.rs new file mode 100644 index 0000000..5711a63 --- /dev/null +++ b/engine/src/math/ray.rs @@ -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); + } +} diff --git a/engine/src/math/rect.rs b/engine/src/math/rect.rs new file mode 100644 index 0000000..9415b71 --- /dev/null +++ b/engine/src/math/rect.rs @@ -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))); + } +} diff --git a/engine/src/math/transform.rs b/engine/src/math/transform.rs new file mode 100644 index 0000000..4bc82e8 --- /dev/null +++ b/engine/src/math/transform.rs @@ -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)); + } +} diff --git a/engine/src/prefab.rs b/engine/src/prefab.rs new file mode 100644 index 0000000..987a0dc --- /dev/null +++ b/engine/src/prefab.rs @@ -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"); +//! +//! // 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, ron: impl Into) -> 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(type_name: impl Into, value: &T) -> Option { + 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, +} + +impl Prefab { + /// An empty prefab (spawns a bare node with just the node-baked components). + pub fn new(name: impl Into) -> 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, +} + +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 + '_ { + 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 { + 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 { + 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 { + 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"); + r.register_reflected::("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!["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); + } +} diff --git a/engine/src/project.rs b/engine/src/project.rs new file mode 100644 index 0000000..2dd66d6 --- /dev/null +++ b/engine/src/project.rs @@ -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, + /// Per-project settings as opaque RON blobs, keyed by section name. The + /// typed settings framework round-trips its sections through here. + pub settings: BTreeMap, +} + +impl ProjectMeta { + fn new(name: impl Into) -> 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, name: impl Into) -> Result { + 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) -> Result { + 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) { + 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) { + 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, ron: impl Into) { + 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, + #[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) { + 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) -> 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) -> 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(); + } +} diff --git a/engine/src/reflect.rs b/engine/src/reflect.rs new file mode 100644 index 0000000..fa80f32 --- /dev/null +++ b/engine/src/reflect.rs @@ -0,0 +1,1006 @@ +//! Reflection / type registry — generic, name-keyed access to components. +//! +//! The engine's *dual-editable types* principle says every component must 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. +//! +//! A type is registered **once** under a stable name: +//! +//! ``` +//! use oxide_engine::reflect::TypeRegistry; +//! use oxide_engine::prelude::*; +//! +//! let mut registry = TypeRegistry::new(); +//! registry.register::("Transform"); +//! registry.register::("Node"); +//! ``` +//! +//! From then on, any caller holding only the *name* can round-trip the component +//! on an entity as RON text — which is all a generic inspector or a script needs: +//! +//! ``` +//! # use oxide_engine::reflect::TypeRegistry; +//! # use oxide_engine::prelude::*; +//! # let mut registry = TypeRegistry::new(); +//! # registry.register::("Transform"); +//! let mut scene = Scene::new(); +//! let e = scene.spawn("thing", Transform::IDENTITY); +//! +//! // Read it generically... +//! let ron = registry.get_ron(scene.world(), e, "Transform").unwrap(); +//! // ...and write it back generically, no concrete type at the call site. +//! registry.set_ron(scene.world_mut(), e, "Transform", &ron).unwrap(); +//! ``` +//! +//! That path is **whole-value** reflection (the unit is one component, +//! serialized). On top of it, [`register_reflected`](TypeRegistry::register_reflected) +//! adds **per-field** reflection — named fields each addressable on their own — +//! for types that derive [`Reflect`], which is what a Unity/Godot-style +//! inspector needs to render one widget per field. Both coexist: the registry +//! addresses *types* by name, while [`Reflect`] addresses *fields* within a +//! value. + +use std::collections::BTreeMap; + +use hecs::{Component, Entity, World}; +use serde::de::DeserializeOwned; +use serde::Serialize; + +/// `#[derive(Reflect)]` — generates the [`Reflect`] impl for a struct's public +/// fields. Shares its name with the [`Reflect`] trait (macro vs. type +/// namespace), exactly like `serde`'s `Serialize`. +pub use oxide_engine_derive::Reflect; + +/// `#[derive(ReflectEnum)]` — generates the [`ReflectEnum`] impl for a fieldless +/// enum, exposing its variant names for inspector dropdowns. +pub use oxide_engine_derive::ReflectEnum; + +/// Errors from generic, name-keyed component access. +#[derive(Debug, thiserror::Error)] +pub enum ReflectError { + /// No type was registered under this name. + #[error("no registered type named '{0}'")] + UnknownType(String), + + /// The entity is not live in the world. + #[error("entity is not live in this world")] + NoSuchEntity, + + /// The entity is live but does not carry this component. + #[error("entity has no component '{0}'")] + Missing(String), + + /// The RON text could not be parsed into the named type. + #[error("failed to parse '{type_name}': {message}")] + Parse { + /// The registered name being parsed. + type_name: String, + /// The underlying parser message. + message: String, + }, + + /// A per-field operation named a field this type does not reflect. + #[error("no reflected field named '{0}'")] + UnknownField(String), + + /// A per-field operation targeted a type registered for whole-value access + /// only (registered with `register`, not `register_reflected`). + #[error("type '{0}' is not field-reflected")] + NotReflected(String), + + /// The RON text could not be parsed into a single field's type. + #[error("failed to parse field '{field}': {message}")] + FieldParse { + /// The field being parsed. + field: String, + /// The underlying parser message. + message: String, + }, +} + +/// Per-field reflection generated by `#[derive(Reflect)]`. +/// +/// Whole-value reflection ([`TypeRegistry::get_ron`] / [`set_ron`]) is enough +/// for serialization and scripts, but a Unity/Godot-style inspector needs to +/// see *named fields* so it can render one widget per field. `Reflect` +/// provides exactly that, without exposing the concrete field types to the +/// caller: each field is addressed by name and round-trips as RON (the same +/// representation the whole-value path uses). +/// +/// Implement it with the derive — see +/// [`oxide_engine_derive::Reflect`](Reflect) (re-exported here as the +/// derive macro of the same name). Only **public** fields are reflected; +/// annotate a public field with `#[reflect(skip)]` to exclude it. +/// +/// [`set_ron`]: TypeRegistry::set_ron +pub trait Reflect { + /// Static descriptors for every reflected field, in declaration order. + fn fields(&self) -> &'static [FieldInfo]; + + /// Serialize one field's current value to RON, or `None` if no field of + /// that name is reflected. + fn get_field(&self, name: &str) -> Option; + + /// Parse `value` (RON) into the named field, replacing it. + /// + /// # Errors + /// [`UnknownField`](ReflectError::UnknownField) if the name isn't a + /// reflected field, or [`FieldParse`](ReflectError::FieldParse) if the + /// text isn't valid for the field's type. + fn set_field(&mut self, name: &str, value: &str) -> Result<(), ReflectError>; +} + +/// A fieldless enum whose variants can be listed by name. +/// +/// Per-field reflection tells the inspector a field's *type name* but not, for +/// an enum-typed field, the set of values it may take. `ReflectEnum` supplies +/// that list so the inspector can render a dropdown instead of a free-text RON +/// box. Register the enum with +/// [`register_enum`](TypeRegistry::register_enum) and the inspector looks its +/// variants up by type name. Derive it with `#[derive(ReflectEnum)]` (unit +/// variants only). +pub trait ReflectEnum { + /// The enum's variant names, in declaration order. Each is valid RON for + /// the corresponding unit variant, so it round-trips through + /// [`get_field`](TypeRegistry::get_field) / [`set_field`](TypeRegistry::set_field). + fn variants() -> &'static [&'static str]; +} + +/// A static description of one reflected field. +/// +/// `type_name` is the field type's *syntactic* spelling (e.g. `"f32"`, +/// `"bool"`, `"Vec3"`, `"Handle < Font >"`) as written in the source. A +/// generic inspector dispatches a widget on it and falls back to a raw RON +/// editor for types it doesn't recognize. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FieldInfo { + /// The field's identifier. + pub name: &'static str, + /// The field type's syntactic name. + pub type_name: &'static str, + /// `(min, max)` bounds set with `#[reflect(min = X, max = Y)]`. The + /// inspector uses this to render a `Slider` for a `f32` field whose value + /// is normalized (e.g. metallic / roughness in `0..=1`); for plain numeric + /// fields it stays `None` and a `DragValue` is used instead. + pub range: Option<(f32, f32)>, +} + +/// Implementation detail of `#[derive(Reflect)]` — serialize a field to RON. +/// +/// Generated code calls this so it never needs `ron` in scope itself. +#[doc(hidden)] +pub fn __reflect_to_ron(value: &T) -> Option { + ron::to_string(value).ok() +} + +/// Implementation detail of `#[derive(Reflect)]` — parse a field from RON. +#[doc(hidden)] +pub fn __reflect_from_ron( + field: &str, + value: &str, +) -> Result { + ron::from_str(value).map_err(|err| ReflectError::FieldParse { + field: field.to_string(), + message: err.to_string(), + }) +} + +/// The monomorphized operations for one registered type, stored as plain +/// function pointers (the closures capture nothing, so they coerce to `fn`). +struct ReflectedType { + get_ron: fn(&World, Entity) -> Option, + set_ron: fn(&mut World, Entity, &str) -> Result<(), String>, + has: fn(&World, Entity) -> bool, + remove: fn(&mut World, Entity) -> bool, + /// Per-field operations, present only for types registered with + /// [`register_reflected`](TypeRegistry::register_reflected) (i.e. `T: + /// Reflect`). `None` for whole-value-only types. The registry callers + /// guarantee the component is present before invoking `get`/`set`. + fields: Option, + /// Inserts a `T::default()` on an entity, present only for types registered + /// with [`register_addable`](TypeRegistry::register_addable) (i.e. `T: + /// Default`). `None` means the type can't be added from a generic "Add + /// Component" menu (no zero-arg construction). + add_default: Option, +} + +/// The `T: Reflect` field operations, type-erased to function pointers. +struct FieldOps { + infos: fn(&World, Entity) -> Option<&'static [FieldInfo]>, + get: fn(&World, Entity, &str) -> Option, + set: fn(&mut World, Entity, &str, &str) -> Result<(), ReflectError>, +} + +/// A registry mapping stable type names to type-erased component operations. +/// +/// Owned by the app/module system (Stage 5): each module registers the component +/// types it introduces, so the editor and scripts can address any of them by +/// name. Names are the identity used in serialized data and UI, so they should +/// be stable across versions. +#[derive(Default)] +pub struct TypeRegistry { + types: BTreeMap<&'static str, ReflectedType>, + /// Variant lists for registered enum types, keyed by the same syntactic + /// type name a [`FieldInfo::type_name`] carries, so the inspector can turn + /// an enum-typed field into a dropdown. + enums: BTreeMap<&'static str, &'static [&'static str]>, +} + +impl TypeRegistry { + /// An empty registry. + pub fn new() -> Self { + Self::default() + } + + /// Registers component type `T` under `name`. + /// + /// `T` must be an ECS component (`Send + Sync + 'static`) and round-trip + /// through `serde`. Re-registering the same name replaces the entry. + pub fn register(&mut self, name: &'static str) + where + T: Component + Serialize + DeserializeOwned, + { + self.types.insert( + name, + ReflectedType { + get_ron: |world, e| { + world + .get::<&T>(e) + .ok() + .and_then(|c| ron::to_string(&*c).ok()) + }, + set_ron: |world, e, text| { + let value: T = ron::from_str(text).map_err(|err| err.to_string())?; + // `contains` is checked by the caller, so insert cannot fail + // for a missing entity; map defensively all the same. + world + .insert_one(e, value) + .map_err(|_| "entity is not live".to_string()) + }, + has: |world, e| world.get::<&T>(e).is_ok(), + remove: |world, e| world.remove_one::(e).is_ok(), + fields: None, + add_default: None, + }, + ); + } + + /// Registers component type `T` with **per-field** reflection in addition + /// to whole-value access. + /// + /// Identical to [`register`](Self::register) but also wires the + /// [`Reflect`] field operations, so [`field_infos`](Self::field_infos) / + /// [`get_field`](Self::get_field) / [`set_field`](Self::set_field) work for + /// this type. This is what lets the editor render a widget per field. Use + /// it for any type whose fields should be individually editable; use + /// `register` for opaque types edited only as a whole. + pub fn register_reflected(&mut self, name: &'static str) + where + T: Component + Serialize + DeserializeOwned + Reflect, + { + self.types.insert( + name, + ReflectedType { + get_ron: |world, e| { + world + .get::<&T>(e) + .ok() + .and_then(|c| ron::to_string(&*c).ok()) + }, + set_ron: |world, e, text| { + let value: T = ron::from_str(text).map_err(|err| err.to_string())?; + world + .insert_one(e, value) + .map_err(|_| "entity is not live".to_string()) + }, + has: |world, e| world.get::<&T>(e).is_ok(), + remove: |world, e| world.remove_one::(e).is_ok(), + fields: Some(FieldOps { + infos: |world, e| world.get::<&T>(e).ok().map(|c| c.fields()), + get: |world, e, field| world.get::<&T>(e).ok().and_then(|c| c.get_field(field)), + set: |world, e, field, ron| { + // The registry verifies the component is present before + // calling, so this access cannot fail. + let mut c = world + .get::<&mut T>(e) + .expect("component present (checked by caller)"); + c.set_field(field, ron) + }, + }), + add_default: None, + }, + ); + } + + /// Registers a reflected component type that can also be **added from a + /// generic "Add Component" menu** — `T` must be `Default`, which supplies + /// the value inserted on the entity. + /// + /// Equivalent to [`register_reflected`](Self::register_reflected) plus a + /// zero-arg constructor. Use it for components a user can attach in the + /// editor; use `register_reflected` for components that only exist + /// implicitly (every entity already has them) or that have no sensible + /// default. + pub fn register_addable(&mut self, name: &'static str) + where + T: Component + Serialize + DeserializeOwned + Reflect + Default, + { + self.register_reflected::(name); + if let Some(reflected) = self.types.get_mut(name) { + reflected.add_default = Some(|world, e| { + let _ = world.insert_one(e, T::default()); + }); + } + } + + /// Removes the type registered under `name`. Returns whether it existed. + pub fn unregister(&mut self, name: &str) -> bool { + self.types.remove(name).is_some() + } + + /// Whether a type is registered under `name`. + pub fn is_registered(&self, name: &str) -> bool { + self.types.contains_key(name) + } + + /// The number of registered types. + pub fn len(&self) -> usize { + self.types.len() + } + + /// Whether no types are registered. + pub fn is_empty(&self) -> bool { + self.types.is_empty() + } + + /// The names of every registered type, sorted. + pub fn names(&self) -> impl Iterator + '_ { + self.types.keys().copied() + } + + /// Serializes the named component on `entity` to RON. + /// + /// # Errors + /// [`UnknownType`](ReflectError::UnknownType) if the name is not registered, + /// [`NoSuchEntity`](ReflectError::NoSuchEntity) if the entity is dead, or + /// [`Missing`](ReflectError::Missing) if the entity lacks the component. + pub fn get_ron( + &self, + world: &World, + entity: Entity, + type_name: &str, + ) -> Result { + let reflected = self.lookup(type_name)?; + if !world.contains(entity) { + return Err(ReflectError::NoSuchEntity); + } + (reflected.get_ron)(world, entity) + .ok_or_else(|| ReflectError::Missing(type_name.to_string())) + } + + /// Parses `ron` into the named type and writes it onto `entity`, inserting + /// the component if absent or replacing it if present. + /// + /// # Errors + /// [`UnknownType`](ReflectError::UnknownType), [`NoSuchEntity`](ReflectError::NoSuchEntity), + /// or [`Parse`](ReflectError::Parse) if the text is not valid for the type. + pub fn set_ron( + &self, + world: &mut World, + entity: Entity, + type_name: &str, + ron: &str, + ) -> Result<(), ReflectError> { + let reflected = self.lookup(type_name)?; + if !world.contains(entity) { + return Err(ReflectError::NoSuchEntity); + } + (reflected.set_ron)(world, entity, ron).map_err(|message| ReflectError::Parse { + type_name: type_name.to_string(), + message, + }) + } + + /// Whether `entity` carries the named component. + /// + /// # Errors + /// [`UnknownType`](ReflectError::UnknownType) if the name is not registered. + pub fn has( + &self, + world: &World, + entity: Entity, + type_name: &str, + ) -> Result { + let reflected = self.lookup(type_name)?; + Ok(world.contains(entity) && (reflected.has)(world, entity)) + } + + /// Removes the named component from `entity`. Returns whether it was present. + /// + /// # Errors + /// [`UnknownType`](ReflectError::UnknownType) if the name is not registered. + pub fn remove( + &self, + world: &mut World, + entity: Entity, + type_name: &str, + ) -> Result { + let reflected = self.lookup(type_name)?; + if !world.contains(entity) { + return Ok(false); + } + Ok((reflected.remove)(world, entity)) + } + + /// The names of all *registered* component types currently on `entity`, + /// sorted. This is what a generic inspector iterates to show every editable + /// component without knowing any concrete types. + pub fn components_on(&self, world: &World, entity: Entity) -> Vec<&'static str> { + if !world.contains(entity) { + return Vec::new(); + } + self.types + .iter() + .filter(|(_, r)| (r.has)(world, entity)) + .map(|(name, _)| *name) + .collect() + } + + /// Whether the named type was registered with per-field reflection + /// ([`register_reflected`](Self::register_reflected)). + pub fn is_reflected(&self, type_name: &str) -> bool { + matches!(self.types.get(type_name), Some(r) if r.fields.is_some()) + } + + /// Registers a fieldless enum `E` under `name` (the syntactic type name its + /// fields carry), so [`enum_variants`](Self::enum_variants) can list its + /// values for an inspector dropdown. Independent of component registration — + /// an enum is a field *type*, not a component. + pub fn register_enum(&mut self, name: &'static str) + where + E: ReflectEnum, + { + self.enums.insert(name, E::variants()); + } + + /// The variant names of an enum type registered with + /// [`register_enum`](Self::register_enum), or `None` if the type name isn't + /// a registered enum. The inspector renders a dropdown when this is `Some`. + pub fn enum_variants(&self, type_name: &str) -> Option<&'static [&'static str]> { + self.enums.get(type_name).copied() + } + + /// Whether the named type can be added from a generic "Add Component" menu + /// ([`register_addable`](Self::register_addable)). + pub fn is_addable(&self, type_name: &str) -> bool { + matches!(self.types.get(type_name), Some(r) if r.add_default.is_some()) + } + + /// The names of every addable component type, sorted — what an "Add + /// Component" menu lists. + pub fn addable_names(&self) -> impl Iterator + '_ { + self.types + .iter() + .filter(|(_, r)| r.add_default.is_some()) + .map(|(name, _)| *name) + } + + /// Adds a default-constructed instance of the named component to `entity`, + /// if the type is addable and the entity doesn't already carry it. Returns + /// whether a component was inserted. + /// + /// # Errors + /// [`UnknownType`](ReflectError::UnknownType) if the name isn't registered, + /// or [`NoSuchEntity`](ReflectError::NoSuchEntity) if the entity is dead. + pub fn add_default( + &self, + world: &mut World, + entity: Entity, + type_name: &str, + ) -> Result { + let reflected = self.lookup(type_name)?; + if !world.contains(entity) { + return Err(ReflectError::NoSuchEntity); + } + let Some(make) = reflected.add_default else { + return Ok(false); + }; + // Don't clobber an existing component — "add" is a no-op if present. + if (reflected.has)(world, entity) { + return Ok(false); + } + make(world, entity); + Ok(true) + } + + /// The field descriptors of the named component on `entity`. + /// + /// This is what a generic inspector iterates to render one widget per + /// field. Returns [`NotReflected`](ReflectError::NotReflected) for types + /// registered for whole-value access only. + /// + /// # Errors + /// [`UnknownType`](ReflectError::UnknownType), + /// [`NoSuchEntity`](ReflectError::NoSuchEntity), + /// [`NotReflected`](ReflectError::NotReflected), or + /// [`Missing`](ReflectError::Missing) if the entity lacks the component. + pub fn field_infos( + &self, + world: &World, + entity: Entity, + type_name: &str, + ) -> Result<&'static [FieldInfo], ReflectError> { + let reflected = self.lookup(type_name)?; + if !world.contains(entity) { + return Err(ReflectError::NoSuchEntity); + } + let ops = reflected + .fields + .as_ref() + .ok_or_else(|| ReflectError::NotReflected(type_name.to_string()))?; + (ops.infos)(world, entity).ok_or_else(|| ReflectError::Missing(type_name.to_string())) + } + + /// Serializes one field of the named component on `entity` to RON. + /// + /// # Errors + /// As [`field_infos`](Self::field_infos), plus + /// [`UnknownField`](ReflectError::UnknownField) if the type has no such + /// field. + pub fn get_field( + &self, + world: &World, + entity: Entity, + type_name: &str, + field: &str, + ) -> Result { + let reflected = self.lookup(type_name)?; + if !world.contains(entity) { + return Err(ReflectError::NoSuchEntity); + } + let ops = reflected + .fields + .as_ref() + .ok_or_else(|| ReflectError::NotReflected(type_name.to_string()))?; + if !(reflected.has)(world, entity) { + return Err(ReflectError::Missing(type_name.to_string())); + } + (ops.get)(world, entity, field).ok_or_else(|| ReflectError::UnknownField(field.to_string())) + } + + /// Parses `ron` into one field of the named component on `entity`. + /// + /// Only the named field changes; the rest of the component is untouched — + /// this is the granularity an inspector edit needs. + /// + /// # Errors + /// As [`field_infos`](Self::field_infos), plus + /// [`UnknownField`](ReflectError::UnknownField) or + /// [`FieldParse`](ReflectError::FieldParse). + pub fn set_field( + &self, + world: &mut World, + entity: Entity, + type_name: &str, + field: &str, + ron: &str, + ) -> Result<(), ReflectError> { + let reflected = self.lookup(type_name)?; + if !world.contains(entity) { + return Err(ReflectError::NoSuchEntity); + } + let ops = reflected + .fields + .as_ref() + .ok_or_else(|| ReflectError::NotReflected(type_name.to_string()))?; + if !(reflected.has)(world, entity) { + return Err(ReflectError::Missing(type_name.to_string())); + } + (ops.set)(world, entity, field, ron) + } + + fn lookup(&self, type_name: &str) -> Result<&ReflectedType, ReflectError> { + self.types + .get(type_name) + .ok_or_else(|| ReflectError::UnknownType(type_name.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::{Transform, Vec3}; + use crate::scene::{Node, Scene}; + use serde::Deserialize; + + fn registry() -> TypeRegistry { + let mut r = TypeRegistry::new(); + r.register_reflected::("Transform"); + r.register_reflected::("Node"); + r + } + + #[test] + fn registration_is_listed_and_sorted() { + let r = registry(); + assert!(r.is_registered("Transform")); + assert!(!r.is_registered("Nope")); + assert_eq!(r.len(), 2); + assert_eq!(r.names().collect::>(), vec!["Node", "Transform"]); + } + + #[test] + fn get_then_set_round_trips_generically() { + let r = registry(); + let mut scene = Scene::new(); + let e = scene.spawn( + "thing", + Transform::from_translation(Vec3::new(1.0, 2.0, 3.0)), + ); + + // Read generically (no Transform type named at this call site beyond the + // string), then write it straight back. + let ron = r.get_ron(scene.world(), e, "Transform").unwrap(); + r.set_ron(scene.world_mut(), e, "Transform", &ron).unwrap(); + + // The value survived the round trip. + let after = scene.local_transform(e).unwrap(); + assert!((after.translation - Vec3::new(1.0, 2.0, 3.0)).length() < 1e-6); + } + + #[test] + fn set_can_mutate_through_the_text() { + let r = registry(); + let mut scene = Scene::new(); + let e = scene.spawn("thing", Transform::IDENTITY); + + // Hand-edit the serialized form (as the inspector / a script would) and + // apply it. + let edited = + ron::to_string(&Transform::from_translation(Vec3::new(5.0, 0.0, 0.0))).unwrap(); + r.set_ron(scene.world_mut(), e, "Transform", &edited) + .unwrap(); + assert!((scene.local_transform(e).unwrap().translation.x - 5.0).abs() < 1e-6); + } + + #[test] + fn components_on_lists_present_registered_types() { + let r = registry(); + let mut scene = Scene::new(); + let e = scene.spawn("thing", Transform::IDENTITY); // has Node + Transform + assert_eq!(r.components_on(scene.world(), e), vec!["Node", "Transform"]); + + // Removing one drops it from the listing. + assert!(r.remove(scene.world_mut(), e, "Transform").unwrap()); + assert_eq!(r.components_on(scene.world(), e), vec!["Node"]); + assert!(!r.has(scene.world(), e, "Transform").unwrap()); + } + + #[test] + fn errors_are_specific() { + let r = registry(); + let mut scene = Scene::new(); + let e = scene.spawn("thing", Transform::IDENTITY); + + // Unknown type name. + assert!(matches!( + r.get_ron(scene.world(), e, "Ghost"), + Err(ReflectError::UnknownType(_)) + )); + // Live entity missing the component. + scene.world_mut().remove_one::(e).unwrap(); + assert!(matches!( + r.get_ron(scene.world(), e, "Node"), + Err(ReflectError::Missing(_)) + )); + // Dead entity. + let dead = scene.spawn("dead", Transform::IDENTITY); + scene.despawn(dead, crate::scene::DespawnPolicy::Recursive); + assert!(matches!( + r.get_ron(scene.world(), dead, "Transform"), + Err(ReflectError::NoSuchEntity) + )); + // Malformed RON. + assert!(matches!( + r.set_ron(scene.world_mut(), e, "Transform", "not valid ron"), + Err(ReflectError::Parse { .. }) + )); + } + + // --- Per-field reflection (`#[derive(Reflect)]`) --- + + /// A representative component: a mix of field types, a skipped public + /// field, and a private field — exercises the derive's selection rules. + #[derive(Reflect, Serialize, Deserialize, PartialEq, Debug)] + struct Timer { + pub repeating: bool, + pub duration: f32, + pub label: String, + #[reflect(skip)] + pub elapsed: f32, + // Private: never reflected regardless of `skip`. + _internal: u32, + } + + impl Timer { + fn sample() -> Self { + Self { + repeating: true, + duration: 2.5, + label: "tick".to_string(), + elapsed: 1.0, + _internal: 7, + } + } + } + + /// A struct whose normalized fields carry slider ranges via the new + /// `#[reflect(min, max)]` attribute. The inspector dispatches a `Slider` + /// instead of a `DragValue` when both bounds are present. + #[derive(Reflect, Serialize, Deserialize)] + struct Knobs { + #[reflect(min = 0.0, max = 1.0)] + pub gain: f32, + pub bias: f32, + } + + #[derive(Reflect, Serialize, Deserialize)] + struct Wrap(pub i32, pub bool); + + #[test] + fn derive_supports_tuple_structs_with_positional_field_names() { + // Tuple-struct field names round-trip as "0", "1", ... — matching + // Rust's own positional accessors. Lets one-field newtype components + // like `Layer(pub LayerMask)` reflect without a wrapper. + let mut w = Wrap(42, false); + let names: Vec<_> = w.fields().iter().map(|f| f.name).collect(); + assert_eq!(names, ["0", "1"]); + assert_eq!(w.get_field("0").as_deref(), Some("42")); + w.set_field("1", "true").unwrap(); + assert!(w.1); + } + + #[test] + fn derive_captures_min_max_attributes_as_field_range() { + let k = Knobs { + gain: 0.5, + bias: 0.0, + }; + let fields = k.fields(); + let gain = fields.iter().find(|f| f.name == "gain").unwrap(); + let bias = fields.iter().find(|f| f.name == "bias").unwrap(); + assert_eq!(gain.range, Some((0.0_f32, 1.0_f32))); + assert_eq!(bias.range, None); + } + + #[test] + fn derive_lists_only_public_non_skipped_fields_in_order() { + let t = Timer::sample(); + let names: Vec<_> = t.fields().iter().map(|f| f.name).collect(); + assert_eq!(names, ["repeating", "duration", "label"]); + // Syntactic type names are preserved for inspector widget dispatch. + let types: Vec<_> = t.fields().iter().map(|f| f.type_name).collect(); + assert_eq!(types, ["bool", "f32", "String"]); + } + + #[test] + fn derive_gets_each_field_as_ron() { + let t = Timer::sample(); + assert_eq!(t.get_field("repeating").as_deref(), Some("true")); + assert_eq!(t.get_field("duration").as_deref(), Some("2.5")); + assert_eq!(t.get_field("label").as_deref(), Some("\"tick\"")); + // Skipped + private + unknown all read as None. + assert_eq!(t.get_field("elapsed"), None); + assert_eq!(t.get_field("_internal"), None); + assert_eq!(t.get_field("nope"), None); + } + + #[test] + fn derive_sets_a_single_field_without_touching_others() { + let mut t = Timer::sample(); + t.set_field("duration", "9.0").unwrap(); + t.set_field("repeating", "false").unwrap(); + assert_eq!(t.duration, 9.0); + assert!(!t.repeating); + // Other fields are untouched. + assert_eq!(t.label, "tick"); + assert_eq!(t.elapsed, 1.0); + } + + #[test] + fn derive_set_reports_unknown_field_and_parse_errors() { + let mut t = Timer::sample(); + assert!(matches!( + t.set_field("elapsed", "0.0"), // public but skipped → not reflected + Err(ReflectError::UnknownField(f)) if f == "elapsed" + )); + assert!(matches!( + t.set_field("missing", "0.0"), + Err(ReflectError::UnknownField(_)) + )); + assert!(matches!( + t.set_field("duration", "not a float"), + Err(ReflectError::FieldParse { field, .. }) if field == "duration" + )); + } + + #[test] + fn registry_lists_fields_of_a_reflected_component() { + let r = registry(); + let mut scene = Scene::new(); + let e = scene.spawn("thing", Transform::IDENTITY); + assert!(r.is_reflected("Transform")); + let names: Vec<_> = r + .field_infos(scene.world(), e, "Transform") + .unwrap() + .iter() + .map(|f| f.name) + .collect(); + assert_eq!(names, ["translation", "rotation", "scale"]); + } + + #[test] + fn registry_gets_and_sets_one_field_through_the_world() { + let r = registry(); + let mut scene = Scene::new(); + let e = scene.spawn("thing", Transform::IDENTITY); + + // Set just the translation; rotation/scale stay identity. glam's Vec3 + // serializes as a tuple, so RON is `(1.0,2.0,3.0)`. + r.set_field( + scene.world_mut(), + e, + "Transform", + "translation", + "(1.0, 2.0, 3.0)", + ) + .unwrap(); + let t = scene.world().get::<&Transform>(e).unwrap(); + assert_eq!(t.translation, Vec3::new(1.0, 2.0, 3.0)); + assert_eq!(t.scale, Vec3::ONE); + drop(t); + + let got = r + .get_field(scene.world(), e, "Transform", "translation") + .unwrap(); + assert_eq!(got, "(1.0,2.0,3.0)"); + } + + #[test] + fn registry_field_access_errors_are_specific() { + let mut r = registry(); + // A whole-value-only type → NotReflected on field access. + r.register::("TimerWhole"); + let mut scene = Scene::new(); + let e = scene.spawn("thing", Transform::IDENTITY); + scene.world_mut().insert_one(e, TimerWhole(1)).unwrap(); + + assert!(!r.is_reflected("TimerWhole")); + assert!(matches!( + r.field_infos(scene.world(), e, "TimerWhole"), + Err(ReflectError::NotReflected(_)) + )); + // Unknown field on a reflected type. + assert!(matches!( + r.get_field(scene.world(), e, "Transform", "nope"), + Err(ReflectError::UnknownField(_)) + )); + // Reflected type, but the entity lacks the component. + let bare = scene.spawn("bare", Transform::IDENTITY); + scene.world_mut().remove_one::(bare).unwrap(); + assert!(matches!( + r.get_field(scene.world(), bare, "Node", "name"), + Err(ReflectError::Missing(_)) + )); + } + + #[derive(Serialize, Deserialize)] + struct TimerWhole(u32); + + // --- Enum reflection (`#[derive(ReflectEnum)]`) --- + + #[derive(ReflectEnum, Serialize, Deserialize, PartialEq, Debug)] + enum Facing { + North, + East, + South, + West, + } + + #[test] + fn derive_enum_lists_variants_in_order() { + assert_eq!(Facing::variants(), &["North", "East", "South", "West"]); + } + + #[test] + fn variant_names_round_trip_as_ron() { + // The names ReflectEnum returns must be valid RON for the variant, so + // the inspector can write a chosen name straight back through set_field. + for name in Facing::variants() { + let value: Facing = ron::from_str(name).unwrap(); + assert_eq!(&ron::to_string(&value).unwrap(), name); + } + } + + #[test] + fn addable_component_can_be_added_by_name_and_listed() { + use crate::render::MeshRenderer; + let mut r = registry(); + r.register_addable::("MeshRenderer"); + + // Listed as addable; Transform (register_reflected) is not. + assert!(r.is_addable("MeshRenderer")); + assert!(!r.is_addable("Transform")); + let addable: Vec<_> = r.addable_names().collect(); + assert_eq!(addable, ["MeshRenderer"]); + + let mut scene = Scene::new(); + let e = scene.spawn("thing", Transform::IDENTITY); + assert!(!r.has(scene.world(), e, "MeshRenderer").unwrap()); + + // First add inserts the default; a second add is a no-op (already there). + assert!(r.add_default(scene.world_mut(), e, "MeshRenderer").unwrap()); + assert!(r.has(scene.world(), e, "MeshRenderer").unwrap()); + assert!(!r.add_default(scene.world_mut(), e, "MeshRenderer").unwrap()); + + // Its enum field is editable as a registered enum. + r.register_enum::("PrimitiveShape"); + let shape = r + .get_field(scene.world(), e, "MeshRenderer", "shape") + .unwrap(); + assert_eq!(shape, "Cube"); // PrimitiveShape::default() + assert_eq!( + r.enum_variants("PrimitiveShape"), + Some(["Cube", "Sphere", "Plane"].as_slice()) + ); + } + + #[test] + fn non_addable_type_add_default_is_a_noop() { + let r = registry(); // Transform/Node are register_reflected, not addable + let mut scene = Scene::new(); + let e = scene.spawn("thing", Transform::IDENTITY); + // Transform isn't addable → Ok(false), nothing inserted. + assert!(!r.add_default(scene.world_mut(), e, "Transform").unwrap()); + // Unknown type → error. + assert!(matches!( + r.add_default(scene.world_mut(), e, "Ghost"), + Err(ReflectError::UnknownType(_)) + )); + } + + #[test] + fn registry_lists_enum_variants_by_type_name() { + let mut r = registry(); + r.register_enum::("Facing"); + assert_eq!( + r.enum_variants("Facing"), + Some(["North", "East", "South", "West"].as_slice()) + ); + // Unregistered / non-enum type names return None. + assert_eq!(r.enum_variants("Transform"), None); + assert_eq!(r.enum_variants("Nope"), None); + } + + #[test] + fn derive_field_values_round_trip_through_get_then_set() { + let original = Timer::sample(); + let mut clone = Timer { + repeating: false, + duration: 0.0, + label: String::new(), + elapsed: 0.0, + _internal: 0, + }; + for field in original.fields() { + let ron = original.get_field(field.name).unwrap(); + clone.set_field(field.name, &ron).unwrap(); + } + // Every reflected field now matches; non-reflected fields keep clone's. + assert_eq!(clone.repeating, original.repeating); + assert_eq!(clone.duration, original.duration); + assert_eq!(clone.label, original.label); + assert_eq!(clone.elapsed, 0.0); + } +} diff --git a/engine/src/render/camera.rs b/engine/src/render/camera.rs new file mode 100644 index 0000000..281d218 --- /dev/null +++ b/engine/src/render/camera.rs @@ -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 + ); + } +} diff --git a/engine/src/render/context.rs b/engine/src/render/context.rs new file mode 100644 index 0000000..57bc1a4 --- /dev/null +++ b/engine/src/render/context.rs @@ -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) -> Result { + // (label, backend override, force a software adapter) + let attempts: [(&str, Option, bool); 3] = [ + ("default", None, false), + ("GL", Some(wgpu::Backends::GL), false), + ("software", None, true), + ]; + + let mut last_err: Option = 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, + backends: Option, + force_fallback_adapter: bool, + ) -> Result { + 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(()) +} diff --git a/engine/src/render/forward.rs b/engine/src/render/forward.rs new file mode 100644 index 0000000..200ab0a --- /dev/null +++ b/engine/src/render/forward.rs @@ -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::` rounded up to the device's + /// minimum dynamic-uniform-buffer offset alignment. + object_stride: u64, + object_capacity: u32, + depth: Option, + 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::() 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::() 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::() 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::() 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::() 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 +} diff --git a/engine/src/render/gpu.rs b/engine/src/render/gpu.rs new file mode 100644 index 0000000..b0a5daa --- /dev/null +++ b/engine/src/render/gpu.rs @@ -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 { + 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 { + // `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 + } +} diff --git a/engine/src/render/material.rs b/engine/src/render/material.rs new file mode 100644 index 0000000..b737fa3 --- /dev/null +++ b/engine/src/render/material.rs @@ -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), + } + } +} diff --git a/engine/src/render/mesh.rs b/engine/src/render/mesh.rs new file mode 100644 index 0000000..83e3433 --- /dev/null +++ b/engine/src/render/mesh.rs @@ -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::() 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, + /// Triangle indices into [`vertices`](Self::vertices), three per triangle. + pub indices: Vec, +} + +impl Mesh { + /// Creates a mesh from raw vertex and index data. + pub fn new(vertices: Vec, indices: Vec) -> 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, +} diff --git a/engine/src/render/mod.rs b/engine/src/render/mod.rs new file mode 100644 index 0000000..3df4104 --- /dev/null +++ b/engine/src/render/mod.rs @@ -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, + } +} diff --git a/engine/src/render/pipeline.rs b/engine/src/render/pipeline.rs new file mode 100644 index 0000000..8a36d20 --- /dev/null +++ b/engine/src/render/pipeline.rs @@ -0,0 +1,233 @@ +//! [`RenderPipeline`]: a data-driven, ordered list of composable render passes. +//! +//! Stage 4's renderer drew everything in one hardcoded pass. Stage 5 generalizes +//! that into a list of named [`RenderPass`]es that share one frame's targets and +//! run in order. A project enables only the passes it needs — this is the +//! mechanism behind *scalable fidelity*: a flat unlit/low-poly look (or a +//! stylized post effect) versus a full realistic stack, paying only for the +//! passes turned on. +//! +//! The Stage-4 forward pass is retrofitted onto this as [`ForwardPass`], so the +//! default pipeline ([`RenderPipeline::forward`]) is just `[Clear, Forward]` and +//! produces pixel-identical output. Later stages add passes (shadows, +//! post-process, overlay UI) **without touching the renderer core** — they +//! register a pass. + +use crate::math::{Color, Rect, Transform, Vec2}; + +use super::{clear_view, Camera, ForwardRenderer, Lighting, RenderObject}; + +/// Everything one frame's passes operate on: the shared color target and the +/// scene view to draw. +/// +/// Passes share the same `color` target (and, as the pipeline grows, depth and +/// intermediate textures), which is what makes them *composable*: a clear pass +/// fills the target, the forward pass draws into it, a future post pass reads and +/// rewrites it. +pub struct FrameContext<'a> { + /// The GPU device. + pub device: &'a wgpu::Device, + /// The GPU queue. + pub queue: &'a wgpu::Queue, + /// The color target every pass renders into. + pub color: &'a wgpu::TextureView, + /// Target size in physical pixels (the whole color target the pipeline + /// is writing into). + pub size: (u32, u32), + /// The sub-rectangle of the target that drawing is restricted to, in + /// physical pixels (`min` = upper-left, `max` = lower-right). Passes + /// configure the wgpu viewport from this and the camera uses its + /// aspect ratio for the projection. + /// + /// `None` means "use the full target" — the default for headless tests + /// and for hosts that render to a whole window. The editor sets this to + /// the Viewport tab's rect from the docking shell so picking and + /// projection align with what the user sees inside the tab rather than + /// stretching across the whole window. + pub viewport_rect: Option, + /// The background clear color (used by [`ClearPass`]). + pub clear_color: Color, + /// The camera to render from. + pub camera: &'a Camera, + /// The camera's world placement. + pub view_transform: &'a Transform, + /// Scene lighting. + pub lighting: &'a Lighting, + /// The drawables, already culled by the host (e.g. by camera + /// [`visibility`](Camera::visibility)). + pub objects: &'a [RenderObject<'a>], +} + +impl FrameContext<'_> { + /// The viewport rect [`viewport_rect`](Self::viewport_rect) resolves to — + /// the explicit sub-rect when set, otherwise the full target. + pub fn resolved_viewport(&self) -> Rect { + self.viewport_rect.unwrap_or_else(|| { + Rect::from_min_size( + Vec2::ZERO, + Vec2::new(self.size.0.max(1) as f32, self.size.1.max(1) as f32), + ) + }) + } +} + +/// One stage of the frame. Implement this to add a custom pass; register it on a +/// [`RenderPipeline`]. Passes are owned by the pipeline and run in order. +pub trait RenderPass { + /// Records this pass's GPU work for the frame. + fn run(&mut self, frame: &mut FrameContext<'_>); +} + +struct PassEntry { + name: String, + enabled: bool, + pass: Box, +} + +/// An ordered, named list of render passes. +/// +/// Add passes with [`add_pass`](Self::add_pass), toggle them with +/// [`set_enabled`](Self::set_enabled), or drop them with [`remove`](Self::remove) +/// — all without touching any pass's implementation. [`render`](Self::render) +/// runs every enabled pass in order against one [`FrameContext`]. +#[derive(Default)] +pub struct RenderPipeline { + passes: Vec, +} + +impl RenderPipeline { + /// An empty pipeline (no passes). + pub fn new() -> Self { + Self::default() + } + + /// The default forward pipeline: a [`ClearPass`] followed by a + /// [`ForwardPass`]. Pixel-identical to the Stage-4 renderer's output. + pub fn forward(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self { + let mut pipeline = Self::new(); + pipeline.add_pass("clear", ClearPass); + pipeline.add_pass("forward", ForwardPass::new(device, color_format)); + pipeline + } + + /// Appends a named pass (enabled). Replaces any existing pass with the same + /// name, keeping its position. + pub fn add_pass(&mut self, name: impl Into, pass: impl RenderPass + 'static) { + let name = name.into(); + let entry = PassEntry { + name: name.clone(), + enabled: true, + pass: Box::new(pass), + }; + match self.passes.iter_mut().find(|e| e.name == name) { + Some(existing) => *existing = entry, + None => self.passes.push(entry), + } + } + + /// Inserts a pass before the pass named `before` (or at the end if not + /// found). Useful for slotting a post effect into a fixed position. + pub fn insert_before( + &mut self, + before: &str, + name: impl Into, + pass: impl RenderPass + 'static, + ) { + let entry = PassEntry { + name: name.into(), + enabled: true, + pass: Box::new(pass), + }; + match self.passes.iter().position(|e| e.name == before) { + Some(index) => self.passes.insert(index, entry), + None => self.passes.push(entry), + } + } + + /// Enables or disables the named pass. Returns whether it exists. + pub fn set_enabled(&mut self, name: &str, enabled: bool) -> bool { + match self.passes.iter_mut().find(|e| e.name == name) { + Some(entry) => { + entry.enabled = enabled; + true + } + None => false, + } + } + + /// Removes the named pass. Returns whether it existed. + pub fn remove(&mut self, name: &str) -> bool { + let before = self.passes.len(); + self.passes.retain(|e| e.name != name); + self.passes.len() != before + } + + /// Whether a pass with this name is registered. + pub fn has_pass(&self, name: &str) -> bool { + self.passes.iter().any(|e| e.name == name) + } + + /// The pass names in execution order. + pub fn pass_names(&self) -> impl Iterator { + self.passes.iter().map(|e| e.name.as_str()) + } + + /// Runs every enabled pass in order against `frame`. + pub fn render(&mut self, frame: &mut FrameContext<'_>) { + for entry in &mut self.passes { + if entry.enabled { + entry.pass.run(frame); + } + } + } +} + +/// A pass that clears the color target to [`FrameContext::clear_color`]. +/// +/// Conventionally the first pass, so later passes load over the cleared +/// background (matching the Stage-4 clear-then-draw flow). +pub struct ClearPass; + +impl RenderPass for ClearPass { + fn run(&mut self, frame: &mut FrameContext<'_>) { + clear_view(frame.device, frame.queue, frame.color, frame.clear_color); + } +} + +/// A pass that draws the frame's objects with the lit forward renderer. +/// +/// Wraps the Stage-4 [`ForwardRenderer`]; the color target is *loaded* (so a +/// preceding [`ClearPass`] shows through), depth is managed internally. +pub struct ForwardPass { + renderer: ForwardRenderer, +} + +impl ForwardPass { + /// Builds a forward pass for the given color target format. + pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self { + Self { + renderer: ForwardRenderer::new(device, color_format), + } + } + + /// The wrapped renderer's color format. + pub fn color_format(&self) -> wgpu::TextureFormat { + self.renderer.color_format() + } +} + +impl RenderPass for ForwardPass { + fn run(&mut self, frame: &mut FrameContext<'_>) { + self.renderer.render( + frame.device, + frame.queue, + frame.color, + frame.size, + frame.resolved_viewport(), + frame.camera, + frame.view_transform, + frame.lighting, + frame.objects, + ); + } +} diff --git a/engine/src/render/renderable.rs b/engine/src/render/renderable.rs new file mode 100644 index 0000000..297f147 --- /dev/null +++ b/engine/src/render/renderable.rs @@ -0,0 +1,134 @@ +//! Renderable scene components: [`MeshRenderer`] and [`PrimitiveShape`]. +//! +//! A [`MeshRenderer`] is the component that makes a scene entity show up in the +//! 3D viewport: it pairs a mesh source with a [`Material`]. Stage 4 ships the +//! built-in [`PrimitiveShape`] source (cube/sphere/plane) — lightweight and +//! serializable, so the editor (and later scripts/AI agents) can author what an +//! entity renders. Imported meshes attach later via a mesh-asset handle. + +use serde::{Deserialize, Serialize}; + +use super::{Material, Mesh}; +use crate::math::{Aabb, Vec3}; + +/// A built-in primitive mesh an entity can render. +/// +/// This names a shape rather than embedding vertex data, so it stays tiny, +/// serializable, and cheap to edit; the renderer resolves it to a (cached) +/// [`Mesh`]/GPU buffer. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Default, + Serialize, + Deserialize, + crate::reflect::ReflectEnum, +)] +pub enum PrimitiveShape { + /// Unit cube centered at the origin. + #[default] + Cube, + /// Unit-radius UV sphere. + Sphere, + /// A 1×1 ground plane on the XZ axes, facing `+Y`. + Plane, +} + +impl PrimitiveShape { + /// All shapes, for building caches / editor menus. + pub const ALL: [PrimitiveShape; 3] = [ + PrimitiveShape::Cube, + PrimitiveShape::Sphere, + PrimitiveShape::Plane, + ]; + + /// A human-readable label. + pub fn label(self) -> &'static str { + match self { + PrimitiveShape::Cube => "Cube", + PrimitiveShape::Sphere => "Sphere", + PrimitiveShape::Plane => "Plane", + } + } + + /// Builds the CPU [`Mesh`] for this shape. + pub fn mesh(self) -> Mesh { + match self { + PrimitiveShape::Cube => Mesh::cube(), + PrimitiveShape::Sphere => Mesh::uv_sphere(1.0, 32, 16), + PrimitiveShape::Plane => Mesh::plane(1.0), + } + } + + /// The object-space bounds of this shape, without building a mesh — used for + /// ray-picking and culling. + pub fn local_bounds(self) -> Aabb { + let half = match self { + PrimitiveShape::Cube => Vec3::splat(0.5), + PrimitiveShape::Sphere => Vec3::ONE, + PrimitiveShape::Plane => Vec3::new(0.5, 0.0, 0.5), + }; + Aabb::from_center_half_extents(Vec3::ZERO, half) + } +} + +/// Component: what an entity renders. +/// +/// Attach to a scene entity (via the ECS) to make it appear in a forward pass. +/// Stage 4 sources the mesh from a [`PrimitiveShape`]; the [`Material`] is +/// edited in the inspector. Both are serializable, supporting the engine's +/// dual-editable (editor + script/AI) component goal. +#[derive( + Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, crate::reflect::Reflect, +)] +pub struct MeshRenderer { + /// The mesh to draw. + pub shape: PrimitiveShape, + /// The surface material. + pub material: Material, +} + +impl MeshRenderer { + /// A renderer for `shape` with the default material. + pub fn new(shape: PrimitiveShape) -> Self { + Self { + shape, + material: Material::default(), + } + } + + /// A renderer for `shape` with an explicit `material`. + pub fn with_material(shape: PrimitiveShape, material: Material) -> Self { + Self { shape, material } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::Color; + + #[test] + fn every_shape_builds_a_nonempty_mesh() { + for shape in PrimitiveShape::ALL { + let mesh = shape.mesh(); + assert!(!mesh.vertices.is_empty(), "{shape:?} has no vertices"); + assert!(mesh.triangle_count() > 0, "{shape:?} has no triangles"); + } + } + + #[test] + fn mesh_renderer_round_trips_through_ron() { + let mr = MeshRenderer::with_material( + PrimitiveShape::Sphere, + Material::metal(Color::rgb(0.2, 0.4, 0.8), 0.25), + ); + let ron = ron::to_string(&mr).unwrap(); + let back: MeshRenderer = ron::from_str(&ron).unwrap(); + assert_eq!(mr, back); + } +} diff --git a/engine/src/render/shaders/lit.wgsl b/engine/src/render/shaders/lit.wgsl new file mode 100644 index 0000000..4d90020 --- /dev/null +++ b/engine/src/render/shaders/lit.wgsl @@ -0,0 +1,69 @@ +// Stage 4 forward lit shader: a single directional light with Lambert diffuse, +// ambient, and a Blinn-Phong specular term scaled by material roughness/metallic +// (PBR-lite). Output is linear color; an sRGB surface format converts on write. + +struct Globals { + view_proj: mat4x4, + camera_pos: vec4, // xyz world-space camera position + light_dir: vec4, // xyz unit vector pointing TOWARD the light + light_color: vec4, // rgb light color * intensity + ambient: vec4, // rgb ambient term +}; + +struct ObjectData { + model: mat4x4, + normal_mtx: mat4x4, // inverse-transpose of model (3x3 in a 4x4) + albedo: vec4, + mr: vec4, // x = metallic, y = roughness +}; + +@group(0) @binding(0) var globals: Globals; +@group(1) @binding(0) var obj: ObjectData; + +struct VsOut { + @builtin(position) clip_pos: vec4, + @location(0) world_pos: vec3, + @location(1) world_normal: vec3, + @location(2) uv: vec2, +}; + +@vertex +fn vs_main( + @location(0) position: vec3, + @location(1) normal: vec3, + @location(2) uv: vec2, +) -> VsOut { + let world = obj.model * vec4(position, 1.0); + var out: VsOut; + out.world_pos = world.xyz; + out.world_normal = (obj.normal_mtx * vec4(normal, 0.0)).xyz; + out.uv = uv; + out.clip_pos = globals.view_proj * world; + return out; +} + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + let n = normalize(in.world_normal); + let l = normalize(globals.light_dir.xyz); + let v = normalize(globals.camera_pos.xyz - in.world_pos); + let h = normalize(l + v); + + let albedo = obj.albedo.rgb; + let metallic = obj.mr.x; + let roughness = clamp(obj.mr.y, 0.04, 1.0); + + let ndl = max(dot(n, l), 0.0); + let ndh = max(dot(n, h), 0.0); + + // Metals have no diffuse; dielectrics get a fixed 0.04 specular, metals + // tint their specular by the albedo. + let diffuse = albedo * (1.0 - metallic); + let spec_color = mix(vec3(0.04), albedo, metallic); + let spec_power = mix(8.0, 256.0, 1.0 - roughness); + let spec = spec_color * pow(ndh, spec_power) * select(0.0, 1.0, ndl > 0.0); + + let direct = (diffuse * ndl + spec) * globals.light_color.rgb; + let ambient = albedo * globals.ambient.rgb; + return vec4(ambient + direct, obj.albedo.a); +} diff --git a/engine/src/render/shaders/ui.wgsl b/engine/src/render/shaders/ui.wgsl new file mode 100644 index 0000000..6c03a28 --- /dev/null +++ b/engine/src/render/shaders/ui.wgsl @@ -0,0 +1,47 @@ +// Oxide Stage-8 UI overlay shader. +// +// One vertex format covers both solid quads and glyph quads: the sentinel UV +// `(-1, -1)` marks "solid color, do not sample the atlas". This avoids +// branching on a separate flag attribute and keeps the vertex stride tight +// (32 bytes — pos2 + uv2 + color4). + +struct Uniforms { + mvp: mat4x4, +}; + +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var atlas: texture_2d; +@group(0) @binding(2) var atlas_sampler: sampler; + +struct VsIn { + @location(0) position: vec2, + @location(1) uv: vec2, + @location(2) color: vec4, +}; + +struct VsOut { + @builtin(position) clip_pos: vec4, + @location(0) uv: vec2, + @location(1) color: vec4, +}; + +@vertex +fn vs_main(in: VsIn) -> VsOut { + var out: VsOut; + out.clip_pos = u.mvp * vec4(in.position, 0.0, 1.0); + out.uv = in.uv; + out.color = in.color; + return out; +} + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + // Solid quads use the sentinel UV (-1, -1). Sampling out-of-range would + // be clamped or wrapped depending on the sampler, but we cheaply detect + // it instead so a single texture binding serves every primitive. + if (in.uv.x < 0.0) { + return in.color; + } + let alpha = textureSample(atlas, atlas_sampler, in.uv).r; + return vec4(in.color.rgb, in.color.a * alpha); +} diff --git a/engine/src/render/ui_pass.rs b/engine/src/render/ui_pass.rs new file mode 100644 index 0000000..5500f61 --- /dev/null +++ b/engine/src/render/ui_pass.rs @@ -0,0 +1,1117 @@ +//! [`UiOverlayPass`]: batches Stage-8 UI [`DrawCommand`]s into one render pass. +//! +//! Slots into the Stage-5 [`RenderPipeline`](super::RenderPipeline) **after** +//! the forward pass (so UI draws on top of the 3D scene) and **before** +//! any future post-process. It consumes a list of +//! [`UiBatch`]es per frame — each carries its own MVP matrix and a flat +//! [`PaintedFrame`] of draw commands — uploads the CPU glyph atlas to a +//! single R8 texture (re-uploading only on dirty), and submits one draw call +//! per batch (vertices buffered into a single growable vertex buffer). +//! +//! # Why batches +//! +//! The same pipeline draws **screen-space UI** (the host adds one batch +//! whose MVP is an orthographic projection from window pixels to NDC) and +//! **world-space UI** (piece 4b adds one batch per `UiPanel`, each with its +//! own world-to-clip MVP). The vertex format is identical; the only thing +//! that differs is the MVP — and that's a small per-batch uniform update, +//! so the GPU pipeline never has to switch state between a HUD and a +//! diegetic panel. +//! +//! # Test strategy +//! +//! [`Gpu::headless()`](super::Gpu::headless) gives us a no-window device. +//! The pass renders into an offscreen `Rgba8Unorm` texture; the host reads +//! pixels back via a copy buffer and asserts on them. The Stage-4 +//! `lit_sphere_renders_over_background` test pattern carries over directly — +//! a UI batch whose only command is a `Quad { rect, color: RED }` should +//! produce red pixels inside that rect and the clear color outside it. Tests +//! that need text load a system font via +//! [`common_system_font_paths`](super::super::ui::text::common_system_font_paths) +//! and skip gracefully on hosts without one. + +use std::num::NonZeroU64; + +use bytemuck::{Pod, Zeroable}; +use glam::{Mat4, Vec2, Vec3, Vec4}; + +use super::pipeline::{FrameContext, RenderPass}; +use crate::math::{Color, Rect, Transform}; +use crate::ui::paint::{DrawCommand, PaintedFrame}; +use crate::ui::text::{FontStore, GlyphAtlas}; + +/// One batch of UI to draw with a single MVP — either a screen-space tree or +/// a world-space panel. +pub struct UiBatch { + /// Clip-space matrix applied to every vertex in this batch's commands. + pub mvp: Mat4, + /// The painted commands, in submission order (back-to-front). + pub frame: PaintedFrame, +} + +impl UiBatch { + /// Screen-space batch: maps pixel coordinates `(0, 0)..(width, height)` + /// to NDC with y-down (origin at the top-left, matching UI convention). + pub fn screen_space(frame: PaintedFrame, target_size: (u32, u32)) -> Self { + let (w, h) = (target_size.0.max(1) as f32, target_size.1.max(1) as f32); + // ortho(left, right, bottom, top, near, far) + // For y-down with origin at the top-left: bottom = h, top = 0. + let mvp = Mat4::orthographic_rh(0.0, w, h, 0.0, -1.0, 1.0); + Self { mvp, frame } + } + + /// World-space batch: place a panel's UI inside 3D world space. + /// + /// The painted frame's vertices are in **panel-pixel** coordinates + /// (`(0, 0)..=pixel_size`). This constructor composes the MVP that + /// maps each vertex through: + /// + /// 1. Recenter the pixel origin to the panel's centre (so the pixel + /// midpoint maps to the panel's local origin). + /// 2. Scale pixels → world units using `world_size / pixel_size`, with + /// the y axis **negated** because UI is y-down but world is y-up. + /// 3. Apply `panel_transform` (the panel's world placement). + /// 4. Apply `view_projection` (the camera's clip-space matrix). + /// + /// The end-to-end effect: a pixel at `(0, 0)` in the painted frame + /// lands at world position `panel_transform * (-world.x/2, +world.y/2, + /// 0)` (the panel's top-left corner); a pixel at `pixel_size` lands + /// at the panel's bottom-right. + pub fn world_space( + frame: PaintedFrame, + pixel_size: Vec2, + world_size: Vec2, + panel_transform: &Transform, + view_projection: Mat4, + ) -> Self { + let pixel_to_centered = + Mat4::from_translation(Vec3::new(-pixel_size.x * 0.5, -pixel_size.y * 0.5, 0.0)); + let centered_to_world_local = Mat4::from_scale(Vec3::new( + world_size.x / pixel_size.x.max(1.0), + -world_size.y / pixel_size.y.max(1.0), // y-down → y-up + 1.0, + )); + let world_local_to_world = panel_transform.to_matrix(); + let mvp = + view_projection * world_local_to_world * centered_to_world_local * pixel_to_centered; + Self { mvp, frame } + } +} + +/// A render pass that draws Stage-8 UI batches over the existing color +/// target. +pub struct UiOverlayPass { + pipeline: wgpu::RenderPipeline, + bind_group: wgpu::BindGroup, + + atlas_texture: wgpu::Texture, + // Held to keep the texture view alive while the bind group references + // it (wgpu Arc-counts internally, but storing it here makes the + // ownership explicit). + _atlas_view: wgpu::TextureView, + atlas_size: (u32, u32), + _atlas_sampler: wgpu::Sampler, + + uniform_buffer: wgpu::Buffer, + vertex_buffer: wgpu::Buffer, + vertex_capacity: u64, + + cpu_atlas: GlyphAtlas, + fonts: FontStore, + pending: Vec, +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct UiUniform { + mvp: [[f32; 4]; 4], +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct UiVertex { + position: [f32; 2], + uv: [f32; 2], + color: [f32; 4], +} + +impl UiVertex { + const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![ + 0 => Float32x2, // position + 1 => Float32x2, // uv + 2 => Float32x4, // color + ], + }; +} + +const DEFAULT_ATLAS_SIZE: u32 = 1024; +const DEFAULT_VERTEX_CAPACITY: u64 = 4096; +/// Sentinel UV for solid quads. The shader treats any `uv.x < 0.0` as +/// "skip atlas sample" — see `engine/src/render/shaders/ui.wgsl`. +const SOLID_UV: Vec2 = Vec2::new(-1.0, -1.0); + +impl UiOverlayPass { + /// Build a pass for the given color target format. Initialises a + /// 1024×1024 R8 atlas, the pipeline, and the bind group; the host wires + /// it into [`RenderPipeline`](super::RenderPipeline) with + /// `add_pass("ui", pass)` *after* the forward pass. + pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self { + Self::with_atlas_size(device, color_format, DEFAULT_ATLAS_SIZE, DEFAULT_ATLAS_SIZE) + } + + /// Build a pass with an explicit atlas resolution — useful in tests + /// where a 1024×1024 atlas is overkill. + pub fn with_atlas_size( + device: &wgpu::Device, + color_format: wgpu::TextureFormat, + atlas_w: u32, + atlas_h: u32, + ) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("oxide.ui.shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("shaders/ui.wgsl").into()), + }); + + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("oxide.ui.bind_group_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new(std::mem::size_of::() as u64), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("oxide.ui.pipeline_layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("oxide.ui.pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[UiVertex::LAYOUT], + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + // No cull — UI quads are CPU-emitted CCW but flipping the + // MVP for world-space panels can swap the winding; rely on + // alpha blending instead. + cull_mode: None, + unclipped_depth: false, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + }, + // UI doesn't read depth (it overlays). + depth_stencil: None, + 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 atlas_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("oxide.ui.atlas"), + size: wgpu::Extent3d { + width: atlas_w, + height: atlas_h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("oxide.ui.atlas_sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Nearest, + ..Default::default() + }); + + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("oxide.ui.uniform"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("oxide.ui.vertices"), + size: DEFAULT_VERTEX_CAPACITY * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("oxide.ui.bind_group"), + layout: &bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&atlas_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&atlas_sampler), + }, + ], + }); + + Self { + pipeline, + bind_group, + atlas_texture, + _atlas_view: atlas_view, + atlas_size: (atlas_w, atlas_h), + _atlas_sampler: atlas_sampler, + uniform_buffer, + vertex_buffer, + vertex_capacity: DEFAULT_VERTEX_CAPACITY, + cpu_atlas: GlyphAtlas::new(atlas_w, atlas_h), + fonts: FontStore::new(), + pending: Vec::new(), + } + } + + /// Borrow the pass's font store mutably to register fonts. Fonts + /// referenced by [`DrawCommand::Glyph`] keys must already be in this + /// store before the pass runs. + pub fn fonts_mut(&mut self) -> &mut FontStore { + &mut self.fonts + } + + /// Borrow the pass's font store. Useful for shaping outside the pass + /// (e.g. in [`paint`](crate::ui::paint::paint)) using the same `FontId`s. + pub fn fonts(&self) -> &FontStore { + &self.fonts + } + + /// Replace the pending batches for this frame. The pass renders these on + /// its next [`run`](Self::run) call and then clears them. + pub fn set_batches(&mut self, batches: Vec) { + self.pending = batches; + } + + /// Number of batches currently queued for the next `run`. + pub fn batch_count(&self) -> usize { + self.pending.len() + } + + /// Resolution of the CPU/GPU glyph atlas. + pub fn atlas_size(&self) -> (u32, u32) { + self.atlas_size + } + + /// Number of distinct glyphs currently cached in the atlas. + /// + /// Useful for diagnostics: once this count stops growing across frames, + /// every glyph the UI draws is a cache hit and `run` no longer rasterizes + /// or re-uploads the atlas. HUD-style overlays that animate numeric values + /// reach this steady state after the digits `0`–`9` (and any static + /// labels) have each been seen once. + pub fn atlas_glyph_count(&self) -> usize { + self.cpu_atlas.len() + } + + /// Whether the atlas gained a glyph during the most recent `run` and has + /// not yet been re-uploaded. `run` clears this immediately after uploading, + /// so from a host's perspective it reads `false` in steady state. + pub fn atlas_dirty(&self) -> bool { + self.cpu_atlas.dirty() + } +} + +impl RenderPass for UiOverlayPass { + fn run(&mut self, frame: &mut FrameContext<'_>) { + if self.pending.is_empty() { + return; + } + + // Step 1: walk every glyph in every batch to ensure the atlas has + // their entries. This is the only step that can mutate `cpu_atlas` + // and the only step that may raise the dirty flag. + for batch in &self.pending { + for cmd in &batch.frame.commands { + if let DrawCommand::Glyph { key, .. } = cmd { + let _ = self.cpu_atlas.get_or_rasterize(*key, &self.fonts); + } + } + } + + // Step 2: re-upload the atlas to the GPU texture if it grew. + if self.cpu_atlas.dirty() { + let (w, h) = self.atlas_size; + frame.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &self.atlas_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + self.cpu_atlas.pixels(), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(w), + rows_per_image: Some(h), + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + self.cpu_atlas.clear_dirty(); + } + + // Step 3: render each batch — one draw call per batch. + let resolved_viewport = frame.resolved_viewport(); + for batch in std::mem::take(&mut self.pending) { + self.render_batch(frame, &batch, resolved_viewport); + } + } +} + +impl UiOverlayPass { + fn render_batch(&mut self, frame: &mut FrameContext<'_>, batch: &UiBatch, viewport_rect: Rect) { + // 1. Translate draw commands into a vertex buffer. + let vertices = self.commands_to_vertices(&batch.frame.commands); + if vertices.is_empty() { + return; + } + self.ensure_vertex_capacity(frame.device, vertices.len() as u64); + + frame + .queue + .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&vertices)); + + // 2. Update the MVP uniform. + let uniform = UiUniform { + mvp: batch.mvp.to_cols_array_2d(), + }; + frame + .queue + .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform)); + + // 3. Encode the render pass. + let mut encoder = frame + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("oxide.ui.encoder"), + }); + { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("oxide.ui.pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: frame.color, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + rpass.set_pipeline(&self.pipeline); + rpass.set_bind_group(0, &self.bind_group, &[]); + rpass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); + rpass.set_viewport( + viewport_rect.min.x, + viewport_rect.min.y, + viewport_rect.width().max(1.0), + viewport_rect.height().max(1.0), + 0.0, + 1.0, + ); + rpass.draw(0..vertices.len() as u32, 0..1); + } + frame.queue.submit(Some(encoder.finish())); + } + + fn commands_to_vertices(&self, commands: &[DrawCommand]) -> Vec { + let mut vertices = Vec::with_capacity(commands.len() * 6); + let (atlas_w, atlas_h) = (self.atlas_size.0 as f32, self.atlas_size.1 as f32); + for cmd in commands { + match cmd { + DrawCommand::Quad { rect, color } => { + push_quad( + &mut vertices, + rect.min, + rect.max, + SOLID_UV, + SOLID_UV, + color_to_array(*color), + ); + } + DrawCommand::Glyph { + key, + pen_position, + color, + } => { + let Some(entry) = self.cpu_atlas.get(key) else { + continue; // glyph not yet rasterized (e.g., space) + }; + let top_left = *pen_position + entry.bearing; + let bottom_right = top_left + entry.size_px; + push_quad( + &mut vertices, + top_left, + bottom_right, + entry.uv_min, + entry.uv_max, + color_to_array(*color), + ); + let _ = (atlas_w, atlas_h); + } + } + } + vertices + } + + fn ensure_vertex_capacity(&mut self, device: &wgpu::Device, needed: u64) { + if needed <= self.vertex_capacity { + return; + } + let mut new_cap = self.vertex_capacity.max(1); + while new_cap < needed { + new_cap *= 2; + } + self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("oxide.ui.vertices"), + size: new_cap * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.vertex_capacity = new_cap; + } +} + +fn push_quad( + out: &mut Vec, + min: Vec2, + max: Vec2, + uv_min: Vec2, + uv_max: Vec2, + color: [f32; 4], +) { + // Two triangles: (TL, BL, BR), (TL, BR, TR). Counter-clockwise in + // pixel coords (where y increases downward), which becomes CW after + // the y-flip orthographic projection — `cull_mode: None` covers either. + let tl = UiVertex { + position: [min.x, min.y], + uv: [uv_min.x, uv_min.y], + color, + }; + let tr = UiVertex { + position: [max.x, min.y], + uv: [uv_max.x, uv_min.y], + color, + }; + let bl = UiVertex { + position: [min.x, max.y], + uv: [uv_min.x, uv_max.y], + color, + }; + let br = UiVertex { + position: [max.x, max.y], + uv: [uv_max.x, uv_max.y], + color, + }; + out.push(tl); + out.push(bl); + out.push(br); + out.push(tl); + out.push(br); + out.push(tr); +} + +fn color_to_array(c: Color) -> [f32; 4] { + let v: Vec4 = Vec4::new(c.r, c.g, c.b, c.a); + v.to_array() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::{Color, Transform, Vec2 as MVec2}; + use crate::render::{Camera, Gpu, Lighting}; + use crate::ui::paint::{DrawCommand, PaintedFrame}; + + /// Build a headless GPU + an offscreen Rgba8 target + a [`FrameContext`] + /// with sensible defaults, ready to feed a pass's `run`. + fn make_headless(target_w: u32, target_h: u32) -> Option { + let gpu = match Gpu::headless() { + Ok(gpu) => gpu, + Err(err) => { + eprintln!("SKIP: no GPU adapter available ({err})"); + return None; + } + }; + Some(HeadlessHarness::new(gpu, target_w, target_h)) + } + + struct HeadlessHarness { + gpu: Gpu, + target: wgpu::Texture, + target_view: wgpu::TextureView, + readback: wgpu::Buffer, + target_size: (u32, u32), + } + + impl HeadlessHarness { + fn new(gpu: Gpu, w: u32, h: u32) -> Self { + let device = gpu.device(); + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("test-target"), + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let target_view = target.create_view(&wgpu::TextureViewDescriptor::default()); + let bytes_per_row = align_up(w * 4, 256); + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("test-readback"), + size: (bytes_per_row * h) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + Self { + gpu, + target, + target_view, + readback, + target_size: (w, h), + } + } + + /// Read back the target's pixels as `Rgba8`. + fn read_pixels(&self) -> Vec { + let (w, h) = self.target_size; + let bytes_per_row = align_up(w * 4, 256); + let device = self.gpu.device(); + let queue = self.gpu.queue(); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("test-copy"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &self.target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &self.readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + queue.submit(Some(encoder.finish())); + + let slice = self.readback.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + tx.send(r).unwrap(); + }); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + rx.recv().unwrap().unwrap(); + let view = slice.get_mapped_range(); + let mut out = Vec::with_capacity((w * h * 4) as usize); + for row in 0..h { + let start = (row * bytes_per_row) as usize; + out.extend_from_slice(&view[start..start + (w * 4) as usize]); + } + drop(view); + self.readback.unmap(); + out + } + + fn run_pass(&self, pass: &mut UiOverlayPass, clear: Color) { + let device = self.gpu.device(); + let queue = self.gpu.queue(); + // Clear the target first (using a one-off render pass). + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("test-clear"), + }); + { + let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("test-clear-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.target_view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: clear.r as f64, + g: clear.g as f64, + b: clear.b as f64, + a: clear.a as f64, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + } + queue.submit(Some(encoder.finish())); + + // Build a `FrameContext` to feed the pass. + let camera = Camera::default(); + let view_transform = Transform::default(); + let lighting = Lighting::default(); + let mut frame = FrameContext { + device, + queue, + color: &self.target_view, + size: self.target_size, + viewport_rect: None, + clear_color: clear, + camera: &camera, + view_transform: &view_transform, + lighting: &lighting, + objects: &[], + }; + pass.run(&mut frame); + } + } + + fn align_up(x: u32, to: u32) -> u32 { + x.div_ceil(to) * to + } + + fn pixel(buf: &[u8], w: u32, x: u32, y: u32) -> (u8, u8, u8, u8) { + let i = ((y * w + x) * 4) as usize; + (buf[i], buf[i + 1], buf[i + 2], buf[i + 3]) + } + + #[test] + fn solid_red_quad_renders_inside_its_rect_only() { + let Some(harness) = make_headless(64, 64) else { + return; + }; + let mut pass = UiOverlayPass::with_atlas_size( + harness.gpu.device(), + wgpu::TextureFormat::Rgba8Unorm, + 64, + 64, + ); + // A 20×20 red rect centered in the 64×64 target. + let frame = PaintedFrame { + size: MVec2::new(64.0, 64.0), + commands: vec![DrawCommand::Quad { + rect: Rect::from_min_size(MVec2::new(22.0, 22.0), MVec2::new(20.0, 20.0)), + color: Color::RED, + }], + }; + pass.set_batches(vec![UiBatch::screen_space(frame, (64, 64))]); + harness.run_pass(&mut pass, Color::rgb(0.0, 0.0, 0.2)); + + let pixels = harness.read_pixels(); + // Center pixel (32, 32) is inside the rect → red. + let (r, g, b, _a) = pixel(&pixels, 64, 32, 32); + assert!(r > 200, "center pixel should be red, got r={r}"); + assert!(g < 30, "center pixel should not have green, got g={g}"); + assert!(b < 30, "center pixel should not have blue, got b={b}"); + // Corner pixel (0, 0) is outside → the clear color (dark blue). + let (r, g, b, _) = pixel(&pixels, 64, 0, 0); + assert!(r < 30 && g < 30 && b > 30, "corner should be clear color"); + } + + #[test] + fn empty_batch_list_is_a_noop() { + let Some(harness) = make_headless(16, 16) else { + return; + }; + let mut pass = UiOverlayPass::with_atlas_size( + harness.gpu.device(), + wgpu::TextureFormat::Rgba8Unorm, + 64, + 64, + ); + // No batches queued — the run should not panic. + harness.run_pass(&mut pass, Color::WHITE); + let pixels = harness.read_pixels(); + let (r, g, b, _) = pixel(&pixels, 16, 8, 8); + assert!(r > 200 && g > 200 && b > 200, "should still be white"); + } + + #[test] + fn two_quads_in_one_batch_both_render() { + let Some(harness) = make_headless(48, 32) else { + return; + }; + let mut pass = UiOverlayPass::with_atlas_size( + harness.gpu.device(), + wgpu::TextureFormat::Rgba8Unorm, + 64, + 64, + ); + let frame = PaintedFrame { + size: MVec2::new(48.0, 32.0), + commands: vec![ + DrawCommand::Quad { + rect: Rect::from_min_size(MVec2::new(2.0, 2.0), MVec2::new(20.0, 28.0)), + color: Color::RED, + }, + DrawCommand::Quad { + rect: Rect::from_min_size(MVec2::new(26.0, 2.0), MVec2::new(20.0, 28.0)), + color: Color::GREEN, + }, + ], + }; + pass.set_batches(vec![UiBatch::screen_space(frame, (48, 32))]); + harness.run_pass(&mut pass, Color::BLACK); + + let pixels = harness.read_pixels(); + // Left rect → red. + let (r, g, b, _) = pixel(&pixels, 48, 10, 16); + assert!(r > 200 && g < 30 && b < 30); + // Right rect → green. + let (r, g, b, _) = pixel(&pixels, 48, 36, 16); + assert!(r < 30 && g > 200 && b < 30); + // Gap between rects → clear (black). + let (r, g, b, _) = pixel(&pixels, 48, 24, 16); + assert!(r < 30 && g < 30 && b < 30); + } + + #[test] + fn vertex_buffer_grows_when_command_count_exceeds_capacity() { + let Some(harness) = make_headless(32, 32) else { + return; + }; + let mut pass = UiOverlayPass::with_atlas_size( + harness.gpu.device(), + wgpu::TextureFormat::Rgba8Unorm, + 64, + 64, + ); + // Default vertex capacity is 4096; one quad uses 6 vertices, so + // 1000 quads = 6000 vertices, triggering one growth. + let commands: Vec<_> = (0..1000) + .map(|i| DrawCommand::Quad { + rect: Rect::from_min_size( + MVec2::new((i % 32) as f32, (i / 32) as f32), + MVec2::new(1.0, 1.0), + ), + color: Color::WHITE, + }) + .collect(); + let frame = PaintedFrame { + size: MVec2::new(32.0, 32.0), + commands, + }; + pass.set_batches(vec![UiBatch::screen_space(frame, (32, 32))]); + // The run should not panic on the buffer regrow. + harness.run_pass(&mut pass, Color::BLACK); + } + + /// World-space UI panel rendered through a 3D camera. Places a red + /// panel at the origin facing the camera, renders, and asserts that + /// the centre of the framebuffer is red while the corners stay clear. + /// This is the piece-4b gate: the `UiBatch::world_space` MVP path + /// produces pixels at the right place under a real perspective + /// projection. + #[test] + fn world_space_panel_renders_inside_its_projected_region() { + use crate::math::{Transform, Vec3}; + use crate::render::Camera; + use crate::ui::paint::{DrawCommand, PaintedFrame}; + + let Some(harness) = make_headless(64, 64) else { + return; + }; + let mut pass = UiOverlayPass::with_atlas_size( + harness.gpu.device(), + wgpu::TextureFormat::Rgba8Unorm, + 64, + 64, + ); + + // A 2 m × 2 m panel filled with red, laid out at 32×32 pixels. + let pixel_size = MVec2::new(32.0, 32.0); + let world_size = MVec2::new(2.0, 2.0); + let painted = PaintedFrame { + size: pixel_size, + commands: vec![DrawCommand::Quad { + rect: Rect::from_min_size(MVec2::ZERO, pixel_size), + color: Color::RED, + }], + }; + // Panel sits at the origin with default rotation (its normal + // points along +Z in panel-local space, which is +Z in world). + let panel_transform = Transform::default(); + // Camera at (0, 0, 3) looking at the origin: it sees the panel's + // front face. With a 60° FOV and 1:1 aspect the visible width at + // distance 3 is ~3.46 m, so a 2×2 m panel covers about 58% of + // the view's centre — corners stay outside. + let camera = Camera::perspective(60_f32.to_radians(), 0.1, 100.0); + let view_transform = Transform::looking_at(Vec3::new(0.0, 0.0, 3.0), Vec3::ZERO, Vec3::Y); + let view_projection = camera.view_projection(1.0, &view_transform); + + pass.set_batches(vec![UiBatch::world_space( + painted, + pixel_size, + world_size, + &panel_transform, + view_projection, + )]); + harness.run_pass(&mut pass, Color::BLACK); + + let pixels = harness.read_pixels(); + // Centre of the framebuffer → red panel. + let (r, g, b, _) = pixel(&pixels, 64, 32, 32); + assert!( + r > 200 && g < 30 && b < 30, + "centre should be red, got ({r}, {g}, {b})" + ); + // Corner of the framebuffer → black (panel doesn't reach there). + let (r, g, b, _) = pixel(&pixels, 64, 1, 1); + assert!( + r < 30 && g < 30 && b < 30, + "corner should be clear-black, got ({r}, {g}, {b})" + ); + } + + /// End-to-end glyph rendering on the GPU: load a system font, build a + /// painted frame with a single white glyph drawn over a black + /// background, render through the pass, read back pixels, and assert + /// that the glyph's region contains at least one near-white pixel and + /// that the corners stay black. This is the test that proves the path + /// from `DrawCommand::Glyph` through atlas → vertex buffer → shader + /// fragment is intact on the actual GPU (the solid-quad tests cover + /// only the `uv.x < 0.0` fast path). + #[test] + fn glyph_command_renders_visible_pixels_in_its_region() { + use crate::ui::text::{common_system_font_paths, Font, GlyphKey}; + + let Some(harness) = make_headless(64, 64) else { + return; + }; + // Load a system font (skip if none available — same pattern as the + // text-shaping tests). + let font = (|| { + for path in common_system_font_paths() { + if std::path::Path::new(path).exists() { + if let Ok(font) = Font::from_path(path) { + return Some(font); + } + } + } + None + })(); + let Some(font) = font else { + eprintln!("SKIP: no system font available for GPU glyph test"); + return; + }; + + let mut pass = UiOverlayPass::with_atlas_size( + harness.gpu.device(), + wgpu::TextureFormat::Rgba8Unorm, + 128, + 128, + ); + // Register the font with the pass so the atlas can rasterize it. + let font_id = pass.fonts_mut().insert(font); + // Capital 'H' at 32px — a tall, mostly-solid glyph that's easy to + // hit-test in the centre of a 64×64 target. + let glyph = pass.fonts().get(font_id).unwrap().glyph_id('H'); + let key = GlyphKey::new(font_id, glyph, 32.0); + let frame = PaintedFrame { + size: MVec2::new(64.0, 64.0), + commands: vec![DrawCommand::Glyph { + key, + // Pen position at (16, 48): baseline near the vertical + // middle, so the glyph occupies roughly the central rect. + pen_position: MVec2::new(16.0, 48.0), + color: Color::WHITE, + }], + }; + pass.set_batches(vec![UiBatch::screen_space(frame, (64, 64))]); + harness.run_pass(&mut pass, Color::BLACK); + + let pixels = harness.read_pixels(); + // Scan a 32×32 window around the glyph centre for any near-white + // pixel. We don't assert a specific pixel because exact glyph + // bitmap layout varies per font face; we only assert *something* + // got drawn there. + let mut found_lit = false; + for y in 18..50 { + for x in 16..48 { + let (r, g, b, _) = pixel(&pixels, 64, x, y); + if r > 200 && g > 200 && b > 200 { + found_lit = true; + } + } + } + assert!( + found_lit, + "expected at least one near-white pixel inside the glyph's region" + ); + // Corner pixel must still be the clear color (black) — the glyph + // is bounded, not splatted across the whole target. + let (r, g, b, _) = pixel(&pixels, 64, 0, 0); + assert!( + r < 30 && g < 30 && b < 30, + "corner should remain clear-black, got ({r}, {g}, {b})" + ); + } + + /// The atlas grows once per distinct glyph, then stops — the property the + /// `ui_hud` example relies on to claim animated HUD digits become 100% + /// cache hits. Renders the digits `0`–`9` one at a time (the atlas grows + /// each frame), then re-renders an already-seen digit (no growth, no + /// dirty flag). + #[test] + fn atlas_caches_glyphs_and_reaches_steady_state() { + use crate::ui::text::{common_system_font_paths, Font, GlyphKey}; + + let Some(harness) = make_headless(32, 32) else { + return; + }; + let font = (|| { + for path in common_system_font_paths() { + if std::path::Path::new(path).exists() { + if let Ok(font) = Font::from_path(path) { + return Some(font); + } + } + } + None + })(); + let Some(font) = font else { + eprintln!("SKIP: no system font available for atlas-cache test"); + return; + }; + + let mut pass = UiOverlayPass::with_atlas_size( + harness.gpu.device(), + wgpu::TextureFormat::Rgba8Unorm, + 128, + 128, + ); + let font_id = pass.fonts_mut().insert(font); + let glyph_key = |pass: &UiOverlayPass, c: char| { + let glyph = pass.fonts().get(font_id).unwrap().glyph_id(c); + GlyphKey::new(font_id, glyph, 24.0) + }; + let draw = |key: GlyphKey| { + UiBatch::screen_space( + PaintedFrame { + size: MVec2::new(32.0, 32.0), + commands: vec![DrawCommand::Glyph { + key, + pen_position: MVec2::new(8.0, 24.0), + color: Color::WHITE, + }], + }, + (32, 32), + ) + }; + + assert_eq!(pass.atlas_glyph_count(), 0, "atlas starts empty"); + + // Each distinct digit grows the atlas by exactly one entry. + for (i, c) in "0123456789".chars().enumerate() { + let key = glyph_key(&pass, c); + pass.set_batches(vec![draw(key)]); + harness.run_pass(&mut pass, Color::BLACK); + assert_eq!( + pass.atlas_glyph_count(), + i + 1, + "atlas should hold {} glyphs after digit '{c}'", + i + 1 + ); + // `run` clears the dirty flag after uploading, so a host always + // observes it false post-run. + assert!(!pass.atlas_dirty(), "dirty flag is cleared after upload"); + } + + // Re-rendering an already-cached digit is a pure cache hit: the count + // holds and nothing is re-rasterized or marked dirty. + let key = glyph_key(&pass, '7'); + pass.set_batches(vec![draw(key)]); + harness.run_pass(&mut pass, Color::BLACK); + assert_eq!( + pass.atlas_glyph_count(), + 10, + "re-drawing a cached glyph must not grow the atlas" + ); + assert!(!pass.atlas_dirty(), "cache hit leaves the atlas clean"); + } +} diff --git a/engine/src/scene/disabled.rs b/engine/src/scene/disabled.rs new file mode 100644 index 0000000..4e8ab2a --- /dev/null +++ b/engine/src/scene/disabled.rs @@ -0,0 +1,95 @@ +//! [`DisabledComponents`]: a hidden per-entity set of disabled component names. +//! +//! Some game-objects need a component *attached but not active* — e.g. a +//! camera that defaults disabled and a script turns it on at a trigger. ECS +//! component-sets don't carry an "active" bit per component on their own, so +//! this component stores the set of *type names* (matching the reflection +//! registry) that should be skipped by systems on this entity. +//! +//! - Each engine system that runs on a per-entity component query consults +//! [`Scene::is_component_disabled`](crate::scene::Scene::is_component_disabled) +//! (or this component directly) before acting; it's the Unity +//! "Component.enabled" equivalent in an archetypal ECS. +//! - The editor inspector reads + writes it through a per-component +//! checkbox, hides the component itself from view (it's metadata, not +//! authored data), and copies the set on Duplicate. + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +/// Per-entity set of *disabled* component type names. +/// +/// Names match the reflection registry (e.g. `"MeshRenderer"`). An absent +/// component (or an empty set) means every component on the entity is active. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DisabledComponents { + /// Disabled component type names. Stored as `String` so the data + /// round-trips through RON without the `&'static str` reference issue. + pub disabled: HashSet, +} + +impl DisabledComponents { + /// An empty set — every component is active. + pub fn new() -> Self { + Self::default() + } + + /// Whether the component with this type name is disabled. + pub fn is_disabled(&self, type_name: &str) -> bool { + self.disabled.contains(type_name) + } + + /// Marks the component disabled (`true`) or active (`false`). Adds or + /// removes the entry as needed. + pub fn set_disabled(&mut self, type_name: &str, disabled: bool) { + if disabled { + self.disabled.insert(type_name.to_string()); + } else { + self.disabled.remove(type_name); + } + } + + /// True if no components are currently disabled — a hint to systems that + /// the entire `DisabledComponents` component can be removed. + pub fn is_empty(&self) -> bool { + self.disabled.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_empty_and_disables_nothing() { + let d = DisabledComponents::new(); + assert!(d.is_empty()); + assert!(!d.is_disabled("MeshRenderer")); + } + + #[test] + fn set_disabled_toggles_membership() { + let mut d = DisabledComponents::new(); + d.set_disabled("MeshRenderer", true); + assert!(d.is_disabled("MeshRenderer")); + assert!(!d.is_empty()); + // Idempotent. + d.set_disabled("MeshRenderer", true); + assert_eq!(d.disabled.len(), 1); + // Re-enable removes the entry. + d.set_disabled("MeshRenderer", false); + assert!(!d.is_disabled("MeshRenderer")); + assert!(d.is_empty()); + } + + #[test] + fn round_trips_through_ron() { + let mut d = DisabledComponents::new(); + d.set_disabled("MeshRenderer", true); + d.set_disabled("RigidBody", true); + let text = ron::to_string(&d).unwrap(); + let back: DisabledComponents = ron::from_str(&text).unwrap(); + assert_eq!(back, d); + } +} diff --git a/engine/src/scene/graph.rs b/engine/src/scene/graph.rs new file mode 100644 index 0000000..4c684cb --- /dev/null +++ b/engine/src/scene/graph.rs @@ -0,0 +1,722 @@ +//! The [`Scene`]: entities, their components, and a transform hierarchy. + +use std::collections::HashMap; + +use hecs::{Component, Entity, World}; + +use super::SceneError; +use crate::math::Transform; +use crate::scene::node::Node; + +/// What happens to an entity's children when it is despawned. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DespawnPolicy { + /// Despawn the entity together with its entire subtree. + Recursive, + /// Despawn only the entity; reparent each child to the entity's parent, + /// promoting them to roots if the entity was itself a root. + DetachChildren, +} + +/// A scene graph: a [`hecs`] world plus a parent/child transform hierarchy. +/// +/// Entities are [`hecs::Entity`] handles. Every entity created through the +/// scene carries a [`Node`] and a (local) [`Transform`]; arbitrary additional +/// components can be attached via [`world_mut`](Self::world_mut) for the +/// systems added in later stages (meshes, rigid bodies, …). +/// +/// The hierarchy is owned by the scene rather than stored as components, which +/// keeps child ordering deterministic (important for serialization and the +/// editor) and lets reparenting avoid archetype churn. Local transforms are the +/// authored values; [`world_transform`](Self::world_transform) and +/// [`world_transforms`](Self::world_transforms) resolve them against the +/// hierarchy as `parent_world * local`. +#[derive(Default)] +pub struct Scene { + world: World, + /// Top-level entities, in insertion order. + roots: Vec, + /// Child lists keyed by parent, each in insertion order. Entities with no + /// children may be absent. + children: HashMap>, + /// Upward links. Roots are absent from this map. + parents: HashMap, +} + +impl Scene { + /// Creates an empty scene. + pub fn new() -> Self { + Self::default() + } + + // --- Lifecycle --------------------------------------------------------- + + /// Spawns a new root entity carrying `node` and `transform`. + /// + /// Every spawned entity automatically carries the three *node-baked* + /// components: [`Node`], [`Transform`], and + /// [`Layer`](crate::layer::Layer) (membership in the default layer). + /// They are inherent to being an entity in this scene — single-instance, + /// not added through the editor's "Add Component" menu, not removable. + /// Modular components (`MeshRenderer`, future colliders, scripts, …) are + /// attached on top. + pub fn spawn(&mut self, node: impl Into, transform: Transform) -> Entity { + let entity = self + .world + .spawn((node.into(), transform, crate::layer::Layer::default())); + self.roots.push(entity); + entity + } + + /// Spawns a new entity as a child of `parent`. Auto-attaches the same + /// node-baked components as [`spawn`](Self::spawn). + /// + /// # Panics + /// Panics if `parent` is not a live entity in this scene. + pub fn spawn_child( + &mut self, + parent: Entity, + node: impl Into, + transform: Transform, + ) -> Entity { + assert!( + self.world.contains(parent), + "spawn_child: parent {parent:?} is not a live entity in this scene" + ); + let entity = self + .world + .spawn((node.into(), transform, crate::layer::Layer::default())); + self.parents.insert(entity, parent); + self.children.entry(parent).or_default().push(entity); + entity + } + + /// Despawns `entity`, handling its children according to `policy`. + /// + /// Returns `true` if the entity existed and was removed. + pub fn despawn(&mut self, entity: Entity, policy: DespawnPolicy) -> bool { + if !self.world.contains(entity) { + return false; + } + // Remember the parent before unlinking, so DetachChildren can promote + // the orphans to the right place. + let grandparent = self.parents.get(&entity).copied(); + self.unlink(entity); + + match policy { + DespawnPolicy::Recursive => self.despawn_recursive(entity), + DespawnPolicy::DetachChildren => { + let kids = self.children.remove(&entity).unwrap_or_default(); + let _ = self.world.despawn(entity); + for kid in kids { + match grandparent { + Some(gp) => { + self.parents.insert(kid, gp); + self.children.entry(gp).or_default().push(kid); + } + None => { + self.parents.remove(&kid); + self.roots.push(kid); + } + } + } + } + } + true + } + + /// Recursively despawns `entity` and everything beneath it. Assumes + /// `entity` has already been unlinked from its parent / the root list. + fn despawn_recursive(&mut self, entity: Entity) { + let kids = self.children.remove(&entity).unwrap_or_default(); + self.parents.remove(&entity); + let _ = self.world.despawn(entity); + for kid in kids { + self.despawn_recursive(kid); + } + } + + /// Removes `entity` from its parent's child list (or the root list) and + /// from the parent map, without touching the entity itself. + fn unlink(&mut self, entity: Entity) { + match self.parents.remove(&entity) { + Some(parent) => { + if let Some(siblings) = self.children.get_mut(&parent) { + siblings.retain(|&e| e != entity); + } + } + None => self.roots.retain(|&e| e != entity), + } + } + + // --- Hierarchy --------------------------------------------------------- + + /// Reparents `entity` under `new_parent`, or makes it a root when + /// `new_parent` is `None`. Child ordering places `entity` last among its + /// new siblings. + /// + /// Local transforms are preserved as-is (this does not compensate to keep + /// the world transform fixed). + /// + /// # Errors + /// - [`SceneError::NoSuchEntity`] if `entity` or `new_parent` is not live. + /// - [`SceneError::WouldCycle`] if `new_parent` is `entity` itself or one + /// of its descendants. + pub fn set_parent( + &mut self, + entity: Entity, + new_parent: Option, + ) -> Result<(), SceneError> { + if !self.world.contains(entity) { + return Err(SceneError::NoSuchEntity); + } + if let Some(parent) = new_parent { + if !self.world.contains(parent) { + return Err(SceneError::NoSuchEntity); + } + // Walking up from the prospective parent must not reach `entity`, + // otherwise the link would form a cycle. + if parent == entity || self.is_ancestor(entity, parent) { + return Err(SceneError::WouldCycle); + } + } + + self.unlink(entity); + match new_parent { + Some(parent) => { + self.parents.insert(entity, parent); + self.children.entry(parent).or_default().push(entity); + } + None => self.roots.push(entity), + } + Ok(()) + } + + /// Moves `entity` under `new_parent` (or to the root level when `None`), + /// positioned **immediately before** sibling `before`. If `before` is + /// `None` or isn't a child of the target, `entity` is appended. + /// + /// Unlike [`set_parent`](Self::set_parent) (which always appends), this + /// controls the sibling order, so it covers both reparenting *and* + /// reordering within the same parent — the operation a hierarchy + /// drag-and-drop with an insertion indicator needs. The position is + /// resolved *after* unlinking `entity`, so reordering within one parent + /// doesn't suffer an off-by-one. Rejects cycles like `set_parent`. + pub fn reorder( + &mut self, + entity: Entity, + new_parent: Option, + before: Option, + ) -> Result<(), SceneError> { + if !self.world.contains(entity) { + return Err(SceneError::NoSuchEntity); + } + if let Some(parent) = new_parent { + if !self.world.contains(parent) { + return Err(SceneError::NoSuchEntity); + } + if parent == entity || self.is_ancestor(entity, parent) { + return Err(SceneError::WouldCycle); + } + } + + self.unlink(entity); + let siblings = match new_parent { + Some(parent) => { + self.parents.insert(entity, parent); + self.children.entry(parent).or_default() + } + None => &mut self.roots, + }; + let index = before + .and_then(|b| siblings.iter().position(|&e| e == b)) + .unwrap_or(siblings.len()); + siblings.insert(index, entity); + Ok(()) + } + + /// Returns `true` if `ancestor` lies on the parent chain above `entity`. + fn is_ancestor(&self, ancestor: Entity, entity: Entity) -> bool { + let mut cursor = self.parents.get(&entity).copied(); + while let Some(p) = cursor { + if p == ancestor { + return true; + } + cursor = self.parents.get(&p).copied(); + } + false + } + + /// The parent of `entity`, or `None` if it is a root or absent. + pub fn parent(&self, entity: Entity) -> Option { + self.parents.get(&entity).copied() + } + + /// The direct children of `entity`, in order. Empty for leaves. + pub fn children(&self, entity: Entity) -> &[Entity] { + self.children.get(&entity).map_or(&[], Vec::as_slice) + } + + /// The top-level entities, in insertion order. + pub fn roots(&self) -> &[Entity] { + &self.roots + } + + // --- Component access -------------------------------------------------- + + /// The node name, or `None` if `entity` is not live. + pub fn name(&self, entity: Entity) -> Option { + self.world.get::<&Node>(entity).ok().map(|n| n.name.clone()) + } + + /// Renames `entity`. Returns `false` if it is not live. + pub fn set_name(&mut self, entity: Entity, name: impl Into) -> bool { + match self.world.get::<&mut Node>(entity) { + Ok(mut node) => { + node.name = name.into(); + true + } + Err(_) => false, + } + } + + /// Whether `entity` is enabled, or `None` if it is not live. + pub fn is_enabled(&self, entity: Entity) -> Option { + self.world.get::<&Node>(entity).ok().map(|n| n.enabled) + } + + /// Sets the enabled flag on `entity`. Returns `false` if it is not live. + pub fn set_enabled(&mut self, entity: Entity, enabled: bool) -> bool { + match self.world.get::<&mut Node>(entity) { + Ok(mut node) => { + node.enabled = enabled; + true + } + Err(_) => false, + } + } + + /// Whether the named component on `entity` is marked disabled by a + /// [`DisabledComponents`](crate::scene::DisabledComponents) component. + /// Defaults to `false` when no `DisabledComponents` is attached. + /// + /// Systems that act on a per-entity component query check this to honor + /// "attached but inactive" — the ECS equivalent of Unity's + /// `Component.enabled = false`. + pub fn is_component_disabled(&self, entity: hecs::Entity, type_name: &str) -> bool { + self.world + .get::<&super::DisabledComponents>(entity) + .ok() + .map(|d| d.is_disabled(type_name)) + .unwrap_or(false) + } + + /// Whether `entity` is enabled **and every ancestor is enabled** — its + /// effective state in the hierarchy. `None` if it is not live. + /// + /// [`is_enabled`](Self::is_enabled) reports an entity's own authored flag; + /// this reports whether it is actually active, since disabling a node + /// disables its whole subtree (rendering, physics, audio, and queries skip + /// effectively-disabled entities). This is the Unity/Godot + /// `activeInHierarchy` distinction: the per-node flag is what you author, + /// the effective value is what systems honor. + pub fn is_effectively_enabled(&self, entity: Entity) -> Option { + if !self.contains(entity) { + return None; + } + let mut current = Some(entity); + while let Some(e) = current { + if !self.is_enabled(e).unwrap_or(true) { + return Some(false); + } + current = self.parent(e); + } + Some(true) + } + + /// The authored (local) transform of `entity`, or `None` if not live. + pub fn local_transform(&self, entity: Entity) -> Option { + self.world.get::<&Transform>(entity).ok().map(|t| *t) + } + + /// Sets the local transform of `entity`. Returns `false` if not live. + pub fn set_local_transform(&mut self, entity: Entity, transform: Transform) -> bool { + match self.world.get::<&mut Transform>(entity) { + Ok(mut t) => { + *t = transform; + true + } + Err(_) => false, + } + } + + // --- World transforms -------------------------------------------------- + + /// Resolves the world-space transform of a single `entity` by composing + /// local transforms up the parent chain. `None` if `entity` is not live. + /// + /// For resolving many entities at once, prefer + /// [`world_transforms`](Self::world_transforms), which is a single pass. + pub fn world_transform(&self, entity: Entity) -> Option { + let local = self.local_transform(entity)?; + match self.parents.get(&entity) { + Some(&parent) => Some(self.world_transform(parent)?.mul_transform(&local)), + None => Some(local), + } + } + + /// Resolves world-space transforms for every entity in the scene in a + /// single top-down pass (`parent_world * local`). + pub fn world_transforms(&self) -> HashMap { + let mut out = HashMap::with_capacity(self.len()); + // Depth-first from each root, carrying the accumulated parent world + // transform down the stack. + let mut stack: Vec<(Entity, Transform)> = Vec::new(); + for &root in &self.roots { + if let Some(local) = self.local_transform(root) { + stack.push((root, local)); + } + } + while let Some((entity, world)) = stack.pop() { + out.insert(entity, world); + if let Some(children) = self.children.get(&entity) { + for &child in children { + if let Some(local) = self.local_transform(child) { + stack.push((child, world.mul_transform(&local))); + } + } + } + } + out + } + + // --- ECS access -------------------------------------------------------- + + /// Whether `entity` is live in this scene. + pub fn contains(&self, entity: Entity) -> bool { + self.world.contains(entity) + } + + /// Number of live entities. + pub fn len(&self) -> usize { + self.world.len() as usize + } + + /// Whether the scene has no entities. + pub fn is_empty(&self) -> bool { + self.world.len() == 0 + } + + /// An iterator over every live entity, in unspecified order. + pub fn entities(&self) -> impl Iterator + '_ { + self.world.iter().map(|e| e.entity()) + } + + /// Borrows a component of `entity`, e.g. `scene.get::(e)`. + pub fn get(&self, entity: Entity) -> Option> { + self.world.get::<&T>(entity).ok() + } + + /// Mutably borrows a component of `entity`. + /// + /// Do not mutate hierarchy state through here — use the scene's own + /// methods so the parent/child bookkeeping stays consistent. + pub fn get_mut(&mut self, entity: Entity) -> Option> { + self.world.get::<&mut T>(entity).ok() + } + + /// The underlying [`hecs::World`], for read-only ECS queries. + pub fn world(&self) -> &World { + &self.world + } + + /// The underlying [`hecs::World`], for attaching extra components. + /// + /// Spawning or despawning directly through the world bypasses the scene's + /// hierarchy bookkeeping; use [`spawn`](Self::spawn) / + /// [`despawn`](Self::despawn) for lifecycle and reserve this for adding or + /// querying non-hierarchy components. + pub fn world_mut(&mut self) -> &mut World { + &mut self.world + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::Vec3; + + fn t(x: f32, y: f32, z: f32) -> Transform { + Transform::from_translation(Vec3::new(x, y, z)) + } + + fn approx(a: Vec3, b: Vec3) -> bool { + (a - b).length() <= 1e-5 + } + + #[test] + fn spawn_makes_roots() { + let mut scene = Scene::new(); + let a = scene.spawn("a", Transform::IDENTITY); + let b = scene.spawn("b", Transform::IDENTITY); + assert_eq!(scene.roots(), &[a, b]); + assert_eq!(scene.len(), 2); + assert_eq!(scene.parent(a), None); + } + + #[test] + fn spawn_child_links_both_ways() { + let mut scene = Scene::new(); + let parent = scene.spawn("parent", Transform::IDENTITY); + let child = scene.spawn_child(parent, "child", Transform::IDENTITY); + assert_eq!(scene.parent(child), Some(parent)); + assert_eq!(scene.children(parent), &[child]); + assert_eq!(scene.roots(), &[parent]); // child is not a root + } + + #[test] + fn world_transform_composes_down_the_chain() { + let mut scene = Scene::new(); + let a = scene.spawn("a", t(1.0, 0.0, 0.0)); + let b = scene.spawn_child(a, "b", t(0.0, 2.0, 0.0)); + let c = scene.spawn_child(b, "c", t(0.0, 0.0, 3.0)); + let w = scene.world_transform(c).unwrap(); + assert!(approx(w.translation, Vec3::new(1.0, 2.0, 3.0))); + } + + #[test] + fn bulk_world_transforms_match_single() { + let mut scene = Scene::new(); + let a = scene.spawn("a", t(5.0, 0.0, 0.0)); + let b = scene.spawn_child(a, "b", t(0.0, 1.0, 0.0)); + let c = scene.spawn_child(a, "c", t(0.0, 0.0, 1.0)); + let all = scene.world_transforms(); + for e in [a, b, c] { + assert!(approx( + all[&e].translation, + scene.world_transform(e).unwrap().translation + )); + } + assert!(approx(all[&b].translation, Vec3::new(5.0, 1.0, 0.0))); + assert!(approx(all[&c].translation, Vec3::new(5.0, 0.0, 1.0))); + } + + #[test] + fn rotation_propagates_to_children() { + use crate::math::Quat; + use std::f32::consts::FRAC_PI_2; + let mut scene = Scene::new(); + // Parent rotated 90° about Z, child offset +X by 1. + let parent = scene.spawn( + "p", + Transform::from_rotation(Quat::from_rotation_z(FRAC_PI_2)), + ); + let child = scene.spawn_child(parent, "c", t(1.0, 0.0, 0.0)); + let w = scene.world_transform(child).unwrap(); + // The +X offset is rotated into +Y by the parent. + assert!(approx(w.translation, Vec3::new(0.0, 1.0, 0.0))); + } + + #[test] + fn despawn_recursive_removes_subtree() { + let mut scene = Scene::new(); + let a = scene.spawn("a", Transform::IDENTITY); + let b = scene.spawn_child(a, "b", Transform::IDENTITY); + let c = scene.spawn_child(b, "c", Transform::IDENTITY); + assert!(scene.despawn(a, DespawnPolicy::Recursive)); + assert!(!scene.contains(a)); + assert!(!scene.contains(b)); + assert!(!scene.contains(c)); + assert!(scene.roots().is_empty()); + assert_eq!(scene.len(), 0); + } + + #[test] + fn despawn_detach_promotes_children_to_grandparent() { + let mut scene = Scene::new(); + let root = scene.spawn("root", Transform::IDENTITY); + let mid = scene.spawn_child(root, "mid", Transform::IDENTITY); + let leaf = scene.spawn_child(mid, "leaf", Transform::IDENTITY); + assert!(scene.despawn(mid, DespawnPolicy::DetachChildren)); + assert!(!scene.contains(mid)); + assert!(scene.contains(leaf)); + // leaf is now a child of root directly. + assert_eq!(scene.parent(leaf), Some(root)); + assert_eq!(scene.children(root), &[leaf]); + } + + #[test] + fn despawn_detach_root_promotes_children_to_roots() { + let mut scene = Scene::new(); + let root = scene.spawn("root", Transform::IDENTITY); + let child = scene.spawn_child(root, "child", Transform::IDENTITY); + assert!(scene.despawn(root, DespawnPolicy::DetachChildren)); + assert_eq!(scene.parent(child), None); + assert_eq!(scene.roots(), &[child]); + } + + #[test] + fn reparent_updates_links() { + let mut scene = Scene::new(); + let a = scene.spawn("a", Transform::IDENTITY); + let b = scene.spawn("b", Transform::IDENTITY); + let c = scene.spawn_child(a, "c", Transform::IDENTITY); + scene.set_parent(c, Some(b)).unwrap(); + assert_eq!(scene.parent(c), Some(b)); + assert_eq!(scene.children(a), &[] as &[Entity]); + assert_eq!(scene.children(b), &[c]); + } + + #[test] + fn reparent_to_root() { + let mut scene = Scene::new(); + let a = scene.spawn("a", Transform::IDENTITY); + let c = scene.spawn_child(a, "c", Transform::IDENTITY); + scene.set_parent(c, None).unwrap(); + assert_eq!(scene.parent(c), None); + assert!(scene.roots().contains(&c)); + assert_eq!(scene.children(a), &[] as &[Entity]); + } + + #[test] + fn reparent_cycle_is_rejected() { + let mut scene = Scene::new(); + let a = scene.spawn("a", Transform::IDENTITY); + let b = scene.spawn_child(a, "b", Transform::IDENTITY); + // Making `a` a child of its own descendant `b` would form a cycle. + assert_eq!(scene.set_parent(a, Some(b)), Err(SceneError::WouldCycle)); + // Self-parenting is also a cycle. + assert_eq!(scene.set_parent(a, Some(a)), Err(SceneError::WouldCycle)); + // The hierarchy is unchanged. + assert_eq!(scene.parent(b), Some(a)); + assert_eq!(scene.parent(a), None); + } + + #[test] + fn reparent_missing_entity_errors() { + let mut scene = Scene::new(); + let a = scene.spawn("a", Transform::IDENTITY); + scene.despawn(a, DespawnPolicy::Recursive); + assert_eq!(scene.set_parent(a, None), Err(SceneError::NoSuchEntity)); + } + + #[test] + fn enable_and_rename() { + let mut scene = Scene::new(); + let a = scene.spawn("a", Transform::IDENTITY); + assert_eq!(scene.is_enabled(a), Some(true)); + assert!(scene.set_enabled(a, false)); + assert_eq!(scene.is_enabled(a), Some(false)); + assert!(scene.set_name(a, "renamed")); + assert_eq!(scene.name(a).as_deref(), Some("renamed")); + } + + #[test] + fn reorder_moves_within_and_between_parents() { + let mut scene = Scene::new(); + let a = scene.spawn("a", Transform::IDENTITY); + let b = scene.spawn("b", Transform::IDENTITY); + let c = scene.spawn("c", Transform::IDENTITY); + assert_eq!(scene.roots(), &[a, b, c]); + + // Reorder within the root list: move c before a → [c, a, b]. + scene.reorder(c, None, Some(a)).unwrap(); + assert_eq!(scene.roots(), &[c, a, b]); + + // Same-parent move that crosses its old slot (no off-by-one): move c to + // just before b → [a, c, b]. + scene.reorder(c, None, Some(b)).unwrap(); + assert_eq!(scene.roots(), &[a, c, b]); + + // Reparent + position: put b under a, before none → appended child. + scene.reorder(b, Some(a), None).unwrap(); + assert_eq!(scene.roots(), &[a, c]); + assert_eq!(scene.children(a), &[b]); + assert_eq!(scene.parent(b), Some(a)); + + // Insert before an existing child: c under a, before b → [c, b]. + scene.reorder(c, Some(a), Some(b)).unwrap(); + assert_eq!(scene.children(a), &[c, b]); + + // Cycle rejected: a cannot become a child of its descendant b. + assert!(matches!( + scene.reorder(a, Some(b), None), + Err(SceneError::WouldCycle) + )); + } + + #[test] + fn spawn_auto_attaches_layers_default() { + // Every entity is inherently *on* some layer (the default if not + // overridden) — so Layer is a node-baked component the scene always + // provides, not something the user has to add. Pinned here. + use super::super::super::layer::Layer; + let mut scene = Scene::new(); + let e = scene.spawn("e", Transform::IDENTITY); + { + let layers = scene.world().get::<&Layer>(e).expect("Layer attached"); + assert_eq!(*layers, Layer::DEFAULT); + } + + let child = scene.spawn_child(e, "child", Transform::IDENTITY); + let layers = scene + .world() + .get::<&Layer>(child) + .expect("child gets Layer too"); + assert_eq!(*layers, Layer::DEFAULT); + } + + #[test] + fn is_component_disabled_reads_the_disabled_set() { + use super::super::DisabledComponents; + let mut scene = Scene::new(); + let e = scene.spawn("e", Transform::IDENTITY); + // No DisabledComponents attached → nothing is disabled. + assert!(!scene.is_component_disabled(e, "MeshRenderer")); + + let mut d = DisabledComponents::new(); + d.set_disabled("MeshRenderer", true); + scene.world_mut().insert_one(e, d).unwrap(); + assert!(scene.is_component_disabled(e, "MeshRenderer")); + assert!(!scene.is_component_disabled(e, "RigidBody")); + } + + #[test] + fn effective_enabled_cascades_from_ancestors() { + let mut scene = Scene::new(); + let player = scene.spawn("player", Transform::IDENTITY); + let camera = scene.spawn_child(player, "camera", Transform::IDENTITY); + let mesh = scene.spawn_child(camera, "mesh", Transform::IDENTITY); + + // All enabled by default → effectively enabled. + assert_eq!(scene.is_effectively_enabled(mesh), Some(true)); + + // Disabling the root disables the whole subtree's effective state, + // even though each descendant's own flag is still true. + scene.set_enabled(player, false); + assert_eq!(scene.is_enabled(camera), Some(true)); // own flag unchanged + assert_eq!(scene.is_effectively_enabled(camera), Some(false)); + assert_eq!(scene.is_effectively_enabled(mesh), Some(false)); + + // Re-enable the root; disable a middle node → only it + below are off. + scene.set_enabled(player, true); + scene.set_enabled(camera, false); + assert_eq!(scene.is_effectively_enabled(player), Some(true)); + assert_eq!(scene.is_effectively_enabled(camera), Some(false)); + assert_eq!(scene.is_effectively_enabled(mesh), Some(false)); + + // A dead entity has no effective state. + let ghost = scene.spawn("ghost", Transform::IDENTITY); + scene.despawn(ghost, DespawnPolicy::Recursive); + assert_eq!(scene.is_effectively_enabled(ghost), None); + } + + #[test] + fn extra_components_via_world() { + // The scene is a real ECS: extra components can ride along on entities. + let mut scene = Scene::new(); + let a = scene.spawn("a", Transform::IDENTITY); + scene.world_mut().insert_one(a, 42u32).unwrap(); + assert_eq!(*scene.get::(a).unwrap(), 42); + } +} diff --git a/engine/src/scene/mod.rs b/engine/src/scene/mod.rs new file mode 100644 index 0000000..6b0f199 --- /dev/null +++ b/engine/src/scene/mod.rs @@ -0,0 +1,56 @@ +//! Scene graph and entity management. +//! +//! Stage 3 builds the world model every later system plugs into. Entities are +//! [`hecs`] handles living in a [`Scene`], which adds a parent/child +//! [`Transform`](crate::math::Transform) hierarchy on top of the bare ECS: +//! +//! - [`Scene`] — owns the entities and the hierarchy; spawn, despawn, +//! reparent, query, and resolve world-space transforms +//! - [`Node`] — per-entity metadata (`name`, `enabled`) +//! - [`DespawnPolicy`] — whether despawning takes the subtree with it or +//! detaches the children +//! +//! Local transforms are authored per entity; the scene resolves them against +//! the hierarchy on demand ([`Scene::world_transform`], +//! [`Scene::world_transforms`]). The node-baked hierarchy serializes to RON via +//! [`Scene::to_ron`] / [`Scene::from_ron`]; a registry-aware +//! [`SceneSnapshot`] (via [`Scene::snapshot`]) additionally captures every +//! reflected component, for play-mode restore and full scene files. +//! +//! `hecs` is re-exported as [`oxide_engine::hecs`](crate::hecs) so consumers +//! share one copy of [`Entity`](hecs::Entity) and the query API. + +mod disabled; +mod graph; +mod node; +mod serialize; +mod snapshot; + +pub use disabled::DisabledComponents; +pub use graph::{DespawnPolicy, Scene}; +pub use node::Node; +pub use snapshot::SceneSnapshot; + +// The handle type is part of the public scene API; re-export it here so callers +// can name it without reaching into the `hecs` re-export. +pub use hecs::Entity; + +/// Errors produced by scene operations. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum SceneError { + /// An operation referenced an entity that is not live in this scene. + #[error("entity does not exist in this scene")] + NoSuchEntity, + + /// A reparent would have made an entity its own ancestor. + #[error("cannot parent an entity to itself or one of its descendants")] + WouldCycle, + + /// Encoding the scene to RON failed. + #[error("scene serialization failed: {0}")] + Serialize(String), + + /// Decoding the scene from RON failed, or the data was inconsistent. + #[error("scene deserialization failed: {0}")] + Deserialize(String), +} diff --git a/engine/src/scene/node.rs b/engine/src/scene/node.rs new file mode 100644 index 0000000..7a3df7e --- /dev/null +++ b/engine/src/scene/node.rs @@ -0,0 +1,46 @@ +//! The [`Node`] component: per-entity scene metadata. + +use serde::{Deserialize, Serialize}; + +/// Metadata attached to every entity that participates in the scene graph. +/// +/// A `Node` carries the human-facing identity of an entity (its `name`, shown +/// in the editor hierarchy) and an `enabled` flag. Disabling a node is a +/// declaration of intent that later systems honor — rendering, physics, and +/// audio skip disabled subtrees — but it does **not** affect transform +/// resolution, which is purely geometric. Stage 3 only stores and edits the +/// flag; the systems that act on it arrive in later stages. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, crate::reflect::Reflect)] +pub struct Node { + /// Display name. Need not be unique; entities are identified by their + /// [`Entity`](hecs::Entity) handle, not by name. + pub name: String, + /// Whether this node (and, by convention, its subtree) is active. + pub enabled: bool, +} + +impl Node { + /// Creates an enabled node with the given name. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + enabled: true, + } + } +} + +impl Default for Node { + /// An enabled, unnamed node. + fn default() -> Self { + Self { + name: String::new(), + enabled: true, + } + } +} + +impl> From for Node { + fn from(name: T) -> Self { + Self::new(name) + } +} diff --git a/engine/src/scene/serialize.rs b/engine/src/scene/serialize.rs new file mode 100644 index 0000000..89dadd2 --- /dev/null +++ b/engine/src/scene/serialize.rs @@ -0,0 +1,242 @@ +//! RON serialization for [`Scene`]. +//! +//! `hecs::Entity` handles are runtime values that are not stable across a +//! save/load, so the scene is flattened to a list of records with array +//! indices standing in for entity references. The list is built in a +//! deterministic pre-order walk of the hierarchy, so a serialize → deserialize +//! → serialize cycle is byte-for-byte stable. + +use std::collections::HashMap; + +use hecs::Entity; +use serde::{Deserialize, Serialize}; + +use super::{Node, Scene, SceneError}; +use crate::math::Transform; + +/// One entity in the flattened scene. `children` holds indices into the +/// surrounding [`SceneData::nodes`] list. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct NodeRecord { + name: String, + enabled: bool, + transform: Transform, + children: Vec, +} + +/// The serializable form of a [`Scene`]: a flat node list plus the indices of +/// the root nodes. Parent links are implied by the `children` arrays. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct SceneData { + nodes: Vec, + roots: Vec, +} + +impl Scene { + /// Serializes the scene to a pretty-printed RON string. + pub fn to_ron(&self) -> Result { + let data = self.to_data(); + ron::ser::to_string_pretty(&data, ron::ser::PrettyConfig::default()) + .map_err(|e| SceneError::Serialize(e.to_string())) + } + + /// Reconstructs a scene from a RON string produced by [`to_ron`](Self::to_ron). + pub fn from_ron(ron: &str) -> Result { + let data: SceneData = + ron::from_str(ron).map_err(|e| SceneError::Deserialize(e.to_string()))?; + Scene::from_data(&data) + } + + /// Flattens the hierarchy into index-based records via a deterministic + /// pre-order walk (roots in order, then each subtree depth-first). + fn to_data(&self) -> SceneData { + let mut index: HashMap = HashMap::with_capacity(self.len()); + let mut order: Vec = Vec::with_capacity(self.len()); + for &root in self.roots() { + self.assign_indices(root, &mut index, &mut order); + } + + let nodes = order + .iter() + .map(|&entity| { + let node = self + .get::(entity) + .expect("entity in hierarchy must have a Node"); + let transform = self + .local_transform(entity) + .expect("entity in hierarchy must have a Transform"); + NodeRecord { + name: node.name.clone(), + enabled: node.enabled, + transform, + children: self.children(entity).iter().map(|c| index[c]).collect(), + } + }) + .collect(); + + let roots = self.roots().iter().map(|r| index[r]).collect(); + SceneData { nodes, roots } + } + + /// Pre-order index assignment helper for [`to_data`](Self::to_data). + fn assign_indices( + &self, + entity: Entity, + index: &mut HashMap, + order: &mut Vec, + ) { + index.insert(entity, order.len()); + order.push(entity); + for &child in self.children(entity) { + self.assign_indices(child, index, order); + } + } + + /// Rebuilds a scene from flattened records, validating index references. + fn from_data(data: &SceneData) -> Result { + let mut scene = Scene::new(); + let n = data.nodes.len(); + + // Spawn every entity first (as a root), so all indices resolve before + // wiring up parent/child links. + let entities: Vec = data + .nodes + .iter() + .map(|rec| { + scene.spawn( + Node { + name: rec.name.clone(), + enabled: rec.enabled, + }, + rec.transform, + ) + }) + .collect(); + + // Re-link: each record's children become children of that record's + // entity (and are removed from the root list). + for (i, rec) in data.nodes.iter().enumerate() { + for &child_idx in &rec.children { + let child = *entities + .get(child_idx) + .ok_or(SceneError::Deserialize(format!( + "child index {child_idx} out of range (have {n} nodes)" + )))?; + scene + .set_parent(child, Some(entities[i])) + .map_err(|e| SceneError::Deserialize(e.to_string()))?; + } + } + + // Validate the declared roots match the entities left parentless. The + // re-link above already produced the correct root set; we just confirm + // the file's `roots` list is consistent so corrupt input is rejected. + for &root_idx in &data.roots { + let entity = *entities + .get(root_idx) + .ok_or(SceneError::Deserialize(format!( + "root index {root_idx} out of range (have {n} nodes)" + )))?; + if scene.parent(entity).is_some() { + return Err(SceneError::Deserialize(format!( + "node {root_idx} is listed as a root but is also a child" + ))); + } + } + + Ok(scene) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::{Quat, Vec3}; + use crate::scene::DespawnPolicy; + + /// Builds a small, varied scene used by the round-trip tests. + fn sample() -> Scene { + let mut scene = Scene::new(); + let root = scene.spawn( + Node { + name: "root".into(), + enabled: true, + }, + Transform::from_translation(Vec3::new(1.0, 2.0, 3.0)), + ); + let arm = scene.spawn_child( + root, + "arm", + Transform::from_rotation(Quat::from_rotation_y(0.5)), + ); + scene.spawn_child(arm, "hand", Transform::from_scale(Vec3::splat(2.0))); + let mut disabled = Node::new("disabled"); + disabled.enabled = false; + scene.spawn_child(root, disabled, Transform::IDENTITY); + // A second independent root, to exercise multi-root serialization. + scene.spawn("other-root", Transform::from_translation(Vec3::NEG_X)); + scene + } + + #[test] + fn round_trip_preserves_structure() { + let scene = sample(); + let ron = scene.to_ron().unwrap(); + let restored = Scene::from_ron(&ron).unwrap(); + + // Re-serializing the restored scene yields identical text: structure, + // names, flags, transforms, and ordering all survived. + assert_eq!(ron, restored.to_ron().unwrap()); + assert_eq!(scene.len(), restored.len()); + assert_eq!(scene.roots().len(), restored.roots().len()); + } + + #[test] + fn round_trip_preserves_world_transforms() { + let scene = sample(); + let restored = Scene::from_ron(&scene.to_ron().unwrap()).unwrap(); + + // Compare resolved world transforms by name, since entity ids differ + // across the rebuild. + let by_name = |s: &Scene| -> Vec<(String, Vec3)> { + let worlds = s.world_transforms(); + let mut v: Vec<_> = worlds + .iter() + .map(|(&e, t)| (s.name(e).unwrap(), t.translation)) + .collect(); + v.sort_by(|a, b| a.0.cmp(&b.0)); + v + }; + let a = by_name(&scene); + let b = by_name(&restored); + assert_eq!(a.len(), b.len()); + for ((na, ta), (nb, tb)) in a.iter().zip(b.iter()) { + assert_eq!(na, nb); + assert!((*ta - *tb).length() <= 1e-5, "{na}: {ta} vs {tb}"); + } + } + + #[test] + fn empty_scene_round_trips() { + let scene = Scene::new(); + let restored = Scene::from_ron(&scene.to_ron().unwrap()).unwrap(); + assert!(restored.is_empty()); + } + + #[test] + fn reordering_after_edits_still_round_trips() { + // Mutating the scene (despawn) must not break index bookkeeping. + let mut scene = sample(); + let root = scene.roots()[0]; + let kid = scene.children(root)[0]; + scene.despawn(kid, DespawnPolicy::DetachChildren); + let ron = scene.to_ron().unwrap(); + assert_eq!(ron, Scene::from_ron(&ron).unwrap().to_ron().unwrap()); + } + + #[test] + fn corrupt_child_index_is_rejected() { + let bad = r#"(nodes: [(name: "a", enabled: true, transform: (translation: (0,0,0), rotation: (0,0,0,1), scale: (1,1,1)), children: [5])], roots: [0])"#; + assert!(Scene::from_ron(bad).is_err()); + } +} diff --git a/engine/src/scene/snapshot.rs b/engine/src/scene/snapshot.rs new file mode 100644 index 0000000..2e31768 --- /dev/null +++ b/engine/src/scene/snapshot.rs @@ -0,0 +1,377 @@ +//! Registry-aware full-scene capture, for play mode (and future scene files). +//! +//! [`Scene::to_ron`](super::Scene::to_ron) records only the node-baked +//! `Node`/`Transform`/hierarchy. A [`SceneSnapshot`] additionally captures +//! **every reflected component** on each entity through the +//! [`TypeRegistry`], so a scene mutated while *playing* (physics moving bodies, +//! scripts spawning or editing entities) can be restored **bit-for-bit** when +//! play stops — avoiding Unity's classic "edited in play mode, lost it" footgun. +//! +//! Why a separate type from [`SceneData`](super::serialize): the plain RON form +//! is registry-free (it can round-trip without knowing any component types), +//! whereas a snapshot needs the registry to enumerate and serialize arbitrary +//! components. Keeping the two apart means the cheap path stays cheap. +//! +//! Fidelity is bounded by what is *registered*, plus the engine's intrinsic +//! components: every reflected type in the [`TypeRegistry`] is captured, as are +//! the built-in non-reflected components (`Node`/`Transform`, plus the inspector- +//! hidden [`Tags`] and [`DisabledComponents`]). A *module's* component that is +//! neither registered nor one of those is invisible to capture — modules that +//! want play-mode survival register their components, which they do anyway to be +//! editable. Within that set, capture → restore → capture is stable. + +use std::collections::{BTreeMap, HashMap}; + +use hecs::Entity; +use serde::{Deserialize, Serialize}; + +use super::{DisabledComponents, Node, Scene, SceneError}; +use crate::layer::Tags; +use crate::math::Transform; +use crate::reflect::TypeRegistry; + +/// Components captured explicitly via [`Scene::spawn`] on restore, so they are +/// excluded from the per-node component map to avoid storing them twice. +/// (`Layer` is *not* here: it is auto-attached on spawn but carries authored +/// data, so it round-trips through the component map like any other component.) +const SPAWN_BAKED: [&str; 2] = ["Node", "Transform"]; + +/// One entity in a flattened [`SceneSnapshot`]. `children` holds indices into +/// the surrounding [`SceneSnapshot::nodes`] list (entity handles are not stable +/// across a capture/restore, so positions stand in for references). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct SnapshotNode { + name: String, + enabled: bool, + transform: Transform, + children: Vec, + /// Every *other* registered component on the node, `type_name` → RON. + /// A `BTreeMap` so the serialized form is order-stable. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + components: BTreeMap, + /// The entity's gameplay [`Tags`], if any. Captured directly (not via the + /// registry) because `Tags` is an engine-intrinsic component edited through + /// the inspector's dedicated Groups UI, not registered as a generic + /// reflected type — without this, Play→Stop would wipe group membership. + #[serde(default, skip_serializing_if = "Option::is_none")] + tags: Option, + /// The entity's [`DisabledComponents`] set, if any. Captured directly for + /// the same reason as [`tags`](Self::tags): it's hidden engine metadata, not + /// a reflected authored component. + #[serde(default, skip_serializing_if = "Option::is_none")] + disabled: Option, +} + +/// A complete, restorable capture of a [`Scene`]: hierarchy plus every reflected +/// component on each node. See the [module docs](self). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SceneSnapshot { + nodes: Vec, + roots: Vec, +} + +impl SceneSnapshot { + /// Captures `scene` in full, serializing every component the `registry` + /// knows about on each entity. Built from a deterministic pre-order walk of + /// the hierarchy, so two captures of equal scenes compare equal. + pub fn capture(scene: &Scene, registry: &TypeRegistry) -> Self { + let mut index: HashMap = HashMap::with_capacity(scene.len()); + let mut order: Vec = Vec::with_capacity(scene.len()); + for &root in scene.roots() { + assign_indices(scene, root, &mut index, &mut order); + } + + let nodes = order + .iter() + .map(|&entity| { + // Pull the node-baked fields first, then drop the borrow before + // the reflection walk reads the same world. + let (name, enabled) = { + let node = scene + .get::(entity) + .expect("entity in hierarchy must have a Node"); + (node.name.clone(), node.enabled) + }; + let transform = scene + .local_transform(entity) + .expect("entity in hierarchy must have a Transform"); + + let mut components = BTreeMap::new(); + for type_name in registry.components_on(scene.world(), entity) { + if SPAWN_BAKED.contains(&type_name) { + continue; + } + if let Ok(ron) = registry.get_ron(scene.world(), entity, type_name) { + components.insert(type_name.to_string(), ron); + } + } + + SnapshotNode { + name, + enabled, + transform, + children: scene.children(entity).iter().map(|c| index[c]).collect(), + components, + tags: scene.get::(entity).map(|t| (*t).clone()), + disabled: scene + .get::(entity) + .map(|d| (*d).clone()), + } + }) + .collect(); + + let roots = scene.roots().iter().map(|r| index[r]).collect(); + SceneSnapshot { nodes, roots } + } + + /// Rebuilds a fresh [`Scene`] from this snapshot. Spawns every node (which + /// auto-attaches the node-baked `Node`/`Transform`/`Layer`), re-links the + /// hierarchy, then applies each captured component over the defaults. + /// + /// Entity handles in the new scene differ from the captured ones — callers + /// holding an [`Entity`] (e.g. an editor selection) must drop or re-resolve + /// it after a restore. + /// + /// # Errors + /// [`Deserialize`](SceneError::Deserialize) if an index reference is out of + /// range or a component's stored RON no longer parses for its type. + pub fn restore(&self, registry: &TypeRegistry) -> Result { + let mut scene = Scene::new(); + let n = self.nodes.len(); + + // Spawn every entity first (as a root) so all indices resolve before + // wiring parent/child links. + let entities: Vec = self + .nodes + .iter() + .map(|rec| { + scene.spawn( + Node { + name: rec.name.clone(), + enabled: rec.enabled, + }, + rec.transform, + ) + }) + .collect(); + + // Re-link the hierarchy. + for (i, rec) in self.nodes.iter().enumerate() { + for &child_idx in &rec.children { + let child = *entities.get(child_idx).ok_or_else(|| { + SceneError::Deserialize(format!( + "child index {child_idx} out of range (have {n} nodes)" + )) + })?; + scene + .set_parent(child, Some(entities[i])) + .map_err(|e| SceneError::Deserialize(e.to_string()))?; + } + } + + // Apply captured components over the spawn defaults. + for (i, rec) in self.nodes.iter().enumerate() { + for (type_name, ron) in &rec.components { + registry + .set_ron(scene.world_mut(), entities[i], type_name, ron) + .map_err(|e| SceneError::Deserialize(e.to_string()))?; + } + // Reinstate the engine-intrinsic, non-reflected components. + if let Some(tags) = &rec.tags { + let _ = scene.world_mut().insert_one(entities[i], tags.clone()); + } + if let Some(disabled) = &rec.disabled { + let _ = scene.world_mut().insert_one(entities[i], disabled.clone()); + } + } + + Ok(scene) + } + + /// Serializes the snapshot to a pretty-printed RON string. + /// + /// # Errors + /// [`Serialize`](SceneError::Serialize) if encoding fails. + pub fn to_ron(&self) -> Result { + ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default()) + .map_err(|e| SceneError::Serialize(e.to_string())) + } + + /// Reconstructs a snapshot from a string produced by [`to_ron`](Self::to_ron). + /// + /// # Errors + /// [`Deserialize`](SceneError::Deserialize) if the text is not valid. + pub fn from_ron(ron: &str) -> Result { + ron::from_str(ron).map_err(|e| SceneError::Deserialize(e.to_string())) + } +} + +/// Pre-order index assignment: mirrors the plain-RON walk so snapshot and +/// `to_ron` order entities identically. +fn assign_indices( + scene: &Scene, + entity: Entity, + index: &mut HashMap, + order: &mut Vec, +) { + index.insert(entity, order.len()); + order.push(entity); + for &child in scene.children(entity) { + assign_indices(scene, child, index, order); + } +} + +impl Scene { + /// Captures this scene in full (hierarchy + every reflected component) into + /// a restorable [`SceneSnapshot`]. See that type for why it differs from + /// [`to_ron`](Self::to_ron). + pub fn snapshot(&self, registry: &TypeRegistry) -> SceneSnapshot { + SceneSnapshot::capture(self, registry) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::Vec3; + use crate::render::{MeshRenderer, PrimitiveShape}; + + /// A registry seeded like the editor's: node-baked types plus a couple of + /// modular components, so snapshots exercise the component map. + fn registry() -> TypeRegistry { + let mut r = TypeRegistry::new(); + r.register_reflected::("Transform"); + r.register_reflected::("Node"); + r.register_reflected::("Layer"); + r.register_reflected::("MeshRenderer"); + r + } + + fn sample_scene() -> Scene { + let mut scene = Scene::new(); + let parent = scene.spawn("parent", Transform::IDENTITY); + let child = scene.spawn( + "child", + Transform::from_translation(Vec3::new(1.0, 2.0, 3.0)), + ); + scene.set_parent(child, Some(parent)).unwrap(); + scene + .world_mut() + .insert_one( + child, + MeshRenderer { + shape: PrimitiveShape::Sphere, + ..MeshRenderer::default() + }, + ) + .unwrap(); + scene + } + + #[test] + fn capture_restore_round_trips_components_and_hierarchy() { + let reg = registry(); + let scene = sample_scene(); + let snap = scene.snapshot(®); + + let restored = snap.restore(®).unwrap(); + // Hierarchy (plain RON) matches. + assert_eq!(scene.to_ron().unwrap(), restored.to_ron().unwrap()); + // And the full snapshot (incl. components) matches. + assert_eq!(snap, restored.snapshot(®)); + } + + #[test] + fn restore_reinstates_a_modular_component() { + let reg = registry(); + let scene = sample_scene(); + let snap = scene.snapshot(®); + let restored = snap.restore(®).unwrap(); + + // The child's MeshRenderer (Sphere) survived the round-trip. + let child = restored + .entities() + .find(|&e| { + restored + .get::(e) + .map(|n| n.name == "child") + .unwrap_or(false) + }) + .unwrap(); + let mesh = restored.get::(child).unwrap(); + assert_eq!(mesh.shape, PrimitiveShape::Sphere); + } + + #[test] + fn stop_reverts_a_play_mode_mutation_bit_for_bit() { + // The play-mode contract: snapshot, mutate (as a tick would), restore, + // and the scene returns to its captured form. + let reg = registry(); + let mut scene = sample_scene(); + let snap = scene.snapshot(®); + + // Mutate every transform, as a physics/script tick might. + let entities: Vec<_> = scene.entities().collect(); + for e in entities { + let t = scene.local_transform(e).unwrap(); + scene.set_local_transform(e, Transform::from_translation(t.translation + Vec3::X)); + } + assert_ne!(snap, scene.snapshot(®)); + + let reverted = snap.restore(®).unwrap(); + assert_eq!(snap, reverted.snapshot(®)); + } + + #[test] + fn captures_intrinsic_tags_and_disabled() { + use crate::layer::Tags; + use crate::scene::DisabledComponents; + + let reg = registry(); + let mut scene = sample_scene(); + let parent = scene + .entities() + .find(|&e| { + scene + .get::(e) + .map(|n| n.name == "parent") + .unwrap_or(false) + }) + .unwrap(); + scene + .world_mut() + .insert_one(parent, Tags::single("Enemy")) + .unwrap(); + let mut dc = DisabledComponents::new(); + dc.set_disabled("MeshRenderer", true); + scene.world_mut().insert_one(parent, dc).unwrap(); + + let snap = scene.snapshot(®); + let restored = snap.restore(®).unwrap(); + // Both engine-intrinsic, non-reflected components survive the round-trip + // even though neither is in the registry. + assert_eq!(snap, restored.snapshot(®)); + let rparent = restored + .entities() + .find(|&e| { + restored + .get::(e) + .map(|n| n.name == "parent") + .unwrap_or(false) + }) + .unwrap(); + assert!(restored.get::(rparent).unwrap().contains("Enemy")); + assert!(restored + .get::(rparent) + .unwrap() + .is_disabled("MeshRenderer")); + } + + #[test] + fn ron_round_trips() { + let reg = registry(); + let snap = sample_scene().snapshot(®); + let ron = snap.to_ron().unwrap(); + assert_eq!(snap, SceneSnapshot::from_ron(&ron).unwrap()); + } +} diff --git a/engine/src/settings.rs b/engine/src/settings.rs new file mode 100644 index 0000000..be76bec --- /dev/null +++ b/engine/src/settings.rs @@ -0,0 +1,273 @@ +//! The settings / preferences framework. +//! +//! A unified, serialized configuration store shared by the engine, the editor, +//! and modules. Each contributor registers a typed **section** (a plain +//! `serde`-serializable struct) under a name; the framework persists every +//! section to RON and restores it, without any central code knowing the +//! sections' shapes. This is what lets: +//! +//! - **engine** preferences (render/quality defaults), +//! - **editor** preferences (theme, layout, shortcuts), and +//! - **per-module** settings (each module's own options) +//! +//! all live in one place, while a [`Project`](crate::project::Project) persists +//! the per-project subset (it stores section → RON blobs that line up exactly +//! with [`Settings::export`]/[`Settings::import`]). +//! +//! ``` +//! use oxide_engine::settings::Settings; +//! use serde::{Serialize, Deserialize}; +//! +//! #[derive(Serialize, Deserialize, Default, PartialEq, Debug)] +//! struct EditorPrefs { theme: String, grid: bool } +//! +//! let mut settings = Settings::new(); +//! settings.register::("editor"); +//! settings.get_mut::("editor").unwrap().theme = "dark".into(); +//! +//! // Persist every section to RON, and restore it later. +//! let saved = settings.export(); +//! let mut restored = Settings::new(); +//! restored.register::("editor"); +//! restored.import(&saved); +//! assert_eq!(restored.get::("editor").unwrap().theme, "dark"); +//! ``` + +use std::any::Any; +use std::collections::BTreeMap; + +use serde::de::DeserializeOwned; +use serde::Serialize; + +/// The monomorphized operations for one registered section, as plain function +/// pointers (the closures capture nothing). +struct SectionOps { + value: Box, + to_ron: fn(&dyn Any) -> Option, + from_ron: fn(&str) -> Option>, + default: fn() -> Box, +} + +/// A registry of typed, serializable settings sections keyed by name. +#[derive(Default)] +pub struct Settings { + sections: BTreeMap<&'static str, SectionOps>, +} + +impl Settings { + /// An empty settings store. + pub fn new() -> Self { + Self::default() + } + + /// Registers section type `T` under `name`, initialized to `T::default()`. + /// Re-registering the same name resets it to default. + pub fn register(&mut self, name: &'static str) + where + T: Serialize + DeserializeOwned + Default + 'static, + { + self.sections.insert( + name, + SectionOps { + value: Box::new(T::default()), + to_ron: |any| any.downcast_ref::().and_then(|v| ron::to_string(v).ok()), + from_ron: |text| { + ron::from_str::(text) + .ok() + .map(|v| Box::new(v) as Box) + }, + default: || Box::new(T::default()) as Box, + }, + ); + } + + /// Whether a section is registered under `name`. + pub fn is_registered(&self, name: &str) -> bool { + self.sections.contains_key(name) + } + + /// The registered section names, sorted. + pub fn names(&self) -> impl Iterator + '_ { + self.sections.keys().copied() + } + + /// Borrows section `name` as `T`, or `None` if absent or the type mismatches. + pub fn get(&self, name: &str) -> Option<&T> { + self.sections.get(name)?.value.downcast_ref::() + } + + /// Mutably borrows section `name` as `T`. + pub fn get_mut(&mut self, name: &str) -> Option<&mut T> { + self.sections.get_mut(name)?.value.downcast_mut::() + } + + /// Replaces the value of section `name`. Returns whether it was registered + /// (with a matching type). + pub fn set(&mut self, name: &str, value: T) -> bool { + match self.sections.get_mut(name) { + // Only overwrite if the registered type matches. + Some(section) if section.value.is::() => { + section.value = Box::new(value); + true + } + _ => false, + } + } + + /// Resets section `name` to its default. Returns whether it was registered. + pub fn reset(&mut self, name: &str) -> bool { + match self.sections.get_mut(name) { + Some(section) => { + section.value = (section.default)(); + true + } + None => false, + } + } + + /// Serializes section `name` to RON, or `None` if it is not registered. + pub fn section_ron(&self, name: &str) -> Option { + let section = self.sections.get(name)?; + (section.to_ron)(section.value.as_ref()) + } + + /// Loads section `name` from a RON blob, replacing its value. Returns `false` + /// if the section is not registered or the text fails to parse. + pub fn load_section(&mut self, name: &str, ron: &str) -> bool { + match self.sections.get_mut(name) { + Some(section) => match (section.from_ron)(ron) { + Some(value) => { + section.value = value; + true + } + None => false, + }, + None => false, + } + } + + /// Serializes every section to a `name → RON` map (the format a + /// [`Project`](crate::project::Project) stores). + pub fn export(&self) -> BTreeMap { + self.sections + .iter() + .filter_map(|(name, section)| { + (section.to_ron)(section.value.as_ref()).map(|ron| (name.to_string(), ron)) + }) + .collect() + } + + /// Loads every matching, registered section from a `name → RON` map. + /// Unknown sections are ignored (a module may be disabled); malformed + /// sections are skipped, leaving their current value. + pub fn import(&mut self, map: &BTreeMap) { + for (name, ron) in map { + self.load_section(name, ron); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, Default, PartialEq, Debug)] + struct EditorPrefs { + theme: String, + grid: bool, + } + + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct Render { + shadows: bool, + msaa: u32, + } + impl Default for Render { + fn default() -> Self { + Self { + shadows: true, + msaa: 4, + } + } + } + + fn settings() -> Settings { + let mut s = Settings::new(); + s.register::("editor"); + s.register::("render"); + s + } + + #[test] + fn defaults_and_typed_access() { + let mut s = settings(); + assert_eq!(s.names().collect::>(), vec!["editor", "render"]); + assert!(s.get::("render").unwrap().shadows); + s.get_mut::("editor").unwrap().theme = "dark".into(); + assert_eq!(s.get::("editor").unwrap().theme, "dark"); + // Wrong type → None. + assert!(s.get::("editor").is_none()); + } + + #[test] + fn set_and_reset() { + let mut s = settings(); + assert!(s.set( + "render", + Render { + shadows: false, + msaa: 8 + } + )); + assert_eq!(s.get::("render").unwrap().msaa, 8); + // Setting an unregistered section fails. + assert!(!s.set("missing", 5u32)); + // Reset returns to default. + assert!(s.reset("render")); + assert_eq!(s.get::("render").unwrap(), &Render::default()); + } + + #[test] + fn export_import_round_trips() { + let mut s = settings(); + s.get_mut::("editor").unwrap().theme = "light".into(); + s.get_mut::("editor").unwrap().grid = true; + s.set( + "render", + Render { + shadows: false, + msaa: 2, + }, + ); + let saved = s.export(); + + // A fresh store with the same sections restores the saved values. + let mut restored = settings(); + restored.import(&saved); + assert_eq!( + restored.get::("editor").unwrap(), + &EditorPrefs { + theme: "light".into(), + grid: true + } + ); + assert_eq!(restored.get::("render").unwrap().msaa, 2); + } + + #[test] + fn import_ignores_unknown_and_malformed() { + let mut s = settings(); + let mut map = BTreeMap::new(); + map.insert("editor".to_string(), "(theme:\"x\",grid:true)".to_string()); + map.insert("disabled_module".to_string(), "(whatever:1)".to_string()); + map.insert("render".to_string(), "not valid ron".to_string()); + s.import(&map); + // Known + valid applied. + assert_eq!(s.get::("editor").unwrap().theme, "x"); + // Malformed left the section at its default (unchanged). + assert_eq!(s.get::("render").unwrap(), &Render::default()); + // Unknown silently ignored. + assert!(!s.is_registered("disabled_module")); + } +} diff --git a/engine/src/ui/layout.rs b/engine/src/ui/layout.rs new file mode 100644 index 0000000..c7af221 --- /dev/null +++ b/engine/src/ui/layout.rs @@ -0,0 +1,763 @@ +//! Layout algorithm — turns a [`Widget`] tree into resolved screen rects. +//! +//! [`layout`] is a single recursive top-down pass that mixes a one-shot +//! intrinsic-size measurement (for `FitContent` and `Grow` accounting) with +//! the actual placement. The resulting [`LayoutTree`] is a flat `Vec` of +//! [`LayoutNode`]s; each node records its own `rect`, `content_rect` +//! (padding-inset), and the indices of its direct children. The layout +//! function itself has no GPU, no input, no allocation outside the result — +//! every test in this stage runs headlessly. +//! +//! # Slot vs rect, and why anchor children skip resizing +//! +//! The recursion uses two entry points: +//! +//! - [`arrange_in_slot`] is for stack / grid children and the root: the slot +//! is the **outer space** the widget can occupy; the algorithm applies the +//! widget's margin, sizing, and alignment to derive its rect. +//! - [`arrange_in_rect`] is for anchor children: the rect is *already* what +//! the anchor decided; the widget's margin / sizing / alignment are skipped +//! so the anchor is authoritative. Padding still applies (it's an inside- +//! the-rect concern). This matches the Unity/Godot convention that "anchor +//! determines rect" — sizing knobs would let the child silently disagree +//! with the anchor it was placed by. +//! +//! # DPI scale factor +//! +//! Every linear input (sizing, padding, margin, gaps, anchor offsets) is in +//! logical pixels and multiplied by [`layout`]'s `scale` argument at resolve +//! time. The widget tree is DPI-independent; the layout call is where the +//! display's scale factor enters. + +use glam::Vec2; +use serde::{Deserialize, Serialize}; + +use crate::math::Rect; + +use super::style::{Align, Insets, LayoutStyle, Sizing}; +use super::widget::{AnchorGroup, Grid, Stack, StackDirection, Widget, WidgetId, WidgetKind}; + +/// One node in a resolved [`LayoutTree`] — the widget's id and its on-screen +/// rectangles. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LayoutNode { + /// Mirror of [`Widget::id`]. + pub id: WidgetId, + /// The outer rectangle the widget occupies, after margin / sizing / + /// alignment. + pub rect: Rect, + /// `rect` minus the widget's padding — the area children are arranged + /// inside. + pub content_rect: Rect, + /// Indices into [`LayoutTree::nodes`] of the direct children, in the same + /// order as on the input widget. + pub children: Vec, +} + +/// Result of laying out a widget tree — a flat array of [`LayoutNode`]s with +/// the root at index 0. +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +pub struct LayoutTree { + nodes: Vec, +} + +impl LayoutTree { + /// All nodes, root first, in the pre-order produced by [`layout`]. + pub fn nodes(&self) -> &[LayoutNode] { + &self.nodes + } + + /// The root node (always present after a successful layout). + pub fn root(&self) -> Option<&LayoutNode> { + self.nodes.first() + } + + /// Look up the first node with the given non-empty id. + /// + /// Returns `None` if `id` is empty or no node matches. Linear scan — fine + /// for the dozens-of-widgets trees Stage 8 currently targets; a hash map + /// can be added if a profile says it's hot. + pub fn find(&self, id: &WidgetId) -> Option<&LayoutNode> { + if id.is_empty() { + return None; + } + self.nodes.iter().find(|n| n.id == *id) + } + + /// The direct children of the node at `index`. + pub fn children_of(&self, index: usize) -> impl Iterator + '_ { + self.nodes[index] + .children + .iter() + .map(move |i| &self.nodes[*i as usize]) + } +} + +/// Lay out `root` inside `viewport` at the given DPI `scale`, producing a +/// [`LayoutTree`] with one entry per widget in pre-order. +pub fn layout(root: &Widget, viewport: Rect, scale: f32) -> LayoutTree { + let mut nodes = Vec::with_capacity(root.node_count()); + arrange_in_slot(root, viewport, scale, &mut nodes); + LayoutTree { nodes } +} + +// ---------- internal: recursive arrangement ---------- + +fn arrange_in_slot(widget: &Widget, slot: Rect, scale: f32, out: &mut Vec) -> u32 { + let margin = widget.style.margin.scaled(scale); + let outer = shrink(slot, margin); + let outer_size = outer.size(); + + let intrinsic = measure(widget, outer_size, scale); + let resolved_w = resolve_axis(widget.style.width, outer_size.x, intrinsic.x, scale); + let resolved_h = resolve_axis(widget.style.height, outer_size.y, intrinsic.y, scale); + let resolved = Vec2::new(resolved_w, resolved_h); + + let extra = (outer_size - resolved).max(Vec2::ZERO); + let offset = Vec2::new( + align_offset(widget.style.align_horizontal, extra.x), + align_offset(widget.style.align_vertical, extra.y), + ); + let rect = Rect::from_min_size(outer.min + offset, resolved); + + arrange_in_rect(widget, rect, scale, out) +} + +fn arrange_in_rect(widget: &Widget, rect: Rect, scale: f32, out: &mut Vec) -> u32 { + let padding = widget.style.padding.scaled(scale); + let content_rect = shrink(rect, padding); + + let my_idx = out.len() as u32; + out.push(LayoutNode { + id: widget.id.clone(), + rect, + content_rect, + children: Vec::new(), + }); + + match &widget.kind { + WidgetKind::Leaf { .. } => {} + WidgetKind::Stack(stack) => arrange_stack(my_idx, stack, content_rect, scale, out), + WidgetKind::Grid(grid) => arrange_grid(my_idx, grid, content_rect, scale, out), + WidgetKind::Anchor(group) => arrange_anchor(my_idx, group, content_rect, scale, out), + } + + my_idx +} + +fn arrange_stack( + parent_idx: u32, + stack: &Stack, + content: Rect, + scale: f32, + out: &mut Vec, +) { + let n = stack.children.len(); + if n == 0 { + return; + } + + let gap = stack.gap * scale; + let total_gap = gap * n.saturating_sub(1) as f32; + let content_main = main_extent(stack.direction, content.size()); + let content_cross = cross_extent(stack.direction, content.size()); + + // Pass 1: compute each child's main-axis size (fixed/fit) and tally + // grow weights. + let mut main_sizes: Vec = Vec::with_capacity(n); + let mut grow_weights: Vec> = Vec::with_capacity(n); + let mut fixed_main_total = 0.0_f32; + let mut total_grow = 0.0_f32; + + for child in &stack.children { + let margin = child.style.margin.scaled(scale); + let margin_main = main_extent( + stack.direction, + Vec2::new(margin.horizontal(), margin.vertical()), + ); + let main_sizing = match stack.direction { + StackDirection::Row => child.style.width, + StackDirection::Column => child.style.height, + }; + + let (inner_main, weight) = match main_sizing { + Sizing::Fixed(v) => (v * scale, None), + Sizing::FitContent => { + let m = measure(child, content.size(), scale); + (main_extent(stack.direction, m), None) + } + Sizing::Grow(w) => (0.0, Some(w.max(0.0))), + }; + + if let Some(w) = weight { + total_grow += w; + } + grow_weights.push(weight); + main_sizes.push(inner_main + margin_main); + fixed_main_total += inner_main + margin_main; + } + + let leftover = (content_main - fixed_main_total - total_gap).max(0.0); + if total_grow > 0.0 { + for (i, w) in grow_weights.iter().enumerate() { + if let Some(w) = w { + main_sizes[i] += leftover * (*w / total_grow); + } + } + } + + // After distributing Grow, any remaining slack is positioned via the + // stack's `main_align`. (If any child grew, slack is zero.) + let used_main: f32 = main_sizes.iter().sum::() + total_gap; + let extra = (content_main - used_main).max(0.0); + let start_offset = align_offset(stack.main_align, extra); + + // Pass 2: place each child in its slot. + let mut cursor = start_offset; + let mut child_indices = Vec::with_capacity(n); + for (i, child) in stack.children.iter().enumerate() { + let slot_main = main_sizes[i]; + let slot = make_slot(stack.direction, content, cursor, slot_main, content_cross); + cursor += slot_main + gap; + child_indices.push(arrange_in_slot(child, slot, scale, out)); + } + + out[parent_idx as usize].children = child_indices; +} + +fn arrange_grid( + parent_idx: u32, + grid: &Grid, + content: Rect, + scale: f32, + out: &mut Vec, +) { + if grid.cols == 0 || grid.rows == 0 || grid.children.is_empty() { + return; + } + let gap = grid.gap * scale; + let total_gap_x = gap.x * grid.cols.saturating_sub(1) as f32; + let total_gap_y = gap.y * grid.rows.saturating_sub(1) as f32; + let cell_w = ((content.width() - total_gap_x) / grid.cols as f32).max(0.0); + let cell_h = ((content.height() - total_gap_y) / grid.rows as f32).max(0.0); + let cells = grid.cols * grid.rows; + + let mut child_indices = Vec::with_capacity(grid.children.len().min(cells as usize)); + for (i, child) in grid.children.iter().enumerate() { + if i as u32 >= cells { + break; + } + let row = i as u32 / grid.cols; + let col = i as u32 % grid.cols; + let cell_origin = + content.min + Vec2::new(col as f32 * (cell_w + gap.x), row as f32 * (cell_h + gap.y)); + let slot = Rect::from_min_size(cell_origin, Vec2::new(cell_w, cell_h)); + child_indices.push(arrange_in_slot(child, slot, scale, out)); + } + out[parent_idx as usize].children = child_indices; +} + +fn arrange_anchor( + parent_idx: u32, + group: &AnchorGroup, + content: Rect, + scale: f32, + out: &mut Vec, +) { + let size = content.size(); + let mut child_indices = Vec::with_capacity(group.children.len()); + for child in &group.children { + let a = child.style.anchor; + let min = content.min + size * a.min + a.offset_min * scale; + let max = content.min + size * a.max + a.offset_max * scale; + let target = Rect::new(min, max); + child_indices.push(arrange_in_rect(child, target, scale, out)); + } + out[parent_idx as usize].children = child_indices; +} + +// ---------- internal: measurement ---------- + +fn measure(widget: &Widget, available: Vec2, scale: f32) -> Vec2 { + match &widget.kind { + WidgetKind::Leaf { intrinsic } => *intrinsic * scale, + WidgetKind::Stack(stack) => measure_stack(&widget.style, stack, available, scale), + WidgetKind::Grid(grid) => measure_grid(&widget.style, grid, available, scale), + // Anchor parents derive their children's rects from the parent's size, + // so they can't propose an intrinsic "fit" size; FitContent on an + // anchor parent collapses to zero. + WidgetKind::Anchor(_) => Vec2::ZERO, + } +} + +/// Outer footprint of a child (the slot it would consume in its parent), +/// including its own margin. +fn measure_outer(widget: &Widget, available: Vec2, scale: f32) -> Vec2 { + let intrinsic = measure(widget, available, scale); + let w = match widget.style.width { + Sizing::Fixed(v) => v * scale, + Sizing::FitContent => intrinsic.x, + Sizing::Grow(_) => 0.0, + }; + let h = match widget.style.height { + Sizing::Fixed(v) => v * scale, + Sizing::FitContent => intrinsic.y, + Sizing::Grow(_) => 0.0, + }; + let m = widget.style.margin.scaled(scale); + Vec2::new(w + m.horizontal(), h + m.vertical()) +} + +fn measure_stack(parent_style: &LayoutStyle, stack: &Stack, available: Vec2, scale: f32) -> Vec2 { + let mut main = 0.0_f32; + let mut cross = 0.0_f32; + let n = stack.children.len(); + for child in &stack.children { + let s = measure_outer(child, available, scale); + main += main_extent(stack.direction, s); + cross = cross.max(cross_extent(stack.direction, s)); + } + if n > 1 { + main += stack.gap * scale * (n - 1) as f32; + } + let p = parent_style.padding.scaled(scale); + match stack.direction { + StackDirection::Row => Vec2::new(main + p.horizontal(), cross + p.vertical()), + StackDirection::Column => Vec2::new(cross + p.horizontal(), main + p.vertical()), + } +} + +fn measure_grid(parent_style: &LayoutStyle, grid: &Grid, available: Vec2, scale: f32) -> Vec2 { + if grid.cols == 0 || grid.rows == 0 { + return Vec2::ZERO; + } + let mut cell_w = 0.0_f32; + let mut cell_h = 0.0_f32; + for child in &grid.children { + let s = measure_outer(child, available, scale); + cell_w = cell_w.max(s.x); + cell_h = cell_h.max(s.y); + } + let gap = grid.gap * scale; + let total = Vec2::new( + cell_w * grid.cols as f32 + gap.x * grid.cols.saturating_sub(1) as f32, + cell_h * grid.rows as f32 + gap.y * grid.rows.saturating_sub(1) as f32, + ); + let p = parent_style.padding.scaled(scale); + Vec2::new(total.x + p.horizontal(), total.y + p.vertical()) +} + +// ---------- internal: small helpers ---------- + +fn shrink(r: Rect, i: Insets) -> Rect { + let min = r.min + Vec2::new(i.left, i.top); + let max = r.max - Vec2::new(i.right, i.bottom); + Rect::new(min, max) +} + +fn align_offset(align: Align, extra: f32) -> f32 { + match align { + Align::Start => 0.0, + Align::Center => extra * 0.5, + Align::End => extra, + } +} + +fn resolve_axis(sizing: Sizing, available: f32, intrinsic: f32, scale: f32) -> f32 { + match sizing { + Sizing::Fixed(v) => (v * scale).min(available), + Sizing::Grow(_) => available, + Sizing::FitContent => intrinsic.min(available), + } +} + +fn main_extent(dir: StackDirection, v: Vec2) -> f32 { + match dir { + StackDirection::Row => v.x, + StackDirection::Column => v.y, + } +} + +fn cross_extent(dir: StackDirection, v: Vec2) -> f32 { + match dir { + StackDirection::Row => v.y, + StackDirection::Column => v.x, + } +} + +fn make_slot(dir: StackDirection, content: Rect, cursor: f32, main: f32, cross: f32) -> Rect { + match dir { + StackDirection::Row => { + Rect::from_min_size(content.min + Vec2::new(cursor, 0.0), Vec2::new(main, cross)) + } + StackDirection::Column => { + Rect::from_min_size(content.min + Vec2::new(0.0, cursor), Vec2::new(cross, main)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::style::Anchor; + + fn vp(w: f32, h: f32) -> Rect { + Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h)) + } + + /// `Grow(1.0)` on both axes — the common "fill the parent" style for + /// container tests where intrinsic sizing would collapse the root. + fn grow_both() -> LayoutStyle { + LayoutStyle { + width: Sizing::Grow(1.0), + height: Sizing::Grow(1.0), + ..Default::default() + } + } + + #[test] + fn single_leaf_takes_intrinsic_size_at_origin() { + let w = Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a"); + let tree = layout(&w, vp(800.0, 600.0), 1.0); + let n = tree.find(&"a".into()).unwrap(); + assert_eq!( + n.rect, + Rect::from_min_size(Vec2::ZERO, Vec2::new(40.0, 20.0)) + ); + assert_eq!(n.content_rect, n.rect); + assert_eq!(tree.nodes().len(), 1); + } + + #[test] + fn dpi_scale_doubles_sizes() { + let w = Widget::leaf(Vec2::new(40.0, 20.0)); + let tree = layout(&w, vp(800.0, 600.0), 2.0); + let n = tree.root().unwrap(); + assert_eq!(n.rect.size(), Vec2::new(80.0, 40.0)); + } + + #[test] + fn row_stack_places_fixed_children_with_gap() { + let row = Widget::row() + .with_id("row") + .with_gap(4.0) + .with_child(Widget::leaf(Vec2::new(30.0, 20.0)).with_id("a")) + .with_child(Widget::leaf(Vec2::new(50.0, 20.0)).with_id("b")) + .with_child(Widget::leaf(Vec2::new(10.0, 20.0)).with_id("c")); + let tree = layout(&row, vp(200.0, 100.0), 1.0); + let a = tree.find(&"a".into()).unwrap().rect; + let b = tree.find(&"b".into()).unwrap().rect; + let c = tree.find(&"c".into()).unwrap().rect; + assert_eq!(a, Rect::from_min_size(Vec2::ZERO, Vec2::new(30.0, 20.0))); + assert_eq!( + b, + Rect::from_min_size(Vec2::new(34.0, 0.0), Vec2::new(50.0, 20.0)) + ); + assert_eq!( + c, + Rect::from_min_size(Vec2::new(88.0, 0.0), Vec2::new(10.0, 20.0)) + ); + } + + #[test] + fn row_stack_grow_fills_leftover_space() { + // 200 wide; A=30 fixed, B=Grow, C=10 fixed → B gets 160 wide. + let row = + Widget::row() + .with_id("row") + .with_style(grow_both()) + .with_child(Widget::leaf(Vec2::new(30.0, 20.0)).with_id("a")) + .with_child(Widget::leaf(Vec2::new(0.0, 20.0)).with_id("b").with_style( + LayoutStyle { + width: Sizing::Grow(1.0), + height: Sizing::Fixed(20.0), + ..Default::default() + }, + )) + .with_child(Widget::leaf(Vec2::new(10.0, 20.0)).with_id("c")); + let tree = layout(&row, vp(200.0, 100.0), 1.0); + let b = tree.find(&"b".into()).unwrap().rect; + assert_eq!(b.min.x, 30.0); + assert_eq!(b.width(), 160.0); + assert_eq!(tree.find(&"c".into()).unwrap().rect.min.x, 190.0); + } + + #[test] + fn row_stack_grow_weights_split_proportionally() { + // 300 wide root; A=Grow(1), B=Grow(2) → A gets 100, B gets 200. + let row = Widget::row() + .with_style(grow_both()) + .with_child(Widget::default().with_id("a").with_style(LayoutStyle { + width: Sizing::Grow(1.0), + height: Sizing::Grow(1.0), + ..Default::default() + })) + .with_child(Widget::default().with_id("b").with_style(LayoutStyle { + width: Sizing::Grow(2.0), + height: Sizing::Grow(1.0), + ..Default::default() + })); + let tree = layout(&row, vp(300.0, 30.0), 1.0); + let a = tree.find(&"a".into()).unwrap().rect; + let b = tree.find(&"b".into()).unwrap().rect; + assert_eq!(a.width(), 100.0); + assert_eq!(b.width(), 200.0); + assert_eq!(b.min.x, 100.0); + // Cross axis Grow fills full height. + assert_eq!(a.height(), 30.0); + assert_eq!(b.height(), 30.0); + } + + #[test] + fn row_stack_main_align_center_splits_extra() { + // Two 30-wide children with gap 0 → main extent 60; viewport 200 → + // 140 extra, centered → 70 each side. + let row = Widget::row() + .with_main_align(Align::Center) + .with_style(grow_both()) + .with_child(Widget::leaf(Vec2::new(30.0, 10.0)).with_id("a")) + .with_child(Widget::leaf(Vec2::new(30.0, 10.0)).with_id("b")); + let tree = layout(&row, vp(200.0, 20.0), 1.0); + assert_eq!(tree.find(&"a".into()).unwrap().rect.min.x, 70.0); + assert_eq!(tree.find(&"b".into()).unwrap().rect.min.x, 100.0); + } + + #[test] + fn row_stack_cross_align_end_docks_to_bottom() { + // Child is 30x10 in a 100-wide row with 40 tall → align End → top=30. + let row = Widget::row().with_style(grow_both()).with_child( + Widget::leaf(Vec2::new(30.0, 10.0)) + .with_id("a") + .with_style(LayoutStyle { + align_vertical: Align::End, + ..Default::default() + }), + ); + let tree = layout(&row, vp(100.0, 40.0), 1.0); + let a = tree.find(&"a".into()).unwrap().rect; + assert_eq!(a.min.y, 30.0); + assert_eq!(a.max.y, 40.0); + } + + #[test] + fn column_stack_flows_top_to_bottom() { + let col = Widget::column() + .with_gap(2.0) + .with_child(Widget::leaf(Vec2::new(20.0, 10.0)).with_id("a")) + .with_child(Widget::leaf(Vec2::new(20.0, 30.0)).with_id("b")); + let tree = layout(&col, vp(100.0, 100.0), 1.0); + let a = tree.find(&"a".into()).unwrap().rect; + let b = tree.find(&"b".into()).unwrap().rect; + assert_eq!(a.min, Vec2::ZERO); + assert_eq!(a.max.y, 10.0); + assert_eq!(b.min.y, 12.0); + assert_eq!(b.max.y, 42.0); + } + + #[test] + fn grid_two_by_three_makes_six_equal_cells() { + // 100x60 content, 2 cols × 3 rows, no gap → cells 50x20. + let grid = Widget::grid(2, 3) + .with_style(grow_both()) + .with_children((0..6).map(|i| Widget::leaf(Vec2::ZERO).with_id(format!("c{i}")))); + let tree = layout(&grid, vp(100.0, 60.0), 1.0); + for i in 0..6 { + let row = i / 2; + let col = i % 2; + let n = tree.find(&format!("c{i}").into()).unwrap(); + // Default FitContent of zero intrinsic ⇒ children collapse to + // (col*50, row*20)–(col*50, row*20) at Start align inside the + // cell. Verify the *cell origin* via the node's `rect.min`. + assert_eq!(n.rect.min, Vec2::new(col as f32 * 50.0, row as f32 * 20.0)); + } + } + + #[test] + fn grid_gap_subtracts_from_cell_size() { + let grid = Widget::grid(2, 2) + .with_style(grow_both()) + .with_grid_gap(Vec2::new(10.0, 10.0)) + .with_children((0..4).map(|i| { + Widget::default() + .with_id(format!("c{i}")) + .with_style(LayoutStyle { + width: Sizing::Grow(1.0), + height: Sizing::Grow(1.0), + ..Default::default() + }) + })); + let tree = layout(&grid, vp(110.0, 110.0), 1.0); + // (110 - 10 gap) / 2 = 50 per cell. + for i in 0..4 { + let n = tree.find(&format!("c{i}").into()).unwrap(); + assert_eq!(n.rect.size(), Vec2::new(50.0, 50.0)); + } + // Second column starts at 60 (50 + 10 gap). + assert_eq!(tree.find(&"c1".into()).unwrap().rect.min.x, 60.0); + // Second row starts at 60. + assert_eq!(tree.find(&"c2".into()).unwrap().rect.min.y, 60.0); + } + + #[test] + fn anchor_fill_makes_child_match_parent_content() { + let parent = Widget::anchor() + .with_id("p") + .with_style(LayoutStyle { + width: Sizing::Grow(1.0), + height: Sizing::Grow(1.0), + ..Default::default() + }) + .with_child(Widget::leaf(Vec2::ZERO).with_id("child")); + let tree = layout(&parent, vp(200.0, 100.0), 1.0); + let child = tree.find(&"child".into()).unwrap(); + assert_eq!( + child.rect, + Rect::from_min_size(Vec2::ZERO, Vec2::new(200.0, 100.0)) + ); + } + + #[test] + fn anchor_top_right_with_offsets_places_child_relative_to_corner() { + // Pin the child's top-right at the parent's top-right, then push the + // top-left corner 80 pixels left and 24 pixels down → 80×24 child in + // the top-right corner. + let parent = Widget::anchor() + .with_style(LayoutStyle { + width: Sizing::Grow(1.0), + height: Sizing::Grow(1.0), + ..Default::default() + }) + .with_child( + Widget::leaf(Vec2::ZERO) + .with_id("c") + .with_style(LayoutStyle { + anchor: Anchor::TOP_RIGHT + .with_offsets(Vec2::new(-80.0, 0.0), Vec2::new(0.0, 24.0)), + ..Default::default() + }), + ); + let tree = layout(&parent, vp(300.0, 200.0), 1.0); + let c = tree.find(&"c".into()).unwrap().rect; + assert_eq!(c.min, Vec2::new(220.0, 0.0)); + assert_eq!(c.max, Vec2::new(300.0, 24.0)); + } + + #[test] + fn anchor_dpi_scales_offsets() { + let parent = Widget::anchor() + .with_style(LayoutStyle { + width: Sizing::Grow(1.0), + height: Sizing::Grow(1.0), + ..Default::default() + }) + .with_child( + Widget::leaf(Vec2::ZERO) + .with_id("c") + .with_style(LayoutStyle { + anchor: Anchor::TOP_LEFT.with_offsets(Vec2::ZERO, Vec2::new(40.0, 20.0)), + ..Default::default() + }), + ); + let tree = layout(&parent, vp(400.0, 400.0), 2.0); + let c = tree.find(&"c".into()).unwrap().rect; + assert_eq!(c.min, Vec2::ZERO); + assert_eq!(c.max, Vec2::new(80.0, 40.0)); + } + + #[test] + fn padding_shrinks_content_rect_and_offsets_children() { + let row = Widget::row() + .with_id("row") + .with_style(LayoutStyle { + width: Sizing::Grow(1.0), + height: Sizing::Grow(1.0), + padding: Insets::all(10.0), + ..Default::default() + }) + .with_child(Widget::leaf(Vec2::new(50.0, 20.0)).with_id("a")); + let tree = layout(&row, vp(200.0, 100.0), 1.0); + let row_node = tree.find(&"row".into()).unwrap(); + assert_eq!( + row_node.rect, + Rect::from_min_size(Vec2::ZERO, Vec2::new(200.0, 100.0)) + ); + assert_eq!( + row_node.content_rect, + Rect::from_min_size(Vec2::splat(10.0), Vec2::new(180.0, 80.0)) + ); + let a = tree.find(&"a".into()).unwrap().rect; + assert_eq!(a.min, Vec2::splat(10.0)); + assert_eq!(a.size(), Vec2::new(50.0, 20.0)); + } + + #[test] + fn margin_reserves_space_outside_widget() { + let row = + Widget::row().with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a").with_style( + LayoutStyle { + margin: Insets::symmetric(5.0, 0.0), + ..Default::default() + }, + )); + let tree = layout(&row, vp(200.0, 100.0), 1.0); + let a = tree.find(&"a".into()).unwrap().rect; + // 5px left margin → child starts at 5, width 40. + assert_eq!(a.min.x, 5.0); + assert_eq!(a.max.x, 45.0); + } + + #[test] + fn fit_content_stack_sums_children_plus_padding() { + // Two 30x10 fixed children, no gap, padding=8 → root 76 x 26. + let row = Widget::row() + .with_id("root") + .with_style(LayoutStyle { + padding: Insets::all(8.0), + ..Default::default() + }) + .with_child(Widget::leaf(Vec2::new(30.0, 10.0))) + .with_child(Widget::leaf(Vec2::new(30.0, 10.0))); + let tree = layout(&row, vp(1000.0, 1000.0), 1.0); + let r = tree.find(&"root".into()).unwrap().rect; + assert_eq!(r.size(), Vec2::new(76.0, 26.0)); + } + + #[test] + fn layout_tree_round_trips_through_ron() { + let w = Widget::row() + .with_id("root") + .with_gap(4.0) + .with_child(Widget::leaf(Vec2::new(20.0, 10.0)).with_id("a")); + let tree = layout(&w, vp(100.0, 50.0), 1.0); + let text = ron::ser::to_string(&tree).unwrap(); + let decoded: LayoutTree = ron::de::from_str(&text).unwrap(); + assert_eq!(tree, decoded); + } + + #[test] + fn find_rejects_empty_id() { + let w = Widget::leaf(Vec2::ONE); + let tree = layout(&w, vp(10.0, 10.0), 1.0); + assert!(tree.find(&WidgetId::default()).is_none()); + } + + #[test] + fn children_of_iterates_direct_children_only() { + let tree = layout( + &Widget::row() + .with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("a")) + .with_child( + Widget::column() + .with_id("col") + .with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("inner")), + ), + vp(100.0, 100.0), + 1.0, + ); + let ids: Vec<_> = tree + .children_of(0) + .map(|n| n.id.as_str().to_owned()) + .collect(); + assert_eq!(ids, vec!["a".to_string(), "col".to_string()]); + } +} diff --git a/engine/src/ui/mod.rs b/engine/src/ui/mod.rs new file mode 100644 index 0000000..3c8b58c --- /dev/null +++ b/engine/src/ui/mod.rs @@ -0,0 +1,88 @@ +//! In-game UI system — widget tree, layout, styling, text, input routing. +//! +//! Stage 8 builds the engine's **in-game** UI — what an exported game uses +//! to draw its menus, HUDs, and tools. This is intentionally distinct from +//! the editor's `egui` (which stays editor-only): a shipped game cannot pull +//! in `egui`, so the runtime owns its own widget tree, lays it out, batches +//! it through the Stage-5 render pipeline, and routes input through the +//! Stage-7 model. +//! +//! Stage 8 is shipped in pieces: +//! +//! 1. **Piece 1 — widget tree + layout (this module, right now).** A flat +//! [`Widget`] data structure, three layout modes ([`Stack`], [`Grid`], +//! [`AnchorGroup`]), and a pure-logic [`layout`] function that turns a +//! tree into a [`LayoutTree`] of resolved screen rects. No rendering, +//! no input, fully testable headlessly. +//! 2. Piece 2 — styling & theming (`Style` / `Theme` + RON dual-edit). +//! 3. Piece 3 — text shaping & glyph atlas. +//! 4. Piece 4 — 2D overlay render pass. +//! 5. Piece 5 — input routing (hit-test, hover/focus/press). +//! 6. Piece 6 — events + data binding. +//! 7. Pieces 7–9 — GUI tail (`examples/ui_menu`, `examples/ui_hud`, editor +//! UI canvas). +//! +//! # Worked example +//! +//! ``` +//! use glam::Vec2; +//! use oxide_engine::math::Rect; +//! use oxide_engine::ui::{layout, Insets, LayoutStyle, Sizing, Widget}; +//! +//! // A toolbar with two buttons and a stretching spacer between them. +//! let toolbar = Widget::row() +//! .with_id("toolbar") +//! .with_gap(8.0) +//! .with_style(LayoutStyle { +//! width: Sizing::Grow(1.0), +//! height: Sizing::Fixed(32.0), +//! padding: Insets::all(4.0), +//! ..Default::default() +//! }) +//! .with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("file")) +//! .with_child( +//! Widget::default() +//! .with_id("spacer") +//! .with_style(LayoutStyle { +//! width: Sizing::Grow(1.0), +//! height: Sizing::Grow(1.0), +//! ..Default::default() +//! }), +//! ) +//! .with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("help")); +//! +//! let viewport = Rect::from_min_size(Vec2::ZERO, Vec2::new(800.0, 600.0)); +//! let tree = layout(&toolbar, viewport, 1.0); +//! let toolbar_rect = tree.root().unwrap().rect; +//! assert_eq!(toolbar_rect.height(), 32.0); +//! let help_rect = tree.find(&"help".into()).unwrap().rect; +//! assert_eq!(help_rect.max.x, 800.0 - 4.0); // padding on the right +//! ``` + +mod layout; +pub mod paint; +mod panel; +pub mod routing; +mod style; +pub mod text; +mod theme; +mod value; +mod visual; +mod widget; + +pub use layout::{layout, LayoutNode, LayoutTree}; +pub use paint::{paint, DrawCommand, PaintedFrame}; +pub use panel::UiPanel; +pub use routing::{hit_test, Router, RouterEvent, RouterFrame}; +pub use style::{Align, Anchor, Insets, LayoutStyle, Sizing}; +pub use text::{ + shape, shape_runs, AtlasEntry, Font, FontError, FontId, FontLoader, FontStore, GlyphAtlas, + GlyphId, GlyphKey, RasterizedGlyph, ShapeParams, ShapedGlyph, ShapedLine, ShapedText, + TextAlign, TextRun, TextStyle, +}; +pub use theme::Theme; +pub use value::WidgetValue; +pub use visual::{Border, FontRef, FontWeight, VisualStyle}; +pub use widget::{ + AnchorGroup, Grid, Stack, StackDirection, Widget, WidgetId, WidgetKind, WidgetPath, +}; diff --git a/engine/src/ui/paint.rs b/engine/src/ui/paint.rs new file mode 100644 index 0000000..5283524 --- /dev/null +++ b/engine/src/ui/paint.rs @@ -0,0 +1,319 @@ +//! Paint — turn a laid-out widget tree into a flat list of draw commands. +//! +//! Layout (piece 1) is purely geometric: rects in, rects out. Paint (piece 4) +//! adds the *visual* dimension: solid fills for backgrounds, textured quads +//! for text. The output is a [`PaintedFrame`] — a flat list of +//! [`DrawCommand`]s the [`UiOverlayPass`](super::super::render::UiOverlayPass) +//! consumes directly. Keeping paint pure-CPU and the GPU pass downstream +//! lets every paint test run headlessly; the GPU pass only has to know how +//! to *consume* commands, not how to derive them. +//! +//! # Algorithm +//! +//! 1. Walk the [`LayoutTree`] in node order (root first, children after). +//! 2. For each laid-out node: +//! - Resolve its [`VisualStyle`] under the active [`Theme`]. +//! - If the resolved style has a background, emit one [`DrawCommand::Quad`] +//! filling `node.rect`. +//! - If the source widget has `text`, shape it inside `node.content_rect` +//! with the resolved font / size / color, then emit one +//! [`DrawCommand::Glyph`] per non-space glyph. +//! 3. The frame's overall `size` mirrors the layout root's rect so the GPU +//! pass knows how big the viewport for this batch is. +//! +//! Render order is the layout order: parents before children, so the +//! children draw *on top of* their parents (matching standard UI layering). + +use glam::Vec2; + +use super::layout::LayoutTree; +use super::text::{shape, FontStore, GlyphKey, ShapeParams, TextStyle}; +use super::theme::Theme; +use super::visual::VisualStyle; +use super::widget::Widget; +use crate::math::{Color, Rect}; + +/// One draw call in a painted UI frame. +/// +/// All commands share a single GPU pipeline and one texture (the glyph +/// atlas). Solid quads emit a sentinel UV the shader recognises as +/// "untextured" so a single fragment path handles both cases. +#[derive(Debug, Clone, PartialEq)] +pub enum DrawCommand { + /// A solid-colored axis-aligned rectangle. + Quad { rect: Rect, color: Color }, + /// One glyph quad — the renderer turns the [`GlyphKey`] into an atlas + /// region at draw time. `pen_position` is the **baseline** point; the + /// atlas's per-glyph bearing positions the quad relative to it. + Glyph { + key: GlyphKey, + pen_position: Vec2, + color: Color, + }, +} + +/// Output of [`paint`] — the size of the painted area and the ordered list +/// of draw commands. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct PaintedFrame { + /// Size of the painted area in (post-scale) pixels — usually the + /// layout root's rect size. + pub size: Vec2, + /// Draw commands in the order they should be submitted (back-to-front). + pub commands: Vec, +} + +/// Walk a laid-out widget tree under a theme and produce the draw commands +/// for one frame. +/// +/// `scale` matches the value passed to +/// [`layout`](super::layout::layout) — paint uses it to pass the same DPI +/// factor to [`shape`] for text. +pub fn paint( + root: &Widget, + tree: &LayoutTree, + theme: &Theme, + fonts: &FontStore, + scale: f32, +) -> PaintedFrame { + let mut commands = Vec::new(); + paint_widget(root, tree, 0, theme, fonts, scale, &mut commands); + let size = tree + .root() + .map(|node| node.rect.size()) + .unwrap_or(Vec2::ZERO); + PaintedFrame { size, commands } +} + +fn paint_widget( + widget: &Widget, + tree: &LayoutTree, + node_index: usize, + theme: &Theme, + fonts: &FontStore, + scale: f32, + out: &mut Vec, +) { + let node = &tree.nodes()[node_index]; + let resolved = widget.resolve_visual(theme); + + // Background fill — only emit if the rect has area and a background was + // resolved. A `corner_radius` is captured in the resolved style for + // future use but ignored by piece-4's rectangular renderer. + if let Some(bg) = resolved.background { + if !node.rect.is_empty() { + out.push(DrawCommand::Quad { + rect: node.rect, + color: bg, + }); + } + } + + // Text — shape inside `content_rect` (so padding is respected) and emit + // one glyph per non-empty position. + if let Some(text) = widget.text.as_ref() { + paint_text(text, node.content_rect, &resolved, fonts, scale, out); + } + + // Children draw on top of self. + for (child_widget, child_index) in widget.children().iter().zip(node.children.iter()) { + paint_widget( + child_widget, + tree, + *child_index as usize, + theme, + fonts, + scale, + out, + ); + } +} + +fn paint_text( + text: &str, + content_rect: Rect, + resolved: &VisualStyle, + fonts: &FontStore, + scale: f32, + out: &mut Vec, +) { + let Some(font_ref) = resolved.font.as_ref() else { + return; + }; + let Some(font_id) = fonts.resolve(font_ref) else { + return; + }; + let size_px = resolved.font_size.unwrap_or(14.0); + let color = resolved.foreground.unwrap_or(Color::BLACK); + let style = TextStyle { + font: font_id, + size_px, + }; + let params = ShapeParams { + max_width: Some(content_rect.width()), + scale, + ..ShapeParams::default() + }; + let shaped = shape(text, style, ¶ms, fonts); + for line in &shaped.lines { + for g in &line.glyphs { + out.push(DrawCommand::Glyph { + key: g.key, + pen_position: content_rect.min + g.position, + color, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::super::layout::layout; + use super::super::text::{common_system_font_paths, Font}; + use super::super::visual::FontRef; + use super::super::widget::Widget; + use super::*; + use glam::Vec2; + + fn viewport(w: f32, h: f32) -> Rect { + Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h)) + } + + fn solid_panel(color: Color, w: f32, h: f32) -> Widget { + Widget::leaf(Vec2::new(w, h)).with_visual(VisualStyle { + background: Some(color), + ..VisualStyle::EMPTY + }) + } + + #[test] + fn solid_widget_emits_one_quad_at_its_rect() { + let root = solid_panel(Color::RED, 40.0, 20.0); + let tree = layout(&root, viewport(100.0, 50.0), 1.0); + let theme = Theme::new(); + let fonts = FontStore::new(); + let painted = paint(&root, &tree, &theme, &fonts, 1.0); + assert_eq!(painted.size, Vec2::new(40.0, 20.0)); + assert_eq!(painted.commands.len(), 1); + match &painted.commands[0] { + DrawCommand::Quad { rect, color } => { + assert_eq!( + *rect, + Rect::from_min_size(Vec2::ZERO, Vec2::new(40.0, 20.0)) + ); + assert_eq!(*color, Color::RED); + } + _ => panic!("expected a Quad"), + } + } + + #[test] + fn widget_without_visual_emits_no_quads() { + // Default widget has empty visual — nothing to paint. + let root = Widget::leaf(Vec2::new(40.0, 20.0)); + let tree = layout(&root, viewport(100.0, 50.0), 1.0); + let painted = paint(&root, &tree, &Theme::new(), &FontStore::new(), 1.0); + assert!(painted.commands.is_empty()); + } + + #[test] + fn child_quad_is_emitted_after_parent_quad() { + let root = Widget::row() + .with_visual(VisualStyle { + background: Some(Color::WHITE), + ..VisualStyle::EMPTY + }) + .with_style(super::super::style::LayoutStyle { + width: super::super::style::Sizing::Fixed(100.0), + height: super::super::style::Sizing::Fixed(50.0), + ..Default::default() + }) + .with_child(solid_panel(Color::RED, 40.0, 20.0)); + let tree = layout(&root, viewport(200.0, 100.0), 1.0); + let painted = paint(&root, &tree, &Theme::new(), &FontStore::new(), 1.0); + assert_eq!(painted.commands.len(), 2); + // Parent (white) painted before child (red), so child draws on top. + match &painted.commands[0] { + DrawCommand::Quad { color, .. } => assert_eq!(*color, Color::WHITE), + _ => panic!(), + } + match &painted.commands[1] { + DrawCommand::Quad { color, .. } => assert_eq!(*color, Color::RED), + _ => panic!(), + } + } + + fn try_load_font() -> Option { + for path in common_system_font_paths() { + if std::path::Path::new(path).exists() { + if let Ok(font) = Font::from_path(path) { + return Some(font); + } + } + } + eprintln!("SKIP: no system font available for paint tests"); + None + } + + #[test] + fn text_emits_one_glyph_per_visible_char() { + let Some(font) = try_load_font() else { + return; + }; + let descriptor = FontRef::regular("Sys"); + let mut fonts = FontStore::new(); + fonts.insert_with_descriptor(descriptor.clone(), font); + let theme = Theme::new().with_default(VisualStyle { + font: Some(descriptor), + font_size: Some(14.0), + foreground: Some(Color::BLACK), + ..VisualStyle::EMPTY + }); + + let root = Widget::leaf(Vec2::new(80.0, 20.0)) + .with_id("label") + .with_text("Hi") + .with_style(super::super::style::LayoutStyle { + width: super::super::style::Sizing::Fixed(80.0), + height: super::super::style::Sizing::Fixed(20.0), + ..Default::default() + }); + let tree = layout(&root, viewport(200.0, 100.0), 1.0); + let painted = paint(&root, &tree, &theme, &fonts, 1.0); + + // "Hi" → 2 glyphs (H, i). No background → no Quad commands. + let glyph_count = painted + .commands + .iter() + .filter(|c| matches!(c, DrawCommand::Glyph { .. })) + .count(); + let quad_count = painted + .commands + .iter() + .filter(|c| matches!(c, DrawCommand::Quad { .. })) + .count(); + assert_eq!(glyph_count, 2); + assert_eq!(quad_count, 0); + + // Both glyphs sit at the same baseline. + let baselines: Vec = painted + .commands + .iter() + .filter_map(|c| match c { + DrawCommand::Glyph { pen_position, .. } => Some(pen_position.y), + _ => None, + }) + .collect(); + assert_eq!(baselines[0], baselines[1]); + } + + #[test] + fn text_without_font_in_theme_silently_emits_nothing() { + // No font registered → text resolves but shape returns no lines. + // Paint must not panic. + let root = Widget::leaf(Vec2::new(40.0, 20.0)).with_text("Hi"); + let tree = layout(&root, viewport(100.0, 50.0), 1.0); + let painted = paint(&root, &tree, &Theme::new(), &FontStore::new(), 1.0); + assert!(painted.commands.is_empty()); + } +} diff --git a/engine/src/ui/panel.rs b/engine/src/ui/panel.rs new file mode 100644 index 0000000..5565898 --- /dev/null +++ b/engine/src/ui/panel.rs @@ -0,0 +1,231 @@ +//! World-space UI panels — a [`Widget`] tree rendered onto a quad in 3D. +//! +//! Stage 8's UI is "in-game UI" — what the exported game uses to draw +//! menus and HUDs. Most of the time those are **screen-space**: pixel- +//! anchored, drawn over the 3D scene by piece 4a's +//! [`UiOverlayPass`](super::super::render::UiOverlayPass) using an +//! orthographic projection. A [`UiPanel`] is the world-space alternative — +//! the same `Widget` tree, but laid out on a flat panel that sits in the +//! 3D world at some [`Transform`]. +//! +//! This is what gives game projects: +//! +//! - **Diegetic UI** — terminal screens, signs, dashboards, control +//! panels — the player sees them rendered inside the world rather than +//! pasted over it. +//! - **Editor previews** — the UI canvas (piece 9) can drop a panel into +//! the scene to preview a document at scale, on the same hardware path +//! the shipped game uses. +//! - **VR / room-scale UI** later — once Stage-13 head-mounted display +//! support lands, world-space panels are the only sensible way to +//! present interactive UI. +//! +//! # How the math works +//! +//! A panel describes itself in two coordinate spaces: +//! +//! - **Pixel space** — where the layout algorithm operates. `pixel_size` +//! is the resolution the `Widget` tree is laid out at (e.g., +//! `Vec2::new(1024.0, 768.0)`). Glyphs are rasterized at this scale. +//! - **World space** — where the panel sits in 3D. `world_size` is its +//! physical size in world units (e.g., `Vec2::new(2.0, 1.5)` for a +//! 2 m × 1.5 m monitor). +//! +//! The piece-4 vertex format carries 2D pixel-space positions. To draw +//! that on a 3D quad, [`UiBatch::world_space`](super::super::render::UiBatch::world_space) +//! builds a single MVP that composes: +//! +//! ```text +//! mvp = camera_view_projection +//! * panel_transform // world placement +//! * scale(world.x / pixel.x, -world.y / pixel.y, 1) // pixels → world (and flip y, since UI is y-down) +//! * translate(-pixel.x / 2, -pixel.y / 2, 0) // recentre pixel origin +//! ``` +//! +//! The same `UiOverlayPass` then draws the panel using the same shader +//! and the same R8 atlas — the only thing that distinguishes a screen- +//! space batch from a world-space one is which constructor built it. +//! +//! # Overlay semantics for piece 4b +//! +//! World-space panels in piece 4b render as **overlays**: no depth test, +//! no depth write — they draw on top of whatever's already in the color +//! target. That keeps the implementation simple and matches the common +//! "always-visible" use case (player nameplates, mission markers, +//! editor canvas previews). +//! +//! A future depth-aware mode (where a panel behind a wall is properly +//! hidden) is in [`PLAN.md`](../../../../PLAN.md)'s Stage-8 backlog and +//! slots in by attaching a depth attachment to a second pass of the +//! same pipeline. + +use glam::Mat4; +use serde::{Deserialize, Serialize}; + +use super::layout::layout; +use super::paint::paint; +use super::text::FontStore; +use super::theme::Theme; +use super::widget::Widget; +use crate::math::{Rect, Transform, Vec2}; + +/// A widget tree placed on a 3D quad. +/// +/// `UiPanel` carries pure data: the document, its pixel resolution, and +/// its world size. The host owns the panel's [`Transform`] separately +/// (typically as an ECS component on the same entity), the active +/// [`Theme`], and the [`FontStore`] — all three are needed at render +/// time to build the panel's [`UiBatch`](super::super::render::UiBatch). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UiPanel { + /// The UI document on this panel. + pub root: Widget, + /// Resolution to lay out the UI at, in logical pixels. Drives the + /// pixel size of every glyph rasterization (so a higher + /// `pixel_size.x` on the same `world_size.x` produces a crisper + /// panel at a cost of more atlas memory). + pub pixel_size: Vec2, + /// Panel dimensions in world units. Together with `pixel_size` this + /// gives the pixels-per-world-unit ratio the MVP uses. + pub world_size: Vec2, +} + +impl UiPanel { + /// Build a panel with the given UI document and dimensions. Equivalent + /// to the struct literal; kept as a function so the API can grow + /// validation later without breaking callers. + pub fn new(root: Widget, pixel_size: Vec2, world_size: Vec2) -> Self { + Self { + root, + pixel_size, + world_size, + } + } + + /// Convenience: lay out + paint this panel and build the + /// [`UiBatch`](super::super::render::UiBatch) the + /// [`UiOverlayPass`](super::super::render::UiOverlayPass) consumes. + /// + /// Returns `None` if the panel's `pixel_size` is non-positive — the + /// caller didn't configure the panel and there's no meaningful + /// rendering to do. + pub fn build_batch( + &self, + theme: &Theme, + fonts: &FontStore, + panel_transform: &Transform, + view_projection: Mat4, + ) -> Option { + if self.pixel_size.x <= 0.0 || self.pixel_size.y <= 0.0 { + return None; + } + let viewport = Rect::from_min_size(Vec2::ZERO, self.pixel_size); + let tree = layout(&self.root, viewport, 1.0); + let painted = paint(&self.root, &tree, theme, fonts, 1.0); + Some(super::super::render::UiBatch::world_space( + painted, + self.pixel_size, + self.world_size, + panel_transform, + view_projection, + )) + } +} + +#[cfg(test)] +mod tests { + use super::super::style::{LayoutStyle, Sizing}; + use super::super::visual::VisualStyle; + use super::*; + use crate::math::{Color, Vec3}; + + #[test] + fn build_batch_returns_none_on_zero_pixel_size() { + let panel = UiPanel::new(Widget::default(), Vec2::ZERO, Vec2::new(2.0, 2.0)); + let theme = Theme::new(); + let fonts = FontStore::new(); + let result = panel.build_batch(&theme, &fonts, &Transform::default(), Mat4::IDENTITY); + assert!(result.is_none()); + } + + #[test] + fn build_batch_succeeds_with_valid_panel() { + let root = Widget::default() + .with_style(LayoutStyle { + width: Sizing::Grow(1.0), + height: Sizing::Grow(1.0), + ..Default::default() + }) + .with_visual(VisualStyle { + background: Some(Color::RED), + ..VisualStyle::EMPTY + }); + let panel = UiPanel::new(root, Vec2::new(256.0, 128.0), Vec2::new(2.0, 1.0)); + let theme = Theme::new(); + let fonts = FontStore::new(); + let batch = panel + .build_batch(&theme, &fonts, &Transform::default(), Mat4::IDENTITY) + .expect("valid panel should build a batch"); + // The batch's painted frame matches the panel's pixel size and has + // one Quad command (the red background). + assert_eq!(batch.frame.size, Vec2::new(256.0, 128.0)); + assert_eq!(batch.frame.commands.len(), 1); + } + + #[test] + fn panel_round_trips_through_ron() { + let panel = UiPanel::new( + Widget::row().with_id("hud").with_visual(VisualStyle { + background: Some(Color::WHITE), + ..VisualStyle::EMPTY + }), + Vec2::new(1024.0, 768.0), + Vec2::new(4.0, 3.0), + ); + let text = ron::ser::to_string_pretty(&panel, ron::ser::PrettyConfig::default()).unwrap(); + let decoded: UiPanel = ron::de::from_str(&text).unwrap(); + assert_eq!(panel, decoded); + } + + #[test] + fn identity_mvp_keeps_pixel_origin_at_panel_centre() { + // Sanity: with an identity view-projection and default panel + // transform, a vertex at (0, 0) in pixel space lands at the top- + // left of the panel in world space, which under our MVP becomes + // (-world.x/2, +world.y/2, 0) (y-down → y-up). + let panel = UiPanel::new( + Widget::default() + .with_style(LayoutStyle { + width: Sizing::Grow(1.0), + height: Sizing::Grow(1.0), + ..Default::default() + }) + .with_visual(VisualStyle { + background: Some(Color::RED), + ..VisualStyle::EMPTY + }), + Vec2::new(2.0, 2.0), + Vec2::new(2.0, 2.0), + ); + let batch = panel + .build_batch( + &Theme::new(), + &FontStore::new(), + &Transform::default(), + Mat4::IDENTITY, + ) + .unwrap(); + // Apply the MVP to the pixel-space top-left (0, 0, 0, 1). + let top_left = batch.mvp * Vec3::new(0.0, 0.0, 0.0).extend(1.0); + assert!( + (top_left.x - -1.0).abs() < 1e-5 && (top_left.y - 1.0).abs() < 1e-5, + "top-left should map to (-1, 1) under identity MVP, got {top_left:?}" + ); + // Bottom-right pixel maps to (+world.x/2, -world.y/2). + let bottom_right = batch.mvp * Vec3::new(2.0, 2.0, 0.0).extend(1.0); + assert!( + (bottom_right.x - 1.0).abs() < 1e-5 && (bottom_right.y - -1.0).abs() < 1e-5, + "bottom-right should map to (1, -1), got {bottom_right:?}" + ); + } +} diff --git a/engine/src/ui/routing.rs b/engine/src/ui/routing.rs new file mode 100644 index 0000000..24614d9 --- /dev/null +++ b/engine/src/ui/routing.rs @@ -0,0 +1,642 @@ +//! Input routing — hit-test the UI against the cursor, track hover / press / +//! focus per widget, and tell the host whether the UI captured the frame's +//! input so the game can decide whether to also handle it. +//! +//! Stage 8's UI must *consume input before the game* (PLAN.md): if the +//! cursor is over a button, clicking shouldn't also fire the game-world +//! action bound to that mouse button. The [`Router`] gives the host one +//! object to drive each frame: +//! +//! ```text +//! game loop: +//! input.handle_event(e); ... +//! let frame = router.process(&layout_tree, &input); +//! if !frame.captured_mouse { /* game receives mouse input */ } +//! if !frame.captured_keyboard { /* game receives keys */ } +//! for event in &frame.events { /* run widget callbacks (piece 6) */ } +//! ``` +//! +//! The router is purely a state machine over the Stage-7 [`InputState`] and +//! the Stage-8 [`LayoutTree`] — no GPU, no widget callbacks (those land in +//! piece 6). Tests run headlessly. +//! +//! # Hit-test order +//! +//! Hit testing walks [`LayoutTree::nodes`] in **reverse order**. That order +//! matches the paint order (parents-before-children, earlier siblings +//! before later ones — see [`super::paint`]) — so the *last* node drawn +//! is the *first* one tested, which is exactly the topmost interactive +//! widget under the cursor. +//! +//! Anonymous widgets (`WidgetId::default()`) are treated as transparent +//! for hit-test purposes: the router skips them and looks deeper, so a +//! decorative container without an id doesn't block clicks reaching the +//! button inside it. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use winit::event::MouseButton; + +use super::layout::{LayoutNode, LayoutTree}; +use super::widget::WidgetId; +use crate::input::InputState; +use crate::math::Vec2; + +/// One event emitted by [`Router::process`] for the current frame. +/// +/// Events are ordered: hover changes come first, then per-button press / +/// release / click, then focus changes. Callers in piece 6 will dispatch +/// each event to the matching widget's registered callback. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum RouterEvent { + /// The cursor moved onto this widget this frame. + Hovered(WidgetId), + /// The cursor moved off this widget this frame. + Unhovered(WidgetId), + /// A mouse button was pressed while the cursor was over this widget. + Pressed(WidgetId, MouseButton), + /// A mouse button was released while the cursor was over this widget. + /// May or may not be accompanied by a [`Clicked`](Self::Clicked); see + /// the comment on that variant. + Released(WidgetId, MouseButton), + /// A click completed on this widget: the press *and* release happened + /// over the same widget without the cursor leaving in between. + /// Dragging off cancels the click. + Clicked(WidgetId, MouseButton), + /// This widget became the focused widget. + FocusGained(WidgetId), + /// This widget lost focus. + FocusLost(WidgetId), +} + +/// What [`Router::process`] produces for one frame. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct RouterFrame { + /// Events emitted this frame, in the order they were observed. + pub events: Vec, + /// `true` if the cursor is over any (non-anonymous) widget — the game + /// should not also process this frame's mouse input. + pub captured_mouse: bool, + /// `true` if a widget currently has keyboard focus — the game should + /// not also process this frame's key events. + pub captured_keyboard: bool, +} + +impl RouterFrame { + /// `true` if this frame contains a `Clicked` event on `id` for the + /// given mouse button. The immediate-mode pattern: game code calls + /// `if frame.clicked("play", MouseButton::Left) { start_game() }` + /// instead of registering a callback. + pub fn clicked(&self, id: impl AsRef, button: MouseButton) -> bool { + let id = id.as_ref(); + self.events.iter().any(|e| match e { + RouterEvent::Clicked(w, b) => w.as_str() == id && *b == button, + _ => false, + }) + } + + /// Shorthand for [`clicked`](Self::clicked) with the left button. + pub fn clicked_left(&self, id: impl AsRef) -> bool { + self.clicked(id, MouseButton::Left) + } + + /// `true` if this frame contains a `Pressed` event on `id` with the + /// given mouse button. + pub fn pressed(&self, id: impl AsRef, button: MouseButton) -> bool { + let id = id.as_ref(); + self.events.iter().any(|e| match e { + RouterEvent::Pressed(w, b) => w.as_str() == id && *b == button, + _ => false, + }) + } + + /// `true` if this frame contains a `Released` event on `id` with the + /// given mouse button. + pub fn released(&self, id: impl AsRef, button: MouseButton) -> bool { + let id = id.as_ref(); + self.events.iter().any(|e| match e { + RouterEvent::Released(w, b) => w.as_str() == id && *b == button, + _ => false, + }) + } + + /// `true` if the cursor entered `id` this frame. + pub fn hovered_in(&self, id: impl AsRef) -> bool { + let id = id.as_ref(); + self.events.iter().any(|e| match e { + RouterEvent::Hovered(w) => w.as_str() == id, + _ => false, + }) + } + + /// `true` if the cursor left `id` this frame. + pub fn hovered_out(&self, id: impl AsRef) -> bool { + let id = id.as_ref(); + self.events.iter().any(|e| match e { + RouterEvent::Unhovered(w) => w.as_str() == id, + _ => false, + }) + } + + /// `true` if `id` gained focus this frame. + pub fn focus_gained(&self, id: impl AsRef) -> bool { + let id = id.as_ref(); + self.events.iter().any(|e| match e { + RouterEvent::FocusGained(w) => w.as_str() == id, + _ => false, + }) + } + + /// `true` if `id` lost focus this frame. + pub fn focus_lost(&self, id: impl AsRef) -> bool { + let id = id.as_ref(); + self.events.iter().any(|e| match e { + RouterEvent::FocusLost(w) => w.as_str() == id, + _ => false, + }) + } +} + +/// Per-widget input state machine. Persists hover / focus / pending-press +/// across frames so click-detection (press *and* release on the same +/// widget) works correctly across the multiple frames a click typically +/// spans. +#[derive(Debug, Default)] +pub struct Router { + hovered: Option, + focused: Option, + /// Per-button: the widget that received the most recent un-released + /// press. A click completes if the release happens over the same + /// widget; otherwise the press is cancelled (drag-off semantics). + pending: HashMap, +} + +impl Router { + /// Build an empty router with no hover, no focus, and no pending presses. + pub fn new() -> Self { + Self::default() + } + + /// The currently hovered widget, or `None` when the cursor is not over + /// any addressable widget. + pub fn hovered(&self) -> Option<&WidgetId> { + self.hovered.as_ref() + } + + /// The currently focused widget, or `None` if none. + pub fn focused(&self) -> Option<&WidgetId> { + self.focused.as_ref() + } + + /// Explicitly focus a widget (e.g., from game code after opening a + /// menu). Emits no event — the caller decided to do this. + pub fn set_focused(&mut self, id: Option) { + self.focused = id; + } + + /// Run the input pipeline against one frame's [`InputState`] and the + /// current [`LayoutTree`]. Updates internal state, returns events plus + /// the capture flags. + pub fn process(&mut self, tree: &LayoutTree, input: &InputState) -> RouterFrame { + let mut frame = RouterFrame::default(); + let new_hover = input + .cursor() + .and_then(|c| hit_test(tree, c)) + .map(|node| node.id.clone()); + + // Hover transitions. + if new_hover != self.hovered { + if let Some(old) = self.hovered.take() { + frame.events.push(RouterEvent::Unhovered(old)); + } + if let Some(new) = new_hover.clone() { + frame.events.push(RouterEvent::Hovered(new)); + } + } + self.hovered = new_hover; + frame.captured_mouse = self.hovered.is_some(); + + // Mouse press / release per button. The Stage-7 InputState + // exposes "buttons held" + per-button edge flags; we walk the + // currently-relevant buttons (those held this frame *or* present + // as pending from previous frames). + let mut buttons = std::collections::HashSet::new(); + buttons.extend(input.mouse_buttons_held()); + buttons.extend(self.pending.keys().copied()); + // Common buttons that may have just pressed/released without being + // held now (release edge happens after the held set has cleared + // the button). + for b in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] { + if input.mouse_pressed(b) || input.mouse_released(b) { + buttons.insert(b); + } + } + + for button in buttons { + if input.mouse_pressed(button) { + if let Some(target) = self.hovered.clone() { + frame + .events + .push(RouterEvent::Pressed(target.clone(), button)); + self.pending.insert(button, target.clone()); + self.update_focus(Some(target), &mut frame); + } else { + // Click outside any widget clears focus. + self.update_focus(None, &mut frame); + } + } + if input.mouse_released(button) { + if let Some(pending_id) = self.pending.remove(&button) { + if let Some(current) = self.hovered.clone() { + frame + .events + .push(RouterEvent::Released(current.clone(), button)); + if current == pending_id { + frame.events.push(RouterEvent::Clicked(current, button)); + } + } else { + // Drag-off then release: cancel the click. No + // Released event has a target either, since we + // require a hovered widget for that. + } + } + } + } + + frame.captured_keyboard = self.focused.is_some(); + frame + } + + /// Move focus to `next` (or clear it when `None`), emitting `FocusLost` + /// / `FocusGained` events. Idempotent when `next` matches the current + /// focus. + fn update_focus(&mut self, next: Option, frame: &mut RouterFrame) { + if next == self.focused { + return; + } + if let Some(old) = self.focused.take() { + frame.events.push(RouterEvent::FocusLost(old)); + } + if let Some(new) = next.clone() { + frame.events.push(RouterEvent::FocusGained(new)); + } + self.focused = next; + } +} + +/// Hit-test `point` against the laid-out widgets. Returns the topmost +/// (most-recently-painted) [`LayoutNode`] with a non-empty id whose `rect` +/// contains the point, or `None` if no addressable widget is under the +/// point. +/// +/// Anonymous widgets (empty `id`) are skipped so a decorative container +/// doesn't block hits on the button it contains. Iteration is in reverse +/// node order — children and later siblings (drawn on top) are tested +/// before their parents. +pub fn hit_test(tree: &LayoutTree, point: Vec2) -> Option<&LayoutNode> { + for node in tree.nodes().iter().rev() { + if node.id.is_empty() { + continue; + } + if node.rect.contains_point(point) { + return Some(node); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::super::layout::layout; + use super::super::style::{LayoutStyle, Sizing}; + use super::super::widget::Widget; + use super::*; + use crate::math::Rect; + + fn viewport(w: f32, h: f32) -> Rect { + Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h)) + } + + fn make_tree() -> (Widget, LayoutTree) { + // Root container with two side-by-side leaves: "left" and "right". + let root = Widget::row() + .with_id("root") + .with_style(LayoutStyle { + width: Sizing::Fixed(200.0), + height: Sizing::Fixed(100.0), + ..Default::default() + }) + .with_child( + Widget::leaf(Vec2::new(100.0, 100.0)) + .with_id("left") + .with_style(LayoutStyle { + width: Sizing::Fixed(100.0), + height: Sizing::Fixed(100.0), + ..Default::default() + }), + ) + .with_child( + Widget::leaf(Vec2::new(100.0, 100.0)) + .with_id("right") + .with_style(LayoutStyle { + width: Sizing::Fixed(100.0), + height: Sizing::Fixed(100.0), + ..Default::default() + }), + ); + let tree = layout(&root, viewport(400.0, 200.0), 1.0); + (root, tree) + } + + #[test] + fn hit_test_returns_topmost_widget_with_id() { + let (_root, tree) = make_tree(); + // Cursor over the left child → returns "left", not "root". + let hit = hit_test(&tree, Vec2::new(50.0, 50.0)).unwrap(); + assert_eq!(hit.id.as_str(), "left"); + // Cursor over the right child → "right". + let hit = hit_test(&tree, Vec2::new(150.0, 50.0)).unwrap(); + assert_eq!(hit.id.as_str(), "right"); + } + + #[test] + fn hit_test_falls_back_to_parent_when_children_dont_cover() { + // Root 200×100 with 20-pixel padding, containing one 80×60 button. + // The padding gutter is "root-only" space — clicks there should + // resolve to "root", not the button. + let root = Widget::row() + .with_id("root") + .with_style(LayoutStyle { + width: Sizing::Fixed(200.0), + height: Sizing::Fixed(100.0), + padding: super::super::style::Insets::all(20.0), + ..Default::default() + }) + .with_child( + Widget::leaf(Vec2::new(80.0, 60.0)) + .with_id("button") + .with_style(LayoutStyle { + width: Sizing::Fixed(80.0), + height: Sizing::Fixed(60.0), + ..Default::default() + }), + ); + let tree = layout(&root, viewport(400.0, 200.0), 1.0); + // Inside the button. + let hit = hit_test(&tree, Vec2::new(60.0, 50.0)).unwrap(); + assert_eq!(hit.id.as_str(), "button"); + // Inside root's padding gutter (10, 50) → root, not button. + let hit = hit_test(&tree, Vec2::new(10.0, 50.0)).unwrap(); + assert_eq!(hit.id.as_str(), "root"); + } + + #[test] + fn hit_test_skips_anonymous_widgets() { + // A button buried inside two anonymous containers should still hit. + let root = Widget::row() + .with_style(LayoutStyle { + width: Sizing::Fixed(200.0), + height: Sizing::Fixed(100.0), + ..Default::default() + }) + .with_child( + Widget::row() + .with_style(LayoutStyle { + width: Sizing::Fixed(100.0), + height: Sizing::Fixed(100.0), + ..Default::default() + }) + .with_child( + Widget::leaf(Vec2::new(80.0, 80.0)) + .with_id("button") + .with_style(LayoutStyle { + width: Sizing::Fixed(80.0), + height: Sizing::Fixed(80.0), + ..Default::default() + }), + ), + ); + let tree = layout(&root, viewport(200.0, 100.0), 1.0); + let hit = hit_test(&tree, Vec2::new(20.0, 20.0)).unwrap(); + assert_eq!(hit.id.as_str(), "button"); + } + + #[test] + fn hit_test_returns_none_outside_root() { + let (_root, tree) = make_tree(); + let hit = hit_test(&tree, Vec2::new(500.0, 500.0)); + assert!(hit.is_none()); + } + + #[test] + fn cursor_moving_onto_widget_emits_hovered_event() { + let (_root, tree) = make_tree(); + let mut router = Router::new(); + let mut input = InputState::new(); + + // First frame: cursor outside, no hover. + input.set_cursor(Vec2::new(500.0, 500.0)); + let f = router.process(&tree, &input); + assert!(f.events.is_empty()); + assert!(!f.captured_mouse); + + // Move into the left widget. + input.set_cursor(Vec2::new(50.0, 50.0)); + let f = router.process(&tree, &input); + assert_eq!(f.events, vec![RouterEvent::Hovered("left".into())]); + assert!(f.captured_mouse); + assert_eq!(router.hovered(), Some(&"left".into())); + } + + #[test] + fn cursor_moving_off_emits_unhovered() { + let (_root, tree) = make_tree(); + let mut router = Router::new(); + let mut input = InputState::new(); + input.set_cursor(Vec2::new(50.0, 50.0)); + router.process(&tree, &input); + + // Move off the widget. + input.set_cursor(Vec2::new(500.0, 500.0)); + let f = router.process(&tree, &input); + assert_eq!(f.events, vec![RouterEvent::Unhovered("left".into())]); + assert!(!f.captured_mouse); + } + + #[test] + fn cursor_moving_between_widgets_swaps_hover() { + let (_root, tree) = make_tree(); + let mut router = Router::new(); + let mut input = InputState::new(); + input.set_cursor(Vec2::new(50.0, 50.0)); + router.process(&tree, &input); + + input.set_cursor(Vec2::new(150.0, 50.0)); + let f = router.process(&tree, &input); + // Unhover left, then hover right (both this frame). + assert_eq!( + f.events, + vec![ + RouterEvent::Unhovered("left".into()), + RouterEvent::Hovered("right".into()), + ] + ); + } + + #[test] + fn pressing_over_widget_emits_pressed_and_focuses() { + let (_root, tree) = make_tree(); + let mut router = Router::new(); + let mut input = InputState::new(); + input.set_cursor(Vec2::new(50.0, 50.0)); + // Frame 1: hover only. + router.process(&tree, &input); + // Frame 2: press the left button while hovering. + input.press_mouse(MouseButton::Left); + let f = router.process(&tree, &input); + assert!(f + .events + .contains(&RouterEvent::Pressed("left".into(), MouseButton::Left))); + assert!(f.events.contains(&RouterEvent::FocusGained("left".into()))); + assert_eq!(router.focused(), Some(&"left".into())); + assert!(f.captured_keyboard); + } + + #[test] + fn press_then_release_on_same_widget_emits_click() { + let (_root, tree) = make_tree(); + let mut router = Router::new(); + let mut input = InputState::new(); + input.set_cursor(Vec2::new(50.0, 50.0)); + router.process(&tree, &input); + input.press_mouse(MouseButton::Left); + router.process(&tree, &input); + input.end_frame(); // clear the press edge + input.release_mouse(MouseButton::Left); + let f = router.process(&tree, &input); + // Released and Clicked, both on "left". + assert!(f + .events + .contains(&RouterEvent::Released("left".into(), MouseButton::Left))); + assert!(f + .events + .contains(&RouterEvent::Clicked("left".into(), MouseButton::Left))); + } + + #[test] + fn press_then_drag_off_then_release_does_not_emit_click() { + let (_root, tree) = make_tree(); + let mut router = Router::new(); + let mut input = InputState::new(); + input.set_cursor(Vec2::new(50.0, 50.0)); + router.process(&tree, &input); + input.press_mouse(MouseButton::Left); + router.process(&tree, &input); + input.end_frame(); + + // Drag onto the right widget, then release. + input.set_cursor(Vec2::new(150.0, 50.0)); + input.release_mouse(MouseButton::Left); + let f = router.process(&tree, &input); + let clicked = f + .events + .iter() + .any(|e| matches!(e, RouterEvent::Clicked(_, _))); + assert!(!clicked, "drag-off should cancel the click"); + } + + #[test] + fn pressing_outside_any_widget_clears_focus() { + let (_root, tree) = make_tree(); + let mut router = Router::new(); + router.set_focused(Some("left".into())); + let mut input = InputState::new(); + input.set_cursor(Vec2::new(500.0, 500.0)); + input.press_mouse(MouseButton::Left); + let f = router.process(&tree, &input); + assert!(f.events.contains(&RouterEvent::FocusLost("left".into()))); + assert_eq!(router.focused(), None); + } + + #[test] + fn captured_flags_match_state() { + let (_root, tree) = make_tree(); + let mut router = Router::new(); + let mut input = InputState::new(); + // No cursor, no focus → nothing captured. + let f = router.process(&tree, &input); + assert!(!f.captured_mouse); + assert!(!f.captured_keyboard); + + // Cursor over a widget → captures mouse. + input.set_cursor(Vec2::new(50.0, 50.0)); + let f = router.process(&tree, &input); + assert!(f.captured_mouse); + assert!(!f.captured_keyboard); + + // Press → focuses, captures keyboard too. + input.press_mouse(MouseButton::Left); + let f = router.process(&tree, &input); + assert!(f.captured_keyboard); + } + + #[test] + fn cursor_off_screen_does_not_hover() { + let (_root, tree) = make_tree(); + let mut router = Router::new(); + let input = InputState::new(); // cursor unset + let f = router.process(&tree, &input); + assert!(f.events.is_empty()); + assert!(!f.captured_mouse); + } + + #[test] + fn router_frame_clicked_query_matches_button_and_id() { + let (_root, tree) = make_tree(); + let mut router = Router::new(); + let mut input = InputState::new(); + input.set_cursor(Vec2::new(50.0, 50.0)); + router.process(&tree, &input); + input.press_mouse(MouseButton::Left); + router.process(&tree, &input); + input.end_frame(); + input.release_mouse(MouseButton::Left); + let f = router.process(&tree, &input); + // Immediate-mode query: was "left" clicked with Left? + assert!(f.clicked_left("left")); + assert!(f.clicked("left", MouseButton::Left)); + // Different id or different button → false. + assert!(!f.clicked_left("right")); + assert!(!f.clicked("left", MouseButton::Right)); + } + + #[test] + fn router_frame_query_methods_cover_each_event_kind() { + // Build a frame manually with one of each event variant and + // verify each query method matches exactly one. + let f = RouterFrame { + events: vec![ + RouterEvent::Hovered("a".into()), + RouterEvent::Unhovered("b".into()), + RouterEvent::Pressed("c".into(), MouseButton::Right), + RouterEvent::Released("d".into(), MouseButton::Middle), + RouterEvent::Clicked("e".into(), MouseButton::Left), + RouterEvent::FocusGained("f".into()), + RouterEvent::FocusLost("g".into()), + ], + captured_mouse: true, + captured_keyboard: true, + }; + assert!(f.hovered_in("a")); + assert!(f.hovered_out("b")); + assert!(f.pressed("c", MouseButton::Right)); + assert!(f.released("d", MouseButton::Middle)); + assert!(f.clicked("e", MouseButton::Left)); + assert!(f.focus_gained("f")); + assert!(f.focus_lost("g")); + // Negative checks. + assert!(!f.hovered_in("b")); + assert!(!f.clicked_left("c")); // Pressed, not Clicked + } +} diff --git a/engine/src/ui/style.rs b/engine/src/ui/style.rs new file mode 100644 index 0000000..1df27bf --- /dev/null +++ b/engine/src/ui/style.rs @@ -0,0 +1,354 @@ +//! Layout style primitives — sizing, padding, margin, alignment, and anchors. +//! +//! Every Stage-8 widget carries a [`LayoutStyle`] that tells the layout +//! algorithm how to size and position it inside its parent's content rect. +//! The primitives here are deliberately small and orthogonal so they compose +//! into the three layout modes (stack, grid, anchor) without each mode +//! introducing its own bespoke parameters. +//! +//! All linear measurements (`Sizing::Fixed`, [`Insets`] fields, anchor +//! offsets, stack/grid gaps) are in **logical pixels**. The layout function +//! takes a separate `scale` factor (typically the window's DPI scale) and +//! multiplies these values at resolve time, so one widget tree lays out +//! sensibly on a 1× laptop and a 2× HiDPI monitor without per-widget rewrites. + +use glam::Vec2; +use serde::{Deserialize, Serialize}; + +/// How a widget asks to be sized along one axis. +/// +/// Sizing interacts with the parent's layout mode: +/// +/// - In a stack, the **main axis** sums all `Fixed` and `FitContent` sizes, +/// then divides leftover space among `Grow` siblings by weight. The +/// **cross axis** sizes each child independently (`Grow` fills the parent's +/// cross extent; the other variants behave like the main axis). +/// - In a grid, every child fills its cell, but `Fixed`/`FitContent` cap the +/// child's drawn size and let [`LayoutStyle::align_horizontal`] / +/// [`LayoutStyle::align_vertical`] position the smaller rect inside the +/// cell. +/// - In an anchor parent, child sizing is **ignored** along axes the anchor +/// actually constrains; the anchor + offsets fully determine the child's +/// rect. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +pub enum Sizing { + /// A fixed size in logical pixels. Multiplied by the layout scale factor. + Fixed(f32), + /// Take a share of the parent's leftover space, weighted by `f32`. + /// + /// Two siblings with `Grow(1.0)` split leftover space evenly; `Grow(2.0)` + /// next to `Grow(1.0)` takes 2/3 of it. A non-positive weight contributes + /// nothing and the child collapses to zero on that axis. + Grow(f32), + /// Size to fit the widget's own content — the intrinsic size for leaves, + /// the recursive content extent for containers. + #[default] + FitContent, +} + +/// Per-side spacing in logical pixels — used for both padding (inside) and +/// margin (outside). +/// +/// Padding shrinks a widget's `content_rect` (children draw inside it); margin +/// reserves space *around* the widget so siblings don't touch it. Both are +/// scaled by the layout scale factor at resolve time. +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)] +pub struct Insets { + pub left: f32, + pub right: f32, + pub top: f32, + pub bottom: f32, +} + +impl Insets { + pub const ZERO: Self = Self { + left: 0.0, + right: 0.0, + top: 0.0, + bottom: 0.0, + }; + + /// Same value on every side. + pub const fn all(v: f32) -> Self { + Self { + left: v, + right: v, + top: v, + bottom: v, + } + } + + /// Symmetric: one value for left+right, another for top+bottom. + pub const fn symmetric(horizontal: f32, vertical: f32) -> Self { + Self { + left: horizontal, + right: horizontal, + top: vertical, + bottom: vertical, + } + } + + /// Combined horizontal extent (`left + right`). + #[inline] + pub fn horizontal(&self) -> f32 { + self.left + self.right + } + + /// Combined vertical extent (`top + bottom`). + #[inline] + pub fn vertical(&self) -> f32 { + self.top + self.bottom + } + + /// Component-wise scale (used internally by the layout algorithm to apply + /// the DPI factor; exposed for tests that want to verify the scaling). + #[inline] + pub fn scaled(&self, scale: f32) -> Self { + Self { + left: self.left * scale, + right: self.right * scale, + top: self.top * scale, + bottom: self.bottom * scale, + } + } +} + +/// Alignment along one axis when a widget is smaller than its slot. +/// +/// In a row stack, `align_vertical` decides whether a short child docks to the +/// top, middle, or bottom of the row's content rect. The stack's own +/// [`Stack::main_align`](super::widget::Stack::main_align) does the analogous +/// thing along the **main** axis when all children are sized but don't sum to +/// the full main extent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum Align { + /// Top / left edge. + #[default] + Start, + /// Centered in the available space. + Center, + /// Bottom / right edge. + End, +} + +/// How a child positions itself inside an [`AnchorGroup`](super::widget::AnchorGroup) +/// parent. +/// +/// Anchors are two normalized points in `[0, 1]²` (the **anchor rectangle**) +/// plus per-corner offsets in logical pixels. The child's resulting rect is: +/// +/// ```text +/// rect.min = parent.content.min + parent.content.size * anchor.min + offset_min * scale +/// rect.max = parent.content.min + parent.content.size * anchor.max + offset_max * scale +/// ``` +/// +/// This is the standard Unity / Godot anchor formulation: pick two anchor +/// corners (a single point for "follow that corner", a full rectangle for +/// "dock to this edge / fill"), then nudge with offsets. The default is +/// [`Anchor::FILL`]. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Anchor { + pub min: Vec2, + pub max: Vec2, + pub offset_min: Vec2, + pub offset_max: Vec2, +} + +impl Anchor { + /// Fill the parent's content rect exactly. The default for new widgets. + pub const FILL: Self = Self { + min: Vec2::ZERO, + max: Vec2::ONE, + offset_min: Vec2::ZERO, + offset_max: Vec2::ZERO, + }; + + /// Pin to the top-left corner with `offset_max` controlling the child's + /// size (which is otherwise zero because `min == max`). + pub const TOP_LEFT: Self = Self { + min: Vec2::ZERO, + max: Vec2::ZERO, + offset_min: Vec2::ZERO, + offset_max: Vec2::ZERO, + }; + + /// Pin to the top-right corner. + pub const TOP_RIGHT: Self = Self { + min: Vec2::new(1.0, 0.0), + max: Vec2::new(1.0, 0.0), + offset_min: Vec2::ZERO, + offset_max: Vec2::ZERO, + }; + + /// Pin to the bottom-left corner. + pub const BOTTOM_LEFT: Self = Self { + min: Vec2::new(0.0, 1.0), + max: Vec2::new(0.0, 1.0), + offset_min: Vec2::ZERO, + offset_max: Vec2::ZERO, + }; + + /// Pin to the bottom-right corner. + pub const BOTTOM_RIGHT: Self = Self { + min: Vec2::ONE, + max: Vec2::ONE, + offset_min: Vec2::ZERO, + offset_max: Vec2::ZERO, + }; + + /// Dock to the top edge — full width, child height controlled by + /// `offset_max.y`. + pub const TOP: Self = Self { + min: Vec2::ZERO, + max: Vec2::new(1.0, 0.0), + offset_min: Vec2::ZERO, + offset_max: Vec2::ZERO, + }; + + /// Dock to the bottom edge — full width, child height controlled by + /// `offset_min.y` (negative pushes the top edge upward). + pub const BOTTOM: Self = Self { + min: Vec2::new(0.0, 1.0), + max: Vec2::ONE, + offset_min: Vec2::ZERO, + offset_max: Vec2::ZERO, + }; + + /// Dock to the left edge — full height, child width via `offset_max.x`. + pub const LEFT: Self = Self { + min: Vec2::ZERO, + max: Vec2::new(0.0, 1.0), + offset_min: Vec2::ZERO, + offset_max: Vec2::ZERO, + }; + + /// Dock to the right edge — full height, child width via `offset_min.x` + /// (negative widens the child leftward). + pub const RIGHT: Self = Self { + min: Vec2::new(1.0, 0.0), + max: Vec2::ONE, + offset_min: Vec2::ZERO, + offset_max: Vec2::ZERO, + }; + + /// Construct an anchor with explicit corner pair (offsets zero). + pub const fn between(min: Vec2, max: Vec2) -> Self { + Self { + min, + max, + offset_min: Vec2::ZERO, + offset_max: Vec2::ZERO, + } + } + + /// Add fixed offsets in logical pixels to the resolved corners. + pub const fn with_offsets(mut self, offset_min: Vec2, offset_max: Vec2) -> Self { + self.offset_min = offset_min; + self.offset_max = offset_max; + self + } +} + +impl Default for Anchor { + fn default() -> Self { + Self::FILL + } +} + +/// Combined style controlling how a widget sizes, spaces, and aligns itself +/// inside its parent's slot. +/// +/// `LayoutStyle` is deliberately one flat struct (rather than per-axis or +/// per-mode sub-structs) because every widget needs the same fields and most +/// of them are zero by default. Tests and authors can write +/// `LayoutStyle::default()` and only set the fields they care about. +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)] +pub struct LayoutStyle { + /// Horizontal sizing rule. + pub width: Sizing, + /// Vertical sizing rule. + pub height: Sizing, + /// Space *inside* this widget's rect, before children are arranged. + pub padding: Insets, + /// Space *outside* this widget's rect, reserved in the parent's layout + /// before computing leftover space. + pub margin: Insets, + /// Horizontal alignment when this widget's resolved width is smaller than + /// the slot the parent gave it. + pub align_horizontal: Align, + /// Vertical alignment when this widget's resolved height is smaller than + /// the slot the parent gave it. + pub align_vertical: Align, + /// Anchor — only consulted when this widget's parent is an + /// [`AnchorGroup`](super::widget::AnchorGroup); ignored otherwise. + pub anchor: Anchor, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_layout_style_is_fit_content_fill_anchor() { + let s = LayoutStyle::default(); + assert_eq!(s.width, Sizing::FitContent); + assert_eq!(s.height, Sizing::FitContent); + assert_eq!(s.padding, Insets::ZERO); + assert_eq!(s.margin, Insets::ZERO); + assert_eq!(s.align_horizontal, Align::Start); + assert_eq!(s.align_vertical, Align::Start); + assert_eq!(s.anchor, Anchor::FILL); + } + + #[test] + fn insets_helpers_are_correct() { + let i = Insets::all(4.0); + assert_eq!(i.left, 4.0); + assert_eq!(i.right, 4.0); + assert_eq!(i.top, 4.0); + assert_eq!(i.bottom, 4.0); + assert_eq!(i.horizontal(), 8.0); + assert_eq!(i.vertical(), 8.0); + + let s = Insets::symmetric(2.0, 6.0); + assert_eq!(s.horizontal(), 4.0); + assert_eq!(s.vertical(), 12.0); + + let scaled = i.scaled(2.0); + assert_eq!(scaled, Insets::all(8.0)); + } + + #[test] + fn anchor_constants_match_doc_corners() { + // FILL spans the whole parent. + assert_eq!(Anchor::FILL.min, Vec2::ZERO); + assert_eq!(Anchor::FILL.max, Vec2::ONE); + // Each corner pin collapses to a point. + assert_eq!(Anchor::TOP_LEFT.min, Anchor::TOP_LEFT.max); + assert_eq!(Anchor::TOP_RIGHT.min, Vec2::new(1.0, 0.0)); + assert_eq!(Anchor::BOTTOM_LEFT.max, Vec2::new(0.0, 1.0)); + assert_eq!(Anchor::BOTTOM_RIGHT.min, Vec2::ONE); + // Edge docks span one full axis. + assert_eq!(Anchor::TOP.min, Vec2::ZERO); + assert_eq!(Anchor::TOP.max, Vec2::new(1.0, 0.0)); + assert_eq!(Anchor::BOTTOM.min, Vec2::new(0.0, 1.0)); + assert_eq!(Anchor::LEFT.max, Vec2::new(0.0, 1.0)); + assert_eq!(Anchor::RIGHT.min, Vec2::new(1.0, 0.0)); + } + + #[test] + fn layout_style_round_trips_through_ron() { + let s = LayoutStyle { + width: Sizing::Grow(2.0), + height: Sizing::Fixed(48.0), + padding: Insets::all(8.0), + margin: Insets::symmetric(4.0, 2.0), + align_horizontal: Align::Center, + align_vertical: Align::End, + anchor: Anchor::TOP_RIGHT.with_offsets(Vec2::new(-100.0, 0.0), Vec2::ZERO), + }; + let text = ron::ser::to_string_pretty(&s, ron::ser::PrettyConfig::default()).unwrap(); + let decoded: LayoutStyle = ron::de::from_str(&text).unwrap(); + assert_eq!(s, decoded); + } +} diff --git a/engine/src/ui/text/atlas.rs b/engine/src/ui/text/atlas.rs new file mode 100644 index 0000000..4deb397 --- /dev/null +++ b/engine/src/ui/text/atlas.rs @@ -0,0 +1,426 @@ +//! Glyph atlas — packs rasterized glyphs into one R8 alpha texture, caches +//! them by (font, glyph, size), and exposes UV regions the renderer draws as +//! textured quads. +//! +//! The atlas **is** the cache: every glyph is rasterized exactly once per +//! `(FontId, GlyphId, size_px)` triple and reused for the rest of the +//! process's lifetime. The performance discussion in the Stage-8 design +//! notes assumes this — a HUD that repaints the same characters every frame +//! never re-rasterizes after warm-up. +//! +//! # Packer choice +//! +//! Piece 3 uses a **shelf packer**: glyphs are arranged in horizontal rows +//! ("shelves") whose height is the height of the first glyph that opened the +//! shelf. Subsequent glyphs either fit horizontally on an existing shelf +//! (height ≤ shelf height) or start a new shelf below. This is the standard +//! choice for monotonically-growing glyph atlases — simple, deterministic, +//! near-optimal density for typically-uniform glyph heights, and easy to +//! grow (later: multi-page atlases) when full. +//! +//! Piece 3 does **not** evict. With a 1024×1024 R8 atlas the typical Western +//! UI uses a single-digit-percent fraction; CJK or many-size scenarios that +//! actually run out are handled by piece-4 follow-ups (multi-page atlases +//! or LRU per page). + +use std::collections::HashMap; + +use glam::Vec2; +use serde::{Deserialize, Serialize}; + +use super::font::{FontId, FontStore, GlyphId}; + +/// Cache key for one rasterized glyph. +/// +/// `size_px` is rounded to the nearest pixel before being used as the key — +/// distinct 23.4-pixel and 23.6-pixel renderings would otherwise produce +/// different atlas entries despite being visually indistinguishable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct GlyphKey { + pub font: FontId, + pub glyph: GlyphId, + pub size_px: u16, +} + +impl GlyphKey { + /// Build a key, rounding `size_px` to the nearest pixel. + pub fn new(font: FontId, glyph: GlyphId, size_px: f32) -> Self { + Self { + font, + glyph, + size_px: size_px.round().max(1.0) as u16, + } + } +} + +/// One glyph's packed location inside the atlas plus the metrics the +/// renderer needs to position its quad on a baseline. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AtlasEntry { + /// Top-left UV (normalized to `[0, 1]`). + pub uv_min: Vec2, + /// Bottom-right UV. + pub uv_max: Vec2, + /// Width / height of the packed region in **pixels**, so the renderer + /// can size the quad without re-querying the atlas dimensions. + pub size_px: Vec2, + /// Offset from the glyph's pen position to the top-left of the quad, + /// in pixels (`bearing.x` left/right, `bearing.y` from the **baseline**; + /// negative `y` means the glyph extends above the baseline). + pub bearing: Vec2, + /// Horizontal advance for the next glyph at this size. + pub advance_px: f32, +} + +/// CPU-side glyph atlas — owns the alpha buffer, the packer state, and the +/// `(GlyphKey -> AtlasEntry)` cache. +/// +/// A piece-4 GPU follow-up will upload [`pixels`](Self::pixels) into a +/// single R8 texture and re-upload only the dirty region when new glyphs are +/// packed. Piece 3 stays pixel-buffer-only so every test runs headlessly. +#[derive(Debug)] +pub struct GlyphAtlas { + width: u32, + height: u32, + pixels: Vec, + cache: HashMap, + packer: ShelfPacker, + dirty: bool, +} + +impl GlyphAtlas { + /// Allocate a fresh `width × height` R8 atlas (one byte per pixel, + /// initially zero). + pub fn new(width: u32, height: u32) -> Self { + Self { + width, + height, + pixels: vec![0u8; (width as usize) * (height as usize)], + cache: HashMap::new(), + packer: ShelfPacker::new(width, height), + dirty: false, + } + } + + /// `(width, height)` in pixels. + pub fn size(&self) -> (u32, u32) { + (self.width, self.height) + } + + /// Raw alpha buffer (`width * height` bytes, row-major). The piece-4 + /// render pass will upload this into an R8 texture; tests assert on it + /// directly. + pub fn pixels(&self) -> &[u8] { + &self.pixels + } + + /// Look up an entry, rasterizing and packing if not yet present. + /// + /// Returns `None` if the glyph has no outline (e.g., a space — the + /// shaper still positions it via the font's advance) **or** the atlas + /// has no room for the rasterized bitmap. A space-glyph miss is + /// indistinguishable from a packing failure by signature; in practice + /// the shaper handles both the same way (skip the quad, keep the + /// advance). + pub fn get_or_rasterize(&mut self, key: GlyphKey, fonts: &FontStore) -> Option { + if let Some(entry) = self.cache.get(&key) { + return Some(*entry); + } + let font = fonts.get(key.font)?; + let raster = font.rasterize(key.glyph, key.size_px as f32)?; + let (x, y) = self.packer.pack(raster.width, raster.height)?; + + // Blit the alpha mask into the atlas at (x, y). + let aw = self.width as usize; + for row in 0..raster.height as usize { + let src_start = row * raster.width as usize; + let dst_start = (y as usize + row) * aw + x as usize; + self.pixels[dst_start..dst_start + raster.width as usize] + .copy_from_slice(&raster.bitmap[src_start..src_start + raster.width as usize]); + } + self.dirty = true; + + let w = self.width as f32; + let h = self.height as f32; + let entry = AtlasEntry { + uv_min: Vec2::new(x as f32 / w, y as f32 / h), + uv_max: Vec2::new( + (x + raster.width) as f32 / w, + (y + raster.height) as f32 / h, + ), + size_px: Vec2::new(raster.width as f32, raster.height as f32), + bearing: Vec2::new(raster.bearing_x, raster.bearing_y), + advance_px: raster.advance_x, + }; + self.cache.insert(key, entry); + Some(entry) + } + + /// Borrow an entry that's already cached, without triggering + /// rasterization. Useful when the renderer wants to draw only glyphs the + /// atlas already knows. + pub fn get(&self, key: &GlyphKey) -> Option<&AtlasEntry> { + self.cache.get(key) + } + + /// Number of cached glyphs. + pub fn len(&self) -> usize { + self.cache.len() + } + + /// `true` if no glyphs are cached. + pub fn is_empty(&self) -> bool { + self.cache.is_empty() + } + + /// `true` if [`get_or_rasterize`](Self::get_or_rasterize) added at least + /// one glyph since the last [`clear_dirty`](Self::clear_dirty). The + /// piece-4 render pass checks this before re-uploading the texture. + pub fn dirty(&self) -> bool { + self.dirty + } + + /// Clear the dirty flag. Call after uploading the texture. + pub fn clear_dirty(&mut self) { + self.dirty = false; + } +} + +// ---------- shelf packer ---------- + +#[derive(Debug)] +struct ShelfPacker { + width: u32, + height: u32, + shelves: Vec, + next_y: u32, +} + +#[derive(Debug)] +struct Shelf { + y: u32, + height: u32, + cursor_x: u32, +} + +impl ShelfPacker { + fn new(width: u32, height: u32) -> Self { + Self { + width, + height, + shelves: Vec::new(), + next_y: 0, + } + } + + fn pack(&mut self, w: u32, h: u32) -> Option<(u32, u32)> { + if w > self.width || h > self.height { + return None; + } + // Prefer the tightest-fitting existing shelf that still has + // horizontal room — keeps shelf heights stable and packs short + // glyphs against short glyphs. + let mut best: Option = None; + let mut best_waste = u32::MAX; + for (i, shelf) in self.shelves.iter().enumerate() { + if shelf.cursor_x + w <= self.width && h <= shelf.height { + let waste = shelf.height - h; + if waste < best_waste { + best = Some(i); + best_waste = waste; + } + } + } + if let Some(i) = best { + let shelf = &mut self.shelves[i]; + let x = shelf.cursor_x; + let y = shelf.y; + shelf.cursor_x += w; + return Some((x, y)); + } + // No existing shelf fits — open a new one at `next_y` if there's + // vertical room. + if self.next_y + h > self.height { + return None; + } + let y = self.next_y; + self.next_y += h; + self.shelves.push(Shelf { + y, + height: h, + cursor_x: w, + }); + Some((0, y)) + } +} + +#[cfg(test)] +mod tests { + use super::super::font::try_load_system_font; + use super::*; + use crate::ui::visual::FontRef; + + #[test] + fn key_rounds_size_to_nearest_pixel() { + let k1 = GlyphKey::new(FontId(0), GlyphId(1), 23.4); + let k2 = GlyphKey::new(FontId(0), GlyphId(1), 23.6); + assert_eq!(k1.size_px, 23); + assert_eq!(k2.size_px, 24); + assert_ne!(k1, k2); + } + + #[test] + fn key_clamps_sub_pixel_size_to_one() { + // A 0.4-pixel font would otherwise round to zero, producing a useless + // key. The packer requires width ≥ 1. + let k = GlyphKey::new(FontId(0), GlyphId(1), 0.4); + assert_eq!(k.size_px, 1); + } + + #[test] + fn shelf_packer_fits_glyphs_in_order() { + let mut p = ShelfPacker::new(64, 64); + // First glyph opens a shelf at y=0 with height 10. + assert_eq!(p.pack(20, 10), Some((0, 0))); + // Second glyph fits on the same shelf — same y, advanced cursor. + assert_eq!(p.pack(20, 10), Some((20, 0))); + // Third glyph: doesn't fit horizontally on shelf 0; opens shelf 1 + // at y=10. + assert_eq!(p.pack(40, 8), Some((0, 10))); + // Tall glyph that fits horizontally on neither existing shelf opens + // shelf 2 at y=18. + assert_eq!(p.pack(64, 20), Some((0, 18))); + } + + #[test] + fn shelf_packer_prefers_tight_fit_among_existing_shelves() { + let mut p = ShelfPacker::new(64, 64); + // Open shelf 0 at y=0 with height 20, occupying width 50. + assert_eq!(p.pack(50, 20), Some((0, 0))); + // A 50-wide 8-tall glyph won't fit horizontally on shelf 0 + // (50 + 50 = 100 > 64) — that forces shelf 1 open at y=20 with + // height 8. + assert_eq!(p.pack(50, 8), Some((0, 20))); + // Now pack a 10×8 glyph: shelf 0 (waste 12) and shelf 1 (waste 0) + // both fit horizontally, so the tight-fit shelf 1 wins. + assert_eq!(p.pack(10, 8), Some((50, 20))); + } + + #[test] + fn shelf_packer_rejects_overflow() { + let mut p = ShelfPacker::new(32, 32); + // First fills almost all the vertical room. + assert_eq!(p.pack(32, 30), Some((0, 0))); + // 4-tall glyph won't fit vertically. + assert_eq!(p.pack(8, 4), None); + // Anything wider than the atlas is also rejected. + let mut p2 = ShelfPacker::new(32, 32); + assert_eq!(p2.pack(40, 4), None); + } + + #[test] + fn empty_atlas_has_no_dirty_no_entries() { + let atlas = GlyphAtlas::new(64, 64); + assert_eq!(atlas.size(), (64, 64)); + assert!(!atlas.dirty()); + assert!(atlas.is_empty()); + assert!(atlas.pixels().iter().all(|&p| p == 0)); + } + + #[test] + fn dirty_flag_lifecycle() { + let Some(font) = try_load_system_font() else { + return; + }; + let mut store = FontStore::new(); + let id = store.insert(font); + let mut atlas = GlyphAtlas::new(256, 256); + assert!(!atlas.dirty()); + + let glyph = store.get(id).unwrap().glyph_id('A'); + atlas + .get_or_rasterize(GlyphKey::new(id, glyph, 24.0), &store) + .unwrap(); + assert!(atlas.dirty()); + atlas.clear_dirty(); + assert!(!atlas.dirty()); + + // Second lookup of the same key is a cache hit — no rasterization, + // no new dirty. + atlas + .get_or_rasterize(GlyphKey::new(id, glyph, 24.0), &store) + .unwrap(); + assert!(!atlas.dirty()); + } + + #[test] + fn distinct_glyphs_get_distinct_regions() { + let Some(font) = try_load_system_font() else { + return; + }; + let mut store = FontStore::new(); + let id = store.insert_with_descriptor(FontRef::regular("System"), font); + let mut atlas = GlyphAtlas::new(512, 512); + + let a = store.get(id).unwrap().glyph_id('A'); + let b = store.get(id).unwrap().glyph_id('B'); + let e_a = atlas + .get_or_rasterize(GlyphKey::new(id, a, 24.0), &store) + .unwrap(); + let e_b = atlas + .get_or_rasterize(GlyphKey::new(id, b, 24.0), &store) + .unwrap(); + // Different glyphs → different UV rects. + assert_ne!(e_a.uv_min, e_b.uv_min); + // UV rects stay inside `[0, 1]`. + assert!(e_a.uv_min.x >= 0.0 && e_a.uv_max.x <= 1.0); + assert!(e_a.uv_min.y >= 0.0 && e_a.uv_max.y <= 1.0); + + assert_eq!(atlas.len(), 2); + } + + #[test] + fn space_glyph_returns_none_but_does_not_corrupt_atlas() { + let Some(font) = try_load_system_font() else { + return; + }; + let mut store = FontStore::new(); + let id = store.insert(font); + let space = store.get(id).unwrap().glyph_id(' '); + let mut atlas = GlyphAtlas::new(128, 128); + assert!(atlas + .get_or_rasterize(GlyphKey::new(id, space, 24.0), &store) + .is_none()); + assert!(atlas.is_empty()); + assert!(!atlas.dirty()); + } + + #[test] + fn atlas_pixels_match_rasterized_bitmap_at_packed_region() { + let Some(font) = try_load_system_font() else { + return; + }; + let mut store = FontStore::new(); + let id = store.insert(font); + let mut atlas = GlyphAtlas::new(128, 128); + let glyph = store.get(id).unwrap().glyph_id('A'); + let entry = atlas + .get_or_rasterize(GlyphKey::new(id, glyph, 24.0), &store) + .unwrap(); + // Convert the entry's UV back to a pixel rect and verify *some* + // pixel inside it is opaque (i.e., the blit actually happened). + let x = (entry.uv_min.x * 128.0).round() as usize; + let y = (entry.uv_min.y * 128.0).round() as usize; + let w = entry.size_px.x as usize; + let h = entry.size_px.y as usize; + let mut had_opaque = false; + for row in 0..h { + for col in 0..w { + if atlas.pixels()[(y + row) * 128 + (x + col)] > 200 { + had_opaque = true; + } + } + } + assert!(had_opaque, "blitted region should contain opaque pixels"); + } +} diff --git a/engine/src/ui/text/font.rs b/engine/src/ui/text/font.rs new file mode 100644 index 0000000..564c48e --- /dev/null +++ b/engine/src/ui/text/font.rs @@ -0,0 +1,429 @@ +//! Font loading and per-glyph metrics — thin wrapper over [`ab_glyph::FontVec`]. +//! +//! The text system stays a layer above the font crate so it can swap +//! rasterizers later (an SDF generator, a different parser) without churning +//! the public Stage-8 API. Every text query a [`super::shape::shape`] or +//! [`super::atlas::GlyphAtlas`] call needs goes through [`Font`]'s methods — +//! `ab_glyph` is never visible to consumers of the engine. + +use std::collections::HashMap; +use std::path::Path; + +use ab_glyph::{Font as AbFont, FontVec, PxScale, ScaleFont}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::super::visual::FontRef; + +/// Errors returned from font loading. +#[derive(Debug, Error)] +pub enum FontError { + /// Reading the font file from disk failed. + #[error("font file read failed: {0}")] + Io(#[from] std::io::Error), + /// The bytes were not a valid TTF / OTF font. + #[error("not a valid TTF/OTF font")] + InvalidFont, +} + +/// Stable, opaque identifier for a font registered in a [`FontStore`]. +/// +/// Held in [`GlyphKey`](super::atlas::GlyphKey)s in the atlas and in +/// [`TextStyle`](super::shape::TextStyle)s passed to the shaper, so a font's +/// id never changes once registered. `Copy` + `Hash` so it indexes hash maps +/// cheaply. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct FontId(pub u32); + +/// One loaded font — a parsed TTF/OTF that can report metrics and rasterize +/// individual glyphs. +pub struct Font { + inner: FontVec, +} + +impl std::fmt::Debug for Font { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Font").finish_non_exhaustive() + } +} + +/// Result of rasterizing one glyph at a specific pixel size — the alpha mask +/// plus enough metrics to position it on a baseline. +#[derive(Debug, Clone, PartialEq)] +pub struct RasterizedGlyph { + /// Width of the alpha mask in pixels. + pub width: u32, + /// Height of the alpha mask in pixels. + pub height: u32, + /// X offset from the glyph's pen position to the mask's left edge. + pub bearing_x: f32, + /// Y offset from the glyph's baseline to the mask's top edge (negative + /// for glyphs that extend above the baseline, which is most of them). + pub bearing_y: f32, + /// How far to advance the pen along the baseline before the next glyph. + pub advance_x: f32, + /// Row-major alpha bytes (`width * height` bytes, `0 = transparent`, + /// `255 = opaque`). + pub bitmap: Vec, +} + +impl Font { + /// Parse a TTF/OTF font from raw bytes. Bytes are owned by the [`Font`]. + pub fn from_bytes(bytes: Vec) -> Result { + FontVec::try_from_vec(bytes) + .map(|inner| Self { inner }) + .map_err(|_| FontError::InvalidFont) + } + + /// Load and parse a TTF/OTF file from disk. + pub fn from_path(path: impl AsRef) -> Result { + let bytes = std::fs::read(path.as_ref())?; + Self::from_bytes(bytes) + } + + /// The glyph id for a `char`. Returns the font's `notdef` glyph (id `0`) + /// for characters the font does not contain — same behavior as + /// `ab_glyph`. + pub fn glyph_id(&self, ch: char) -> GlyphId { + GlyphId(self.inner.glyph_id(ch).0) + } + + /// Horizontal advance for the next glyph at `size_px` logical pixels. + pub fn h_advance_px(&self, glyph: GlyphId, size_px: f32) -> f32 { + self.inner + .as_scaled(PxScale::from(size_px)) + .h_advance(ab_glyph::GlyphId(glyph.0)) + } + + /// Ascender height in pixels at the given size. + pub fn ascent_px(&self, size_px: f32) -> f32 { + self.inner.as_scaled(PxScale::from(size_px)).ascent() + } + + /// Descender depth in pixels at the given size. Negative for fonts where + /// the descender sits below the baseline (the common case). + pub fn descent_px(&self, size_px: f32) -> f32 { + self.inner.as_scaled(PxScale::from(size_px)).descent() + } + + /// Line gap in pixels — extra leading the font recommends between lines. + pub fn line_gap_px(&self, size_px: f32) -> f32 { + self.inner.as_scaled(PxScale::from(size_px)).line_gap() + } + + /// Total recommended line height at `size_px` (ascent − descent + + /// line_gap). Multiplied by `TextStyle`'s line-height factor by the + /// shaper. + pub fn line_height_px(&self, size_px: f32) -> f32 { + let scaled = self.inner.as_scaled(PxScale::from(size_px)); + scaled.ascent() - scaled.descent() + scaled.line_gap() + } + + /// Rasterize a single glyph to an alpha bitmap. Returns `None` for + /// glyphs with no outline (e.g., the space character) — the caller still + /// gets the advance via [`Font::h_advance_px`] and should treat the + /// glyph as zero-area. + pub fn rasterize(&self, glyph: GlyphId, size_px: f32) -> Option { + let scale = PxScale::from(size_px); + let scaled = self.inner.as_scaled(scale); + let advance_x = scaled.h_advance(ab_glyph::GlyphId(glyph.0)); + let mut positioned = ab_glyph::GlyphId(glyph.0).with_scale(scale); + positioned.position = ab_glyph::point(0.0, 0.0); + let outlined = self.inner.outline_glyph(positioned)?; + let bounds = outlined.px_bounds(); + let width = bounds.width().ceil().max(1.0) as u32; + let height = bounds.height().ceil().max(1.0) as u32; + let mut bitmap = vec![0u8; (width as usize) * (height as usize)]; + outlined.draw(|x, y, coverage| { + if x < width && y < height { + let idx = (y as usize) * (width as usize) + (x as usize); + bitmap[idx] = (coverage * 255.0).round().clamp(0.0, 255.0) as u8; + } + }); + Some(RasterizedGlyph { + width, + height, + bearing_x: bounds.min.x, + bearing_y: bounds.min.y, + advance_x, + bitmap, + }) + } +} + +/// [`AssetLoader`](crate::asset::AssetLoader) for TTF/OTF fonts. +/// +/// Registered by default on every [`AssetServer`](crate::asset::AssetServer), so +/// a font file under a project's `assets/fonts/` can be loaded by path and an +/// [`AssetRef`](crate::asset::AssetRef) resolved to a [`Handle`](crate::asset::Handle) +/// — the link that lets the UI canvas pick a font asset and the runtime draw with it. +pub struct FontLoader; + +impl crate::asset::AssetLoader for FontLoader { + type Asset = Font; + + fn extensions(&self) -> &'static [&'static str] { + &["ttf", "otf"] + } + + fn load(&self, path: &Path) -> Result { + Font::from_path(path).map_err(|err| crate::asset::AssetError::Load { + path: path.to_path_buf(), + message: err.to_string(), + }) + } +} + +/// Opaque per-font glyph index. Mirrors `ab_glyph::GlyphId` but is the only +/// glyph type exposed by the engine, so consumers do not need an `ab_glyph` +/// dependency. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct GlyphId(pub u16); + +/// Registry of loaded fonts, indexed by [`FontId`] and (optionally) by +/// [`FontRef`] descriptor. +/// +/// Why a descriptor index: piece-2 [`Theme`](super::super::theme::Theme)s +/// store fonts by family + weight + italic (`FontRef`), not by raw bytes. +/// `FontStore::resolve(&font_ref)` turns the descriptor into a [`FontId`] the +/// shaper can use, so a theme like `{ font: Some(FontRef::bold("Inter")) }` +/// works end-to-end as soon as the matching face has been registered. +#[derive(Default)] +pub struct FontStore { + fonts: Vec, + by_descriptor: HashMap, +} + +impl std::fmt::Debug for FontStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FontStore") + .field("len", &self.fonts.len()) + .field("descriptors", &self.by_descriptor.len()) + .finish() + } +} + +impl FontStore { + /// Create an empty store. + pub fn new() -> Self { + Self::default() + } + + /// Register a font with no descriptor — accessible only by its returned + /// [`FontId`]. Useful for one-off uses where the font isn't part of a + /// theme cascade. + pub fn insert(&mut self, font: Font) -> FontId { + let id = FontId(self.fonts.len() as u32); + self.fonts.push(font); + id + } + + /// Register a font and associate it with a descriptor. + /// + /// Re-registering the same descriptor replaces the previous association + /// but does not free the previous [`FontId`] — both ids continue to + /// reference the now-distinct font. This matches Stage-7 `ActionMap` + /// re-registration semantics: ids are stable, names can be remapped. + pub fn insert_with_descriptor(&mut self, descriptor: FontRef, font: Font) -> FontId { + let id = self.insert(font); + self.by_descriptor.insert(descriptor, id); + id + } + + /// Look up a font by `FontId`. + pub fn get(&self, id: FontId) -> Option<&Font> { + self.fonts.get(id.0 as usize) + } + + /// Resolve a [`FontRef`] descriptor (piece-2 theme value) to a + /// [`FontId`], if the matching face has been registered. + pub fn resolve(&self, descriptor: &FontRef) -> Option { + self.by_descriptor.get(descriptor).copied() + } + + /// Number of registered fonts. + pub fn len(&self) -> usize { + self.fonts.len() + } + + /// `true` if no fonts are registered. + pub fn is_empty(&self) -> bool { + self.fonts.is_empty() + } +} + +/// Common system paths a Linux-style host is likely to have a sans-serif +/// TTF at. Used by tests (and the eventual editor "no theme font set" path) +/// to find *some* font without bundling one. +/// +/// Returned in priority order; the first existing path is the one to try. +/// Empty on hosts the search doesn't know about — the caller must handle +/// "no candidate found" gracefully. +pub fn common_system_font_paths() -> &'static [&'static str] { + &[ + // Linux distributions: + "/usr/share/fonts/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf", + // macOS: + "/Library/Fonts/Arial.ttf", + "/System/Library/Fonts/Helvetica.ttc", + ] +} + +/// Try to load a sans-serif font from a well-known system path. Returns +/// `None` (and prints `SKIP:`) if no candidate exists — the same pattern +/// the Stage-4 GPU tests use for "no adapter". +/// +/// Test-only helper shared between the `font`, `atlas`, and `shape` modules +/// so the same "skip when no system font" branch isn't duplicated. +#[cfg(test)] +pub(crate) fn try_load_system_font() -> Option { + for path in common_system_font_paths() { + if Path::new(path).exists() { + match Font::from_path(path) { + Ok(font) => return Some(font), + Err(err) => { + eprintln!("SKIP-candidate: {path} present but failed to load: {err}"); + } + } + } + } + eprintln!("SKIP: no system font available at any common Linux/macOS path"); + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_garbage_bytes() { + let err = Font::from_bytes(vec![0u8; 32]).unwrap_err(); + assert!(matches!(err, FontError::InvalidFont)); + } + + #[test] + fn missing_file_returns_io_error() { + let err = Font::from_path("/nonexistent/font.ttf").unwrap_err(); + assert!(matches!(err, FontError::Io(_))); + } + + #[test] + fn font_loader_loads_through_the_asset_server() { + use crate::asset::{AssetRef, AssetServer, AssetUid}; + + // Find a real font file on disk; skip cleanly if the host has none. + let Some(path) = common_system_font_paths() + .iter() + .map(std::path::Path::new) + .find(|p| p.exists()) + else { + eprintln!("SKIP: no system font path available"); + return; + }; + + // The default-registered FontLoader makes `.ttf`/`.otf` loadable. + let server = AssetServer::new(); + let handle = server.load::(path); + assert!(handle.is_loaded(), "font should load: {:?}", handle.error()); + // An asset reference to a hypothetical uid resolves to a handle when the + // database hands back this path (proven in asset::database tests); here + // we just confirm the loaded Font is usable. + assert!(handle.get().unwrap().h_advance_px(GlyphId(0), 16.0) >= 0.0); + // AssetRef is constructible (the field type the UI canvas uses). + let _ = AssetRef::::new(AssetUid(1)); + } + + #[test] + fn store_assigns_distinct_ids() { + let Some(a) = try_load_system_font() else { + return; + }; + let Some(b) = try_load_system_font() else { + return; + }; + let mut store = FontStore::new(); + let id_a = store.insert(a); + let id_b = store.insert(b); + assert_ne!(id_a, id_b); + assert_eq!(store.len(), 2); + assert!(store.get(id_a).is_some()); + assert!(store.get(id_b).is_some()); + assert!(store.get(FontId(99)).is_none()); + } + + #[test] + fn descriptor_resolves_to_registered_font() { + let Some(font) = try_load_system_font() else { + return; + }; + let descriptor = FontRef::regular("System"); + let mut store = FontStore::new(); + let id = store.insert_with_descriptor(descriptor.clone(), font); + assert_eq!(store.resolve(&descriptor), Some(id)); + // A different descriptor with no associated font is None. + assert_eq!(store.resolve(&FontRef::bold("System")), None); + } + + #[test] + fn metrics_are_finite_and_non_zero() { + let Some(font) = try_load_system_font() else { + return; + }; + let advance = font.h_advance_px(font.glyph_id('A'), 24.0); + assert!(advance.is_finite()); + assert!(advance > 0.0); + let ascent = font.ascent_px(24.0); + let descent = font.descent_px(24.0); + assert!(ascent > 0.0); + // ab_glyph's `descent` is negative for descenders below the baseline. + assert!(descent <= 0.0); + assert!(font.line_height_px(24.0) > 0.0); + } + + #[test] + fn rasterize_produces_bitmap_for_solid_glyph() { + let Some(font) = try_load_system_font() else { + return; + }; + let raster = font + .rasterize(font.glyph_id('A'), 24.0) + .expect("'A' outlines"); + assert!(raster.width > 0 && raster.height > 0); + assert_eq!( + raster.bitmap.len(), + (raster.width as usize) * (raster.height as usize) + ); + // A capital A at 24px should have at least one fully-opaque pixel + // near its central stroke. + assert!(raster.bitmap.iter().any(|&p| p > 200)); + // And some transparent pixels (it's not a solid square). + assert!(raster.bitmap.iter().any(|&p| p < 10)); + } + + #[test] + fn rasterize_space_returns_none_but_advance_works() { + let Some(font) = try_load_system_font() else { + return; + }; + let space = font.glyph_id(' '); + // Space has no outline — rasterize returns None. + assert!(font.rasterize(space, 24.0).is_none()); + // But the advance is still positive so the shaper can lay it out. + assert!(font.h_advance_px(space, 24.0) > 0.0); + } + + #[test] + fn common_system_font_paths_returns_some_candidates() { + let paths = common_system_font_paths(); + assert!(!paths.is_empty()); + // Every entry should be an absolute path so the existence check is + // unambiguous on the host. + for p in paths { + assert!(p.starts_with('/'), "{p:?} should be an absolute path"); + } + } +} diff --git a/engine/src/ui/text/mod.rs b/engine/src/ui/text/mod.rs new file mode 100644 index 0000000..b7c96fc --- /dev/null +++ b/engine/src/ui/text/mod.rs @@ -0,0 +1,60 @@ +//! Text shaping + glyph atlas — piece 3 of the Stage-8 in-game UI system. +//! +//! Three sub-modules cooperate: +//! +//! - [`font`] wraps `ab_glyph::FontVec` behind an engine-owned [`Font`] / +//! [`FontStore`] surface so consumers never see the font crate directly. +//! Adds descriptor-based lookup keyed by the piece-2 +//! [`FontRef`](super::visual::FontRef), so a theme's `font: Some(...)` +//! resolves to a [`FontId`] the shaper can use. +//! - [`atlas`] packs rasterized glyphs into one R8 alpha texture via a +//! shelf packer and caches them by [`GlyphKey`]. The atlas **is** the +//! cache — the chosen library never re-rasterizes a glyph that's already +//! been packed, which is why this stage's choice between ab_glyph and +//! fontdue is a one-time-startup decision, not a per-frame one. +//! - [`shape`] turns a sequence of [`TextRun`]s into positioned +//! [`ShapedGlyph`]s with line wrapping, alignment, multi-font runs, and +//! DPI scaling. Pure CPU; never touches the atlas. The renderer +//! (piece 4) walks the [`ShapedText`] output and queries the atlas per +//! glyph to emit textured quads. +//! +//! # End-to-end shape → atlas +//! +//! ```no_run +//! use oxide_engine::ui::text::{ +//! shape, FontStore, GlyphAtlas, ShapeParams, ShapedText, TextStyle, +//! }; +//! # use oxide_engine::ui::text::Font; +//! # fn load_font() -> Font { todo!() } +//! +//! let mut fonts = FontStore::new(); +//! let id = fonts.insert(load_font()); +//! let style = TextStyle { font: id, size_px: 16.0 }; +//! let shaped: ShapedText = shape("Hello world", style, &ShapeParams::default(), &fonts); +//! +//! let mut atlas = GlyphAtlas::new(1024, 1024); +//! for line in &shaped.lines { +//! for glyph in &line.glyphs { +//! // get_or_rasterize returns None for glyphs with no outline (e.g. +//! // the space character). Real renderers skip emitting a quad. +//! if let Some(entry) = atlas.get_or_rasterize(glyph.key, &fonts) { +//! let _quad_top_left = glyph.position + entry.bearing; +//! let _quad_size = entry.size_px; +//! } +//! } +//! } +//! ``` + +pub mod atlas; +pub mod font; +pub mod shape; + +pub use atlas::{AtlasEntry, GlyphAtlas, GlyphKey}; +pub use font::{ + common_system_font_paths, Font, FontError, FontId, FontLoader, FontStore, GlyphId, + RasterizedGlyph, +}; +pub use shape::{ + shape, shape_runs, ShapeParams, ShapedGlyph, ShapedLine, ShapedText, TextAlign, TextRun, + TextStyle, +}; diff --git a/engine/src/ui/text/shape.rs b/engine/src/ui/text/shape.rs new file mode 100644 index 0000000..387bdbb --- /dev/null +++ b/engine/src/ui/text/shape.rs @@ -0,0 +1,726 @@ +//! Text shaping — turns a sequence of [`TextRun`]s into positioned glyphs, +//! laid out on baselines, wrapped to a width, and aligned. +//! +//! The shaper does **not** rasterize: it only consults [`Font`](super::font::Font) +//! metrics (ascender, descender, advance width). Each output [`ShapedGlyph`] +//! carries a [`GlyphKey`] the renderer (piece 4) feeds into the atlas to +//! resolve to a textured quad. This split keeps the shaper purely +//! deterministic and CPU-cheap — every test in this module runs without a +//! GPU and most without a font. +//! +//! # Algorithm +//! +//! 1. **Tokenize** each run into items: a `Word` (maximal run of non- +//! whitespace), a `Whitespace` stretch, or a `Break` (`\n`). Each +//! word/whitespace item caches its own width, computed once from the +//! font's per-glyph advance. +//! 2. **Greedy line break**: keep adding items to the current line; on a +//! word that would overflow `max_width`, flush the line and start a new +//! one. Pending inter-word whitespace at the wrap point is **discarded** +//! (it was the gap between lines, not part of either line); leading +//! whitespace on a wrapped line is dropped for the same reason. `\n` +//! forces a flush regardless of width. +//! 3. **Position**: for each line, find the line's `max_ascent` (across the +//! fonts used on it) — that's the baseline offset from the line's top +//! edge — then walk items left-to-right, emitting `ShapedGlyph`s at +//! `(pen_x, baseline_y)` and advancing `pen_x` by each glyph's advance. +//! 4. **Align**: per line, shift glyphs by `align_offset(max_width − +//! line_width)` — Left/Center/Right. Without a `max_width`, alignment +//! is degenerate (everything is left-aligned). +//! +//! # Multi-font runs +//! +//! Lines may mix items from different runs (and therefore different fonts). +//! Line metrics (ascent, descent, line height) are taken from the *largest* +//! contribution among the line's items. This is the CSS behavior: a small +//! superscript run on the same line as body text doesn't collapse the +//! baseline. +//! +//! # Limitations (deliberate, scoped to piece 3) +//! +//! - One glyph per `char` (no ligatures, no combining marks, no shaping). +//! ab_glyph does not shape; full Unicode shaping is a `rustybuzz` / +//! `harfbuzz` follow-up. +//! - No BiDi or RTL — text flows left-to-right. +//! - No hyphenation or character-level fallback inside an overflowing word. +//! - Whitespace is ASCII (` `, `\t`, `\r`). `\t` and `\r` are treated as +//! regular spaces. + +use glam::Vec2; +use serde::{Deserialize, Serialize}; + +use super::atlas::GlyphKey; +use super::font::{FontId, FontStore}; + +/// Per-run style — which font and what point size. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct TextStyle { + pub font: FontId, + /// Logical font size in pixels. Multiplied by [`ShapeParams::scale`] at + /// shape time, so the same `TextStyle` produces correctly-sized output + /// at 1×, 2×, or any other DPI factor. + pub size_px: f32, +} + +/// One run of text with a single [`TextStyle`]. +/// +/// `shape` takes a single run; `shape_runs` takes many for mixed styles +/// (different fonts/sizes/etc. on the same line). +#[derive(Debug, Clone, Copy)] +pub struct TextRun<'a> { + pub text: &'a str, + pub style: TextStyle, +} + +/// Horizontal alignment of each line within `max_width`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum TextAlign { + #[default] + Left, + Center, + Right, +} + +/// Parameters that apply to the whole shape call: wrapping width, alignment, +/// line-height factor, and the DPI scale factor. +#[derive(Debug, Clone, Copy)] +pub struct ShapeParams { + /// Maximum line width in **post-scale** pixels. `None` disables + /// wrapping (and makes alignment a no-op). + pub max_width: Option, + /// Horizontal alignment within `max_width`. + pub align: TextAlign, + /// Multiplier applied to each line's natural line height. `1.0` is the + /// font's own recommendation; `1.4` is a comfortable reading default. + pub line_height: f32, + /// DPI scale factor — multiplies every logical `size_px` from the + /// runs. Same role as [`super::super::layout::layout`]'s `scale`. + pub scale: f32, +} + +impl Default for ShapeParams { + fn default() -> Self { + Self { + max_width: None, + align: TextAlign::Left, + line_height: 1.0, + scale: 1.0, + } + } +} + +/// One positioned glyph in the shaped output. +/// +/// `position` is the **pen position at the baseline** — the renderer adds +/// the atlas's per-glyph bearing to convert it into the top-left of the +/// glyph quad. Keeping it at the baseline (rather than at the top-left) is +/// what makes hit testing and caret positioning straightforward in pieces +/// 5–6. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ShapedGlyph { + pub key: GlyphKey, + pub position: Vec2, +} + +/// One shaped line — the glyphs, the line's content width (trailing +/// whitespace excluded), and the line's baseline / total height. +#[derive(Debug, Clone, PartialEq)] +pub struct ShapedLine { + pub glyphs: Vec, + pub width: f32, + pub baseline_y: f32, + pub line_height: f32, +} + +/// Full shaped output — `lines` in vertical order and the overall bounding +/// box `size`. `size.x` is the widest line's width (not `max_width`); +/// `size.y` is the sum of line heights, which equals the height of the +/// rectangle the text fits in. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ShapedText { + pub lines: Vec, + pub size: Vec2, +} + +/// Shape a single run of text. Convenience wrapper around [`shape_runs`]. +pub fn shape(text: &str, style: TextStyle, params: &ShapeParams, fonts: &FontStore) -> ShapedText { + shape_runs(&[TextRun { text, style }], params, fonts) +} + +/// Shape one or more runs into a single output. Items from different runs +/// share lines and share alignment, just as if they were one continuous +/// string with mixed styles. +pub fn shape_runs(runs: &[TextRun], params: &ShapeParams, fonts: &FontStore) -> ShapedText { + let mut items: Vec = Vec::new(); + for run in runs { + tokenize_run(run, params.scale, fonts, &mut items); + } + + let raw_lines = break_lines(items, params.max_width); + position_lines(raw_lines, params, fonts) +} + +// ---------- internals ---------- + +#[derive(Debug, Clone)] +enum Item { + Word { + font: FontId, + size_px: f32, + width: f32, + // (char, glyph id, advance) — kept so the positioner doesn't have to + // re-walk the source string. + glyphs: Vec, + }, + Whitespace { + font: FontId, + size_px: f32, + width: f32, + }, + Break, +} + +#[derive(Debug, Clone, Copy)] +struct GlyphAdvance { + glyph: super::font::GlyphId, + advance: f32, +} + +impl Item { + fn width(&self) -> f32 { + match self { + Item::Word { width, .. } | Item::Whitespace { width, .. } => *width, + Item::Break => 0.0, + } + } + + fn font_size(&self) -> Option<(FontId, f32)> { + match self { + Item::Word { font, size_px, .. } | Item::Whitespace { font, size_px, .. } => { + Some((*font, *size_px)) + } + Item::Break => None, + } + } + + fn is_whitespace(&self) -> bool { + matches!(self, Item::Whitespace { .. }) + } +} + +fn is_break(c: char) -> bool { + c == '\n' +} + +fn is_space_like(c: char) -> bool { + matches!(c, ' ' | '\t' | '\r') +} + +fn tokenize_run(run: &TextRun, scale: f32, fonts: &FontStore, out: &mut Vec) { + let style = run.style; + let size_px = style.size_px * scale; + let Some(font) = fonts.get(style.font) else { + // Unknown font id — skip the run rather than panicking. Tests in + // piece 4 will catch missing fonts before rendering; for piece 3 + // we want shape to remain a total function. + return; + }; + + let mut buf_word: Vec = Vec::new(); + let mut buf_word_width: f32 = 0.0; + let mut buf_ws_width: f32 = 0.0; + let mut state = TokState::Empty; + + for c in run.text.chars() { + if is_break(c) { + flush_buffers( + &mut state, + &mut buf_word, + &mut buf_word_width, + &mut buf_ws_width, + style.font, + size_px, + out, + ); + out.push(Item::Break); + continue; + } + if is_space_like(c) { + if matches!(state, TokState::Word) { + out.push(Item::Word { + font: style.font, + size_px, + width: buf_word_width, + glyphs: std::mem::take(&mut buf_word), + }); + buf_word_width = 0.0; + } + state = TokState::Whitespace; + let glyph = font.glyph_id(' '); + buf_ws_width += font.h_advance_px(glyph, size_px); + continue; + } + // Non-whitespace. + if matches!(state, TokState::Whitespace) { + out.push(Item::Whitespace { + font: style.font, + size_px, + width: buf_ws_width, + }); + buf_ws_width = 0.0; + } + state = TokState::Word; + let glyph = font.glyph_id(c); + let advance = font.h_advance_px(glyph, size_px); + buf_word.push(GlyphAdvance { glyph, advance }); + buf_word_width += advance; + } + + flush_buffers( + &mut state, + &mut buf_word, + &mut buf_word_width, + &mut buf_ws_width, + style.font, + size_px, + out, + ); +} + +#[derive(PartialEq)] +enum TokState { + Empty, + Word, + Whitespace, +} + +fn flush_buffers( + state: &mut TokState, + word: &mut Vec, + word_width: &mut f32, + ws_width: &mut f32, + font: FontId, + size_px: f32, + out: &mut Vec, +) { + match state { + TokState::Word => { + out.push(Item::Word { + font, + size_px, + width: *word_width, + glyphs: std::mem::take(word), + }); + *word_width = 0.0; + } + TokState::Whitespace => { + out.push(Item::Whitespace { + font, + size_px, + width: *ws_width, + }); + *ws_width = 0.0; + } + TokState::Empty => {} + } + *state = TokState::Empty; +} + +fn break_lines(items: Vec, max_width: Option) -> Vec> { + let mut raw_lines: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut current_width: f32 = 0.0; + let mut pending_ws: Vec = Vec::new(); + let mut pending_ws_width: f32 = 0.0; + + for item in items { + match item { + Item::Break => { + raw_lines.push(std::mem::take(&mut current)); + current_width = 0.0; + pending_ws.clear(); + pending_ws_width = 0.0; + } + Item::Whitespace { width, .. } => { + pending_ws_width += width; + pending_ws.push(item); + } + Item::Word { width, .. } => { + let fits = match max_width { + Some(max) => { + current.is_empty() || current_width + pending_ws_width + width <= max + } + None => true, + }; + if fits { + current.append(&mut pending_ws); + current_width += pending_ws_width; + current_width += width; + current.push(item); + } else { + raw_lines.push(std::mem::take(&mut current)); + // Leading whitespace on a wrapped line is dropped. + pending_ws.clear(); + current_width = width; + current.push(item); + } + pending_ws_width = 0.0; + } + } + } + if !current.is_empty() { + raw_lines.push(current); + } + raw_lines +} + +fn position_lines( + raw_lines: Vec>, + params: &ShapeParams, + fonts: &FontStore, +) -> ShapedText { + let mut lines: Vec = Vec::new(); + let mut cursor_y: f32 = 0.0; + let mut widest: f32 = 0.0; + + for line_items in raw_lines { + // Line metrics from the largest contributing item. + let mut max_ascent: f32 = 0.0; + let mut min_descent: f32 = 0.0; + let mut max_line_height: f32 = 0.0; + for item in &line_items { + if let Some((font_id, size_px)) = item.font_size() { + if let Some(font) = fonts.get(font_id) { + max_ascent = max_ascent.max(font.ascent_px(size_px)); + min_descent = min_descent.min(font.descent_px(size_px)); + max_line_height = max_line_height.max(font.line_height_px(size_px)); + } + } + } + let _ = min_descent; // descent reserved for vertical-extent queries later + let line_height = max_line_height * params.line_height; + + // Trailing whitespace is excluded from line width. + let mut content_width: f32 = 0.0; + let last_non_ws = line_items + .iter() + .enumerate() + .rev() + .find(|(_, it)| !it.is_whitespace()) + .map(|(i, _)| i); + if let Some(end) = last_non_ws { + for it in &line_items[..=end] { + content_width += it.width(); + } + } + + // Horizontal alignment offset. + let align_pad = match params.max_width { + Some(max) => { + let extra = (max - content_width).max(0.0); + match params.align { + TextAlign::Left => 0.0, + TextAlign::Center => extra * 0.5, + TextAlign::Right => extra, + } + } + None => 0.0, + }; + + let baseline_y = cursor_y + max_ascent; + let mut pen_x = align_pad; + let mut glyphs: Vec = Vec::new(); + for item in &line_items { + match item { + Item::Word { + font, + size_px, + glyphs: g, + .. + } => { + for ga in g { + glyphs.push(ShapedGlyph { + key: GlyphKey::new(*font, ga.glyph, *size_px), + position: Vec2::new(pen_x, baseline_y), + }); + pen_x += ga.advance; + } + } + Item::Whitespace { width, .. } => { + pen_x += *width; + } + Item::Break => {} + } + } + + lines.push(ShapedLine { + glyphs, + width: content_width, + baseline_y, + line_height, + }); + cursor_y += line_height; + widest = widest.max(content_width); + } + + ShapedText { + lines, + size: Vec2::new(widest, cursor_y), + } +} + +#[cfg(test)] +mod tests { + use super::super::font::try_load_system_font; + use super::*; + + fn make_store_and_style(size_px: f32) -> Option<(FontStore, TextStyle)> { + let font = try_load_system_font()?; + let mut store = FontStore::new(); + let id = store.insert(font); + Some((store, TextStyle { font: id, size_px })) + } + + #[test] + fn empty_text_produces_no_lines() { + let Some((store, style)) = make_store_and_style(16.0) else { + return; + }; + let out = shape("", style, &ShapeParams::default(), &store); + assert!(out.lines.is_empty()); + assert_eq!(out.size, Vec2::ZERO); + } + + #[test] + fn single_word_emits_one_line_with_correct_glyph_count() { + let Some((store, style)) = make_store_and_style(16.0) else { + return; + }; + let out = shape("Hello", style, &ShapeParams::default(), &store); + assert_eq!(out.lines.len(), 1); + assert_eq!(out.lines[0].glyphs.len(), 5); + // Glyphs are at the same baseline. + let baseline = out.lines[0].baseline_y; + for g in &out.lines[0].glyphs { + assert_eq!(g.position.y, baseline); + } + // x positions are monotonically increasing. + for w in out.lines[0].glyphs.windows(2) { + assert!(w[1].position.x > w[0].position.x); + } + // Line width matches the last glyph's pen-end (advance sum). + assert!(out.lines[0].width > 0.0); + assert!(out.size.x >= out.lines[0].width); + } + + #[test] + fn explicit_newline_starts_new_line() { + let Some((store, style)) = make_store_and_style(16.0) else { + return; + }; + let out = shape("a\nb", style, &ShapeParams::default(), &store); + assert_eq!(out.lines.len(), 2); + assert_eq!(out.lines[0].glyphs.len(), 1); + assert_eq!(out.lines[1].glyphs.len(), 1); + // Second baseline is below the first by one line height. + assert!(out.lines[1].baseline_y > out.lines[0].baseline_y); + } + + #[test] + fn word_wrap_splits_into_multiple_lines() { + let Some((store, style)) = make_store_and_style(16.0) else { + return; + }; + // A line wide enough for "Hello" but not "Hello world". + let one_word_width = shape("Hello", style, &ShapeParams::default(), &store).lines[0].width; + let params = ShapeParams { + max_width: Some(one_word_width + 2.0), + ..ShapeParams::default() + }; + let out = shape("Hello world", style, ¶ms, &store); + assert_eq!(out.lines.len(), 2); + // First line is just "Hello" (5 glyphs). + assert_eq!(out.lines[0].glyphs.len(), 5); + // Second line is "world" (5 glyphs); leading whitespace dropped. + assert_eq!(out.lines[1].glyphs.len(), 5); + // Second line starts at x = 0 (Left align by default; no leading + // whitespace consumed pen space). + assert_eq!(out.lines[1].glyphs[0].position.x, 0.0); + } + + #[test] + fn trailing_whitespace_excluded_from_line_width() { + let Some((store, style)) = make_store_and_style(16.0) else { + return; + }; + let bare = shape("Hi", style, &ShapeParams::default(), &store); + let trailing = shape("Hi ", style, &ShapeParams::default(), &store); + assert_eq!(bare.lines[0].width, trailing.lines[0].width); + } + + #[test] + fn alignment_shifts_glyph_positions_within_max_width() { + let Some((store, style)) = make_store_and_style(16.0) else { + return; + }; + let left = shape( + "Hi", + style, + &ShapeParams { + max_width: Some(200.0), + align: TextAlign::Left, + ..ShapeParams::default() + }, + &store, + ); + let center = shape( + "Hi", + style, + &ShapeParams { + max_width: Some(200.0), + align: TextAlign::Center, + ..ShapeParams::default() + }, + &store, + ); + let right = shape( + "Hi", + style, + &ShapeParams { + max_width: Some(200.0), + align: TextAlign::Right, + ..ShapeParams::default() + }, + &store, + ); + let l = left.lines[0].glyphs[0].position.x; + let c = center.lines[0].glyphs[0].position.x; + let r = right.lines[0].glyphs[0].position.x; + assert_eq!(l, 0.0); + assert!(c > l && c < r); + // Centered + right cases place the line within `max_width = 200`. + let width = left.lines[0].width; + assert!((c - (200.0 - width) * 0.5).abs() < 0.001); + assert!((r - (200.0 - width)).abs() < 0.001); + } + + #[test] + fn dpi_scale_doubles_advance_widths_and_baseline_drop() { + let Some((store, style)) = make_store_and_style(16.0) else { + return; + }; + let at_1x = shape("Hello", style, &ShapeParams::default(), &store); + let at_2x = shape( + "Hello", + style, + &ShapeParams { + scale: 2.0, + ..ShapeParams::default() + }, + &store, + ); + // Line width at 2× is ~2× at 1×. + let ratio = at_2x.lines[0].width / at_1x.lines[0].width; + assert!((ratio - 2.0).abs() < 0.05, "ratio = {ratio}"); + // First glyph's baseline drops at 2× by ~2× the 1× drop. + let baseline_ratio = at_2x.lines[0].baseline_y / at_1x.lines[0].baseline_y; + assert!((baseline_ratio - 2.0).abs() < 0.1); + } + + #[test] + fn line_height_multiplier_increases_vertical_spacing() { + let Some((store, style)) = make_store_and_style(16.0) else { + return; + }; + let single = shape("a\nb", style, &ShapeParams::default(), &store); + let spaced = shape( + "a\nb", + style, + &ShapeParams { + line_height: 2.0, + ..ShapeParams::default() + }, + &store, + ); + let gap_1 = single.lines[1].baseline_y - single.lines[0].baseline_y; + let gap_2 = spaced.lines[1].baseline_y - spaced.lines[0].baseline_y; + // Doubling the line-height factor roughly doubles inter-baseline + // distance — exact ratio depends on the font's gap fraction. + assert!( + (gap_2 / gap_1 - 2.0).abs() < 0.05, + "gap_2/gap_1 = {}", + gap_2 / gap_1 + ); + } + + #[test] + fn glyph_keys_are_stable_across_calls() { + let Some((store, style)) = make_store_and_style(16.0) else { + return; + }; + let a = shape("X", style, &ShapeParams::default(), &store); + let b = shape("X", style, &ShapeParams::default(), &store); + assert_eq!(a.lines[0].glyphs[0].key, b.lines[0].glyphs[0].key); + } + + #[test] + fn multi_font_run_takes_max_ascent_from_largest_size() { + let Some(font) = try_load_system_font() else { + return; + }; + let mut store = FontStore::new(); + let id = store.insert(font); + let small = TextStyle { + font: id, + size_px: 12.0, + }; + let big = TextStyle { + font: id, + size_px: 32.0, + }; + let mixed = shape_runs( + &[ + TextRun { + text: "Hi ", + style: small, + }, + TextRun { + text: "X", + style: big, + }, + ], + &ShapeParams::default(), + &store, + ); + let small_only = shape("Hi", small, &ShapeParams::default(), &store); + // The big-size baseline must be at least as deep as the small-size + // baseline because the line's ascent is the max of contributions. + assert!(mixed.lines[0].baseline_y >= small_only.lines[0].baseline_y); + } + + #[test] + fn unknown_font_id_does_not_panic() { + // No font registered → shape returns no lines instead of panicking. + let store = FontStore::new(); + let style = TextStyle { + font: FontId(99), + size_px: 16.0, + }; + let out = shape("Hello", style, &ShapeParams::default(), &store); + assert!(out.lines.is_empty()); + } + + #[test] + fn no_wrap_when_max_width_is_none() { + let Some((store, style)) = make_store_and_style(16.0) else { + return; + }; + let out = shape( + "one two three four five", + style, + &ShapeParams::default(), + &store, + ); + assert_eq!(out.lines.len(), 1); + } +} diff --git a/engine/src/ui/theme.rs b/engine/src/ui/theme.rs new file mode 100644 index 0000000..9484780 --- /dev/null +++ b/engine/src/ui/theme.rs @@ -0,0 +1,217 @@ +//! Theme — reusable named [`VisualStyle`]s plus a default fallback. +//! +//! A [`Theme`] is what a project ships to give every UI document a consistent +//! look without hand-styling every widget. The resolution rule is a strict +//! left-to-right cascade: +//! +//! 1. Start with `theme.default` (a `VisualStyle` whose `Some` fields are the +//! project-wide defaults — body text color, border weight, …). +//! 2. If the widget specifies `theme_style: Some("button")` and the theme +//! contains a `"button"` entry, merge that on top. +//! 3. Merge the widget's per-instance `visual` on top. +//! +//! Each merge is field-by-field: a `Some` on the right replaces the field; +//! a `None` keeps what was there. The result is a single [`VisualStyle`] +//! where any field that's still `None` means "the renderer's own hard-coded +//! fallback applies" — that fallback lives in piece 4 (the 2D overlay pass). +//! +//! Why named styles instead of CSS-like selectors: it makes per-widget +//! attribution explicit in the UI document (`theme_style: "button-primary"`) +//! and keeps theme resolution constant-time per widget. CSS selectors and +//! cascading rules are a richer model but their authoring cost dwarfs what +//! Stage-8 game UIs actually need. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::visual::VisualStyle; + +/// A named-style theme. Holds a `default` style applied to every widget plus +/// a map of named styles widgets can opt into by their `theme_style` field. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Theme { + /// Project-wide defaults — applied first to every widget before its + /// `theme_style` and per-instance overrides. + #[serde(default, skip_serializing_if = "VisualStyle::is_empty")] + pub default: VisualStyle, + /// Named style buckets — `theme_style: "button"` on a widget pulls the + /// `"button"` entry here on top of `default`. + /// + /// Stored as a `BTreeMap` (not `HashMap`) so RON output is in a + /// deterministic order — important for diff-friendly UI documents and + /// reproducible RON snapshots in tests. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub styles: BTreeMap, +} + +impl Theme { + /// Empty theme — no default fields, no named styles. Every widget under + /// this theme inherits only the renderer's hard-coded fallback. + pub const fn new() -> Self { + Self { + default: VisualStyle::EMPTY, + styles: BTreeMap::new(), + } + } + + /// Insert (or replace) a named style. Chainable for builder-style theme + /// construction in tests and examples. + pub fn with_style(mut self, name: impl Into, style: VisualStyle) -> Self { + self.styles.insert(name.into(), style); + self + } + + /// Replace the project-wide default style. + pub fn with_default(mut self, default: VisualStyle) -> Self { + self.default = default; + self + } + + /// Resolve the effective visual style for a widget that opts into + /// `style_ref` (if any) and provides its own `override_with` per-instance + /// fields. + /// + /// Cascade: `self.default` → (`self.styles[style_ref]` if present) → + /// `override_with`. A missing named style is treated as empty (no + /// contribution) rather than an error — UI documents stay valid when a + /// theme is swapped for a smaller one mid-development. + pub fn resolve(&self, style_ref: Option<&str>, override_with: &VisualStyle) -> VisualStyle { + let mut resolved = self.default.clone(); + if let Some(name) = style_ref { + if let Some(named) = self.styles.get(name) { + resolved = resolved.merged(named); + } + } + resolved.merged(override_with) + } + + /// Serialize this theme to a pretty-printed RON string. + pub fn to_ron(&self) -> Result { + ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default()) + } + + /// Parse a theme from a RON string. + pub fn from_ron(text: &str) -> Result { + ron::de::from_str(text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::Color; + use crate::ui::visual::{Border, FontRef}; + + fn theme_with_three_styles() -> Theme { + Theme::new() + .with_default(VisualStyle { + foreground: Some(Color::BLACK), + background: Some(Color::WHITE), + font: Some(FontRef::regular("Inter")), + font_size: Some(14.0), + ..VisualStyle::EMPTY + }) + .with_style( + "button", + VisualStyle { + background: Some(Color::rgb(0.85, 0.85, 0.9)), + border: Some(Border::new(Color::rgb(0.6, 0.6, 0.7), 1.0)), + corner_radius: Some(4.0), + ..VisualStyle::EMPTY + }, + ) + .with_style( + "button-primary", + VisualStyle { + background: Some(Color::rgb(0.2, 0.4, 0.8)), + foreground: Some(Color::WHITE), + ..VisualStyle::EMPTY + }, + ) + .with_style( + "label", + VisualStyle { + foreground: Some(Color::rgb(0.2, 0.2, 0.2)), + ..VisualStyle::EMPTY + }, + ) + } + + #[test] + fn resolve_returns_default_for_no_style_or_overrides() { + let theme = theme_with_three_styles(); + let resolved = theme.resolve(None, &VisualStyle::EMPTY); + assert_eq!(resolved.foreground, Some(Color::BLACK)); + assert_eq!(resolved.background, Some(Color::WHITE)); + assert_eq!(resolved.font_size, Some(14.0)); + } + + #[test] + fn named_style_overrides_default() { + let theme = theme_with_three_styles(); + let resolved = theme.resolve(Some("button"), &VisualStyle::EMPTY); + assert_eq!(resolved.background, Some(Color::rgb(0.85, 0.85, 0.9))); + // Foreground not set on "button" → kept from default. + assert_eq!(resolved.foreground, Some(Color::BLACK)); + assert_eq!(resolved.corner_radius, Some(4.0)); + } + + #[test] + fn per_instance_override_takes_final_precedence() { + let theme = theme_with_three_styles(); + let overlay = VisualStyle { + background: Some(Color::RED), + ..VisualStyle::EMPTY + }; + let resolved = theme.resolve(Some("button-primary"), &overlay); + // Per-instance background wins over the named style. + assert_eq!(resolved.background, Some(Color::RED)); + // The named style's foreground (WHITE) still beats the default (BLACK). + assert_eq!(resolved.foreground, Some(Color::WHITE)); + } + + #[test] + fn unknown_named_style_falls_back_to_default() { + let theme = theme_with_three_styles(); + let resolved = theme.resolve(Some("does-not-exist"), &VisualStyle::EMPTY); + // Same as resolve(None, &EMPTY). + assert_eq!(resolved, theme.resolve(None, &VisualStyle::EMPTY)); + } + + #[test] + fn theme_round_trips_through_ron() { + let theme = theme_with_three_styles(); + let text = theme.to_ron().unwrap(); + let decoded = Theme::from_ron(&text).unwrap(); + assert_eq!(theme, decoded); + // Named styles are alphabetised by BTreeMap, so "button" precedes + // "button-primary" precedes "label" in the serialized form. + let button_pos = text.find("\"button\"").unwrap(); + let primary_pos = text.find("\"button-primary\"").unwrap(); + let label_pos = text.find("\"label\"").unwrap(); + assert!(button_pos < primary_pos); + assert!(primary_pos < label_pos); + } + + #[test] + fn empty_theme_round_trips_to_empty_ron() { + let empty = Theme::new(); + let text = empty.to_ron().unwrap(); + let decoded = Theme::from_ron(&text).unwrap(); + assert_eq!(empty, decoded); + // The empty theme should not mention either field. + assert!(!text.contains("default:")); + assert!(!text.contains("styles:")); + } + + #[test] + fn builder_chaining_inserts_styles_in_order() { + let t = Theme::new() + .with_style("a", VisualStyle::EMPTY) + .with_style("b", VisualStyle::EMPTY); + assert_eq!(t.styles.len(), 2); + assert!(t.styles.contains_key("a")); + assert!(t.styles.contains_key("b")); + } +} diff --git a/engine/src/ui/value.rs b/engine/src/ui/value.rs new file mode 100644 index 0000000..5124112 --- /dev/null +++ b/engine/src/ui/value.rs @@ -0,0 +1,158 @@ +//! Per-widget typed value — the state interactive widgets carry. +//! +//! Stage 8's UI is data-driven: a slider knows its current position, a +//! text input knows the string the user has typed, a checkbox knows +//! whether it's checked. Rather than encoding "which kind of state does +//! this widget have" inside the layout enum, every [`Widget`](super::widget::Widget) +//! has an optional `value: Option` orthogonal to its `kind`. +//! That keeps the layout algorithm simple (it doesn't care about state) +//! and lets the same `Leaf` form a button (no value) or a checkbox +//! (`Bool` value). +//! +//! # Data binding model +//! +//! Stage-8 piece-6 uses the **immediate-mode** pattern (the same as +//! `egui` and Bevy UI): the widget tree is the source of truth for the +//! frame. Each frame the host: +//! +//! 1. Pulls latest game data into the matching widget values (e.g., +//! `root.set_value("volume", WidgetValue::Float(audio.master_volume as f64))`). +//! 2. Runs the [`Router`](super::routing::Router). +//! 3. Reads back any widget values that interactive widgets may have +//! changed, and pushes them into game data +//! (`audio.master_volume = root.value("volume")?.as_float()? as f32`). +//! +//! No callback storage, no `Rc>` for state, no lifetime +//! gymnastics — exactly what a game's main loop wants. + +use serde::{Deserialize, Serialize}; + +/// A typed value carried on an interactive widget — the slider's +/// position, a checkbox's check, a text-input's string. +/// +/// Variants are intentionally minimal; richer types (Color, Vec2, etc.) +/// can be added as widget needs grow. RON round-trips so a UI document +/// can ship default values inline. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum WidgetValue { + Bool(bool), + Int(i64), + Float(f64), + Text(String), +} + +impl WidgetValue { + /// Borrow as a bool if this is a [`Bool`](Self::Bool). + pub fn as_bool(&self) -> Option { + match self { + Self::Bool(v) => Some(*v), + _ => None, + } + } + + /// Borrow as an i64 if this is an [`Int`](Self::Int). + pub fn as_int(&self) -> Option { + match self { + Self::Int(v) => Some(*v), + _ => None, + } + } + + /// Borrow as an f64 if this is a [`Float`](Self::Float). + pub fn as_float(&self) -> Option { + match self { + Self::Float(v) => Some(*v), + _ => None, + } + } + + /// Borrow as a string slice if this is a [`Text`](Self::Text). + pub fn as_text(&self) -> Option<&str> { + match self { + Self::Text(s) => Some(s.as_str()), + _ => None, + } + } +} + +impl From for WidgetValue { + fn from(v: bool) -> Self { + Self::Bool(v) + } +} + +impl From for WidgetValue { + fn from(v: i64) -> Self { + Self::Int(v) + } +} + +impl From for WidgetValue { + fn from(v: i32) -> Self { + Self::Int(v as i64) + } +} + +impl From for WidgetValue { + fn from(v: f64) -> Self { + Self::Float(v) + } +} + +impl From for WidgetValue { + fn from(v: f32) -> Self { + Self::Float(v as f64) + } +} + +impl From for WidgetValue { + fn from(v: String) -> Self { + Self::Text(v) + } +} + +impl From<&str> for WidgetValue { + fn from(v: &str) -> Self { + Self::Text(v.to_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn as_accessors_match_variants() { + assert_eq!(WidgetValue::Bool(true).as_bool(), Some(true)); + assert_eq!(WidgetValue::Bool(true).as_int(), None); + assert_eq!(WidgetValue::Int(42).as_int(), Some(42)); + assert_eq!(WidgetValue::Float(1.5).as_float(), Some(1.5)); + assert_eq!(WidgetValue::Text("hi".into()).as_text(), Some("hi")); + } + + #[test] + fn primitive_conversions() { + let v: WidgetValue = true.into(); + assert_eq!(v, WidgetValue::Bool(true)); + let v: WidgetValue = 7_i32.into(); + assert_eq!(v, WidgetValue::Int(7)); + let v: WidgetValue = 1.5_f32.into(); + assert!((v.as_float().unwrap() - 1.5_f64).abs() < 1e-5); + let v: WidgetValue = "label".into(); + assert_eq!(v.as_text(), Some("label")); + } + + #[test] + fn ron_round_trips_each_variant() { + for v in [ + WidgetValue::Bool(true), + WidgetValue::Int(-99), + WidgetValue::Float(0.42), + WidgetValue::Text("hello".into()), + ] { + let text = ron::ser::to_string(&v).unwrap(); + let decoded: WidgetValue = ron::de::from_str(&text).unwrap(); + assert_eq!(v, decoded); + } + } +} diff --git a/engine/src/ui/visual.rs b/engine/src/ui/visual.rs new file mode 100644 index 0000000..c6f3aed --- /dev/null +++ b/engine/src/ui/visual.rs @@ -0,0 +1,324 @@ +//! Visual style — colors, borders, fonts. The *what does it look like* layer. +//! +//! [`VisualStyle`] is orthogonal to the Stage-8 [`LayoutStyle`](super::style::LayoutStyle): +//! layout decides where a widget *is*; visual decides what it *looks like*. +//! Every field is `Option`. `None` means **inherit** — from a [`Theme`](super::theme::Theme) +//! when present, otherwise from the renderer's hard-coded fallback in piece 4. +//! `Some` means **override**: this widget (or this named theme style) wants +//! exactly this value, regardless of what the theme provides. +//! +//! Why optional fields instead of full values: it lets a tiny per-widget +//! override stay tiny in RON (one line for "button-pressed has a brighter +//! background") without re-stating every color/border/font the theme already +//! provides. The same merging rule works equally well for theme cascades +//! (default → named style → per-instance) and for runtime state changes +//! (hover/focus/press overlays in piece 5). + +use serde::{Deserialize, Serialize}; + +use crate::asset::AssetRef; +use crate::math::Color; + +use super::text::Font; + +/// Optional per-widget visual properties. `None` on a field means "inherit"; +/// `Some` means "override". +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct VisualStyle { + /// Filled background color drawn behind the widget's `content_rect`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background: Option, + /// Foreground color — text, icons, anything drawn *on top of* the + /// background. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub foreground: Option, + /// Border drawn around the widget's `rect`. `Some(border)` with a + /// `width <= 0.0` is treated as "no border" by the renderer, the same as + /// `None`, but the value still serializes — useful for theme overrides + /// that explicitly *suppress* a border. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub border: Option, + /// Corner radius in logical pixels (zero means square). Applies to both + /// background fill and border. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub corner_radius: Option, + /// Font family + weight + italic flag. Piece 3 turns this into a + /// shaped glyph stream. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font: Option, + /// A specific font **asset** to draw with, chosen in the editor's UI canvas + /// from the project's `fonts/`. When set it takes precedence over the + /// portable [`font`](Self::font) descriptor (the renderer resolves the + /// [`AssetRef`] to a loaded face via the asset database); when `None` the + /// descriptor / theme path applies as before. This is the engine's first + /// `AssetRef` field — the asset-picker's end-to-end target. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_asset: Option>, + /// Font size in logical pixels. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_size: Option, +} + +impl VisualStyle { + /// Empty style — every field `None`. Equivalent to [`Default::default`]; + /// `EMPTY` exists as a `const` for places that want it as an associated + /// constant. + pub const EMPTY: Self = Self { + background: None, + foreground: None, + border: None, + corner_radius: None, + font: None, + font_asset: None, + font_size: None, + }; + + /// Returns a style where every `Some` field in `override_with` replaces + /// the corresponding field in `self`. + /// + /// This is the merge primitive themes and runtime state use: build a + /// resolved style by cascading default → named-style → per-instance → + /// state-overlay, each call replacing only the fields the caller cared + /// about. + pub fn merged(&self, override_with: &VisualStyle) -> VisualStyle { + VisualStyle { + background: override_with.background.or(self.background), + foreground: override_with.foreground.or(self.foreground), + border: override_with.border.or(self.border), + corner_radius: override_with.corner_radius.or(self.corner_radius), + font: override_with.font.clone().or_else(|| self.font.clone()), + font_asset: override_with.font_asset.or(self.font_asset), + font_size: override_with.font_size.or(self.font_size), + } + } + + /// True if every field is `None`. Handy as a `skip_serializing_if` test + /// when embedding a `VisualStyle` in a host struct that wants the empty + /// case to vanish from RON entirely. + pub fn is_empty(&self) -> bool { + *self == Self::EMPTY + } +} + +/// Border drawn around a widget's `rect`. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Border { + pub color: Color, + pub width: f32, +} + +impl Border { + pub const fn new(color: Color, width: f32) -> Self { + Self { color, width } + } +} + +/// Reference to a font face the renderer will load and shape with. +/// +/// Piece 2 stores the descriptor only; piece 3 (text shaping & glyph atlas) +/// resolves it to an actual loaded face. Keeping the descriptor as plain +/// `family` + `weight` + `italic` (rather than a path or a handle) means UI +/// documents are portable: a theme can ask for `"Inter"` and the runtime can +/// pick the platform's best match for that name without rewriting the +/// document. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct FontRef { + pub family: String, + #[serde(default, skip_serializing_if = "FontWeight::is_default")] + pub weight: FontWeight, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub italic: bool, +} + +impl FontRef { + /// Regular-weight, upright font of the given family. + pub fn regular(family: impl Into) -> Self { + Self { + family: family.into(), + weight: FontWeight::Regular, + italic: false, + } + } + + /// Bold-weight, upright font of the given family. + pub fn bold(family: impl Into) -> Self { + Self { + family: family.into(), + weight: FontWeight::Bold, + italic: false, + } + } +} + +/// Font weight — the named buckets the OpenType weight axis snaps to. +/// +/// Stored as a discrete enum (rather than a `u16` 100–900) because the +/// editor's style inspector and a hand-edited RON file both want +/// `weight: Bold` to round-trip exactly. Renderers can map each variant to +/// its OpenType weight value in piece 3. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum FontWeight { + Thin, + Light, + #[default] + Regular, + Medium, + Bold, + Black, +} + +impl FontWeight { + /// OpenType weight value (100..=900) for this bucket. + pub fn opentype_value(self) -> u16 { + match self { + Self::Thin => 100, + Self::Light => 300, + Self::Regular => 400, + Self::Medium => 500, + Self::Bold => 700, + Self::Black => 900, + } + } + + fn is_default(&self) -> bool { + *self == Self::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_style_has_no_set_fields() { + let s = VisualStyle::default(); + assert!(s.is_empty()); + assert_eq!(s, VisualStyle::EMPTY); + } + + #[test] + fn merge_overrides_only_set_fields() { + let base = VisualStyle { + background: Some(Color::WHITE), + foreground: Some(Color::BLACK), + border: Some(Border::new(Color::BLACK, 1.0)), + corner_radius: Some(4.0), + font: Some(FontRef::regular("Inter")), + font_asset: None, + font_size: Some(14.0), + }; + let overlay = VisualStyle { + background: Some(Color::rgb(0.9, 0.9, 0.9)), + font_size: Some(16.0), + ..VisualStyle::EMPTY + }; + let merged = base.merged(&overlay); + assert_eq!(merged.background, Some(Color::rgb(0.9, 0.9, 0.9))); // overlaid + assert_eq!(merged.foreground, Some(Color::BLACK)); // kept from base + assert_eq!(merged.font_size, Some(16.0)); // overlaid + assert_eq!(merged.corner_radius, Some(4.0)); // kept from base + assert_eq!(merged.font, Some(FontRef::regular("Inter"))); + } + + #[test] + fn merge_with_empty_overlay_is_identity() { + let base = VisualStyle { + background: Some(Color::WHITE), + foreground: Some(Color::BLACK), + ..VisualStyle::EMPTY + }; + assert_eq!(base.merged(&VisualStyle::EMPTY), base); + } + + #[test] + fn merge_into_empty_base_takes_overlay() { + let overlay = VisualStyle { + background: Some(Color::RED), + ..VisualStyle::EMPTY + }; + assert_eq!(VisualStyle::EMPTY.merged(&overlay), overlay); + } + + #[test] + fn font_ref_helpers_match_fields() { + let r = FontRef::regular("Inter"); + assert_eq!(r.family, "Inter"); + assert_eq!(r.weight, FontWeight::Regular); + assert!(!r.italic); + + let b = FontRef::bold("Inter"); + assert_eq!(b.weight, FontWeight::Bold); + } + + #[test] + fn font_weight_opentype_value() { + assert_eq!(FontWeight::Thin.opentype_value(), 100); + assert_eq!(FontWeight::Regular.opentype_value(), 400); + assert_eq!(FontWeight::Bold.opentype_value(), 700); + assert_eq!(FontWeight::Black.opentype_value(), 900); + } + + #[test] + fn visual_style_round_trips_through_ron_compactly() { + let s = VisualStyle { + background: Some(Color::WHITE), + corner_radius: Some(8.0), + font: Some(FontRef::bold("Inter")), + ..VisualStyle::EMPTY + }; + let text = ron::ser::to_string(&s).unwrap(); + // Fields that are `None` must not appear in the serialized form. + assert!(!text.contains("foreground")); + assert!(!text.contains("border")); + assert!(!text.contains("font_size")); + let decoded: VisualStyle = ron::de::from_str(&text).unwrap(); + assert_eq!(s, decoded); + } + + #[test] + fn font_asset_overrides_and_round_trips() { + use crate::asset::{AssetRef, AssetUid}; + + // An overlay's font_asset replaces the base's, like the other fields. + let base = VisualStyle { + font_asset: Some(AssetRef::new(AssetUid(1))), + ..VisualStyle::EMPTY + }; + let overlay = VisualStyle { + font_asset: Some(AssetRef::new(AssetUid(2))), + ..VisualStyle::EMPTY + }; + assert_eq!( + base.merged(&overlay).font_asset, + Some(AssetRef::new(AssetUid(2))) + ); + // An empty overlay keeps the base reference (inherit semantics). + assert_eq!(base.merged(&VisualStyle::EMPTY).font_asset, base.font_asset); + + // Round-trips compactly and is skipped when unset. + let text = ron::ser::to_string(&base).unwrap(); + assert!(text.contains("font_asset")); + assert_eq!(ron::de::from_str::(&text).unwrap(), base); + let empty_text = ron::ser::to_string(&VisualStyle::EMPTY).unwrap(); + assert!(!empty_text.contains("font_asset")); + } + + #[test] + fn empty_style_round_trips_to_empty_ron() { + let text = ron::ser::to_string(&VisualStyle::EMPTY).unwrap(); + // No fields set → the struct should serialize to its empty form. + let decoded: VisualStyle = ron::de::from_str(&text).unwrap(); + assert_eq!(decoded, VisualStyle::EMPTY); + } + + #[test] + fn font_ref_defaults_skip_in_ron() { + let f = FontRef::regular("Inter"); + let text = ron::ser::to_string(&f).unwrap(); + // Regular weight and non-italic should be skipped. + assert!(!text.contains("Regular")); + assert!(!text.contains("italic")); + let decoded: FontRef = ron::de::from_str(&text).unwrap(); + assert_eq!(f, decoded); + } +} diff --git a/engine/src/ui/widget.rs b/engine/src/ui/widget.rs new file mode 100644 index 0000000..5f1ad4e --- /dev/null +++ b/engine/src/ui/widget.rs @@ -0,0 +1,933 @@ +//! Widget tree — the data structure laid out by [`super::layout`]. +//! +//! Stage 8 splits widgets cleanly into **what** (the [`WidgetKind`]) and +//! **how** (the [`LayoutStyle`] held on every node). The kind decides whether +//! a node has children and how they're arranged; the style is the same fields +//! on every widget so the layout algorithm has one place to look. +//! +//! Piece 1 ships only what the layout algorithm needs: a [`Leaf`](WidgetKind::Leaf) +//! placeholder with an intrinsic size, and three container kinds — [`Stack`] +//! (row/column), [`Grid`], and [`AnchorGroup`]. Interactive widgets (button, +//! checkbox, slider, text input, …) are layered on top in later pieces by +//! decorating leaves with kind-specific style/state; they all participate in +//! the same layout pass without the algorithm having to know about them. +//! +//! # Building a tree +//! +//! ``` +//! use glam::Vec2; +//! use oxide_engine::ui::{Insets, LayoutStyle, Sizing, Widget}; +//! +//! let panel = Widget::row() +//! .with_id("toolbar") +//! .with_style(LayoutStyle { +//! width: Sizing::Grow(1.0), +//! height: Sizing::Fixed(32.0), +//! padding: Insets::all(4.0), +//! ..Default::default() +//! }) +//! .with_child(Widget::leaf(Vec2::new(80.0, 24.0)).with_id("file")) +//! .with_child(Widget::leaf(Vec2::new(80.0, 24.0)).with_id("edit")); +//! assert_eq!(panel.children().len(), 2); +//! ``` + +use glam::Vec2; +use serde::{Deserialize, Serialize}; + +use super::style::LayoutStyle; +use super::value::WidgetValue; +use super::visual::VisualStyle; + +/// Stable identifier for a widget — used to look up its laid-out rect in a +/// [`LayoutTree`](super::layout::LayoutTree) and (in later pieces) to wire up +/// input routing and data binding. +/// +/// Stored as `String` so UI documents can ship author-facing names (`"play"`, +/// `"volume-slider"`) straight through RON. The empty id (`""`) is the default +/// and means "anonymous"; multiple anonymous widgets are allowed and lookups +/// by empty id are rejected by [`LayoutTree::find`](super::layout::LayoutTree::find). +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct WidgetId(pub String); + +impl WidgetId { + /// `true` if the id string is empty. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Borrow the underlying string. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for WidgetId { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +/// A path from a root [`Widget`] to one of its descendants: the sequence of +/// child indices to follow from the root. The **empty** path denotes the root +/// itself. +/// +/// Unlike [`WidgetId`] (optional, author-facing, possibly absent or duplicated) +/// a path addresses *exactly one* node positionally, so it is what the editor's +/// UI canvas uses to target structural edits — insert, remove, move — and to +/// record them on the undo stack. Paths are only valid against the tree they +/// were derived from; an edit that changes sibling order invalidates the paths +/// after it (the move helper accounts for this itself). +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct WidgetPath(pub Vec); + +impl WidgetPath { + /// The root path (addresses the tree's root widget). + pub fn root() -> Self { + Self(Vec::new()) + } + + /// Whether this path addresses the root (is empty). + pub fn is_root(&self) -> bool { + self.0.is_empty() + } + + /// Depth from the root (number of indices). + pub fn len(&self) -> usize { + self.0.len() + } + + /// Whether the path is empty — alias of [`is_root`](Self::is_root), provided + /// for the clippy `len`/`is_empty` pairing. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// A child path one level deeper, selecting child `index`. + pub fn child(&self, index: usize) -> Self { + let mut v = self.0.clone(); + v.push(index); + Self(v) + } + + /// Splits into `(parent_path, last_index)`, or `None` for the root. + pub fn split_last(&self) -> Option<(WidgetPath, usize)> { + let (last, rest) = self.0.split_last()?; + Some((WidgetPath(rest.to_vec()), *last)) + } + + /// Whether `self` is `other` or lies underneath it (prefix test). Used to + /// reject moving a subtree into its own descendant. + pub fn starts_with(&self, other: &WidgetPath) -> bool { + self.0.starts_with(&other.0) + } +} + +impl From for WidgetId { + fn from(s: String) -> Self { + Self(s) + } +} + +/// A widget tree node — id, layout style, optional visual style + theme +/// reference, and a kind that decides what children it holds. +/// +/// `style` (Stage-8 piece 1) controls layout — where the widget is. +/// `visual` (piece 2) carries per-instance visual overrides — what the +/// widget looks like — and `theme_style` opts into a named entry in the +/// project's [`Theme`](super::theme::Theme). Both default to empty so a +/// piece-1 UI document still parses unchanged. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Widget { + #[serde(default, skip_serializing_if = "WidgetId::is_empty")] + pub id: WidgetId, + #[serde(default)] + pub style: LayoutStyle, + #[serde(default, skip_serializing_if = "VisualStyle::is_empty")] + pub visual: VisualStyle, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub theme_style: Option, + /// Text content shaped inside this widget's `content_rect`. Orthogonal + /// to `kind`: a button is a `Leaf` with `text` + `visual.background`; a + /// label is a `Leaf` with `text` only. Renderers shape this string + /// against the resolved [`VisualStyle::font`] and [`VisualStyle::font_size`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + /// Per-widget typed state — `Bool` for a checkbox, `Float` for a + /// slider, `Text` for a text input. Orthogonal to `kind`; absent + /// means "no state". See [`super::value::WidgetValue`] and the + /// piece-6 [`Widget::value`](Self::value) / [`set_value`](Self::set_value) + /// helpers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + pub kind: WidgetKind, +} + +/// What a widget *is* — leaf or one of three container layout modes. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum WidgetKind { + /// A childless node with an intrinsic logical size. Real interactive + /// widgets (label, button, image) layer on top of this in later pieces. + Leaf { intrinsic: Vec2 }, + /// Row or column container. + Stack(Stack), + /// Equal-cell grid container. + Grid(Grid), + /// Container that positions each child via the child's own + /// [`Anchor`](super::style::Anchor). + Anchor(AnchorGroup), +} + +impl Default for WidgetKind { + fn default() -> Self { + Self::Leaf { + intrinsic: Vec2::ZERO, + } + } +} + +/// Stack container — arranges children along a main axis. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Stack { + pub direction: StackDirection, + /// Logical-pixel gap between adjacent children. + #[serde(default)] + pub gap: f32, + /// How leftover space on the main axis is distributed *after* children + /// have been sized. Ignored when any child uses [`Sizing::Grow`](super::style::Sizing::Grow), + /// since `Grow` consumes the leftover space directly. + #[serde(default)] + pub main_align: super::style::Align, + #[serde(default)] + pub children: Vec, +} + +/// Direction of a [`Stack`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum StackDirection { + /// Children flow left-to-right. + #[default] + Row, + /// Children flow top-to-bottom. + Column, +} + +/// Equal-cell grid container — `cols × rows` cells filled in row-major order. +/// +/// Piece-1 grids are intentionally simple: every cell is the same size, +/// computed from the parent's content rect. More flexible grids (auto-sized +/// rows/columns, spans) are a follow-up; the use cases the editor's Stage-7 +/// preferences page and the Stage-8 settings examples actually need are all +/// served by the equal-cell case. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Grid { + pub cols: u32, + pub rows: u32, + /// `gap.x` between columns, `gap.y` between rows (logical pixels). + #[serde(default)] + pub gap: Vec2, + #[serde(default)] + pub children: Vec, +} + +/// Anchor container — each child is placed according to its own +/// [`LayoutStyle::anchor`](super::style::LayoutStyle::anchor). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct AnchorGroup { + #[serde(default)] + pub children: Vec, +} + +impl Widget { + /// Build a leaf widget with the given intrinsic logical size. + pub fn leaf(intrinsic: Vec2) -> Self { + Self { + kind: WidgetKind::Leaf { intrinsic }, + ..Default::default() + } + } + + /// Build an empty stack with the given direction (gap 0, default align). + pub fn stack(direction: StackDirection) -> Self { + Self { + kind: WidgetKind::Stack(Stack { + direction, + ..Default::default() + }), + ..Default::default() + } + } + + /// Shortcut for `Widget::stack(StackDirection::Row)`. + pub fn row() -> Self { + Self::stack(StackDirection::Row) + } + + /// Shortcut for `Widget::stack(StackDirection::Column)`. + pub fn column() -> Self { + Self::stack(StackDirection::Column) + } + + /// Build an empty grid container. + pub fn grid(cols: u32, rows: u32) -> Self { + Self { + kind: WidgetKind::Grid(Grid { + cols, + rows, + ..Default::default() + }), + ..Default::default() + } + } + + /// Build an empty anchor container. + pub fn anchor() -> Self { + Self { + kind: WidgetKind::Anchor(AnchorGroup::default()), + ..Default::default() + } + } + + /// Set the widget id (builder). + pub fn with_id(mut self, id: impl Into) -> Self { + self.id = id.into(); + self + } + + /// Replace the whole [`LayoutStyle`] (builder). + pub fn with_style(mut self, style: LayoutStyle) -> Self { + self.style = style; + self + } + + /// Replace the per-instance [`VisualStyle`] (builder). + pub fn with_visual(mut self, visual: VisualStyle) -> Self { + self.visual = visual; + self + } + + /// Opt this widget into a named entry of the active + /// [`Theme`](super::theme::Theme) (builder). Pass `""` or call + /// [`Widget::clear_theme_style`] to remove the reference. + pub fn with_theme_style(mut self, name: impl Into) -> Self { + let name = name.into(); + self.theme_style = if name.is_empty() { None } else { Some(name) }; + self + } + + /// Drop any `theme_style` reference (builder). + pub fn clear_theme_style(mut self) -> Self { + self.theme_style = None; + self + } + + /// Set this widget's text content (builder). Pass `""` to clear it. The + /// text is shaped at paint time against the widget's resolved font and + /// font size from the active theme. + pub fn with_text(mut self, text: impl Into) -> Self { + let s = text.into(); + self.text = if s.is_empty() { None } else { Some(s) }; + self + } + + /// Set this widget's typed value (builder). + pub fn with_value(mut self, value: impl Into) -> Self { + self.value = Some(value.into()); + self + } + + /// Set the stack gap (builder). Panics if not a stack — surfaces author + /// mistakes during construction rather than producing a silently + /// misshapen UI at layout time. + pub fn with_gap(mut self, gap: f32) -> Self { + match &mut self.kind { + WidgetKind::Stack(s) => s.gap = gap, + _ => panic!("with_gap is only valid on Stack widgets"), + } + self + } + + /// Set the stack main-axis alignment (builder). Panics if not a stack. + pub fn with_main_align(mut self, align: super::style::Align) -> Self { + match &mut self.kind { + WidgetKind::Stack(s) => s.main_align = align, + _ => panic!("with_main_align is only valid on Stack widgets"), + } + self + } + + /// Set the grid gap vector (builder). Panics if not a grid. + pub fn with_grid_gap(mut self, gap: Vec2) -> Self { + match &mut self.kind { + WidgetKind::Grid(g) => g.gap = gap, + _ => panic!("with_grid_gap is only valid on Grid widgets"), + } + self + } + + /// Append a single child to a container widget (builder). Panics on a + /// leaf so the misuse is caught at construction. + pub fn with_child(mut self, child: Widget) -> Self { + children_mut(&mut self.kind, |c| c.push(child)); + self + } + + /// Append many children (builder). + pub fn with_children(mut self, children: impl IntoIterator) -> Self { + children_mut(&mut self.kind, |c| c.extend(children)); + self + } + + /// Borrow the direct children of this widget. Empty for leaves. + pub fn children(&self) -> &[Widget] { + match &self.kind { + WidgetKind::Leaf { .. } => &[], + WidgetKind::Stack(s) => &s.children, + WidgetKind::Grid(g) => &g.children, + WidgetKind::Anchor(a) => &a.children, + } + } + + /// Borrow the direct children mutably. Empty slice for leaves. + /// + /// Underpins [`find_by_id_mut`](Self::find_by_id_mut) and the piece-6 + /// data-binding helpers; safer than reaching into `kind` because all + /// container kinds funnel through one accessor. + pub fn children_mut(&mut self) -> &mut [Widget] { + match &mut self.kind { + WidgetKind::Leaf { .. } => &mut [], + WidgetKind::Stack(s) => &mut s.children, + WidgetKind::Grid(g) => &mut g.children, + WidgetKind::Anchor(a) => &mut a.children, + } + } + + /// Borrow this widget's children as the owning `Vec`, or `None` for a + /// [`Leaf`](WidgetKind::Leaf) (which cannot hold children). Unlike + /// [`children_mut`](Self::children_mut) this exposes the `Vec` itself, so + /// callers can insert/remove — the basis of the structural edits below. + pub fn children_vec_mut(&mut self) -> Option<&mut Vec> { + match &mut self.kind { + WidgetKind::Leaf { .. } => None, + WidgetKind::Stack(s) => Some(&mut s.children), + WidgetKind::Grid(g) => Some(&mut g.children), + WidgetKind::Anchor(a) => Some(&mut a.children), + } + } + + /// Whether this widget is a container (can hold children) rather than a leaf. + pub fn is_container(&self) -> bool { + !matches!(self.kind, WidgetKind::Leaf { .. }) + } + + /// Borrow the widget addressed by `path` (the root for the empty path), or + /// `None` if any index along the way is out of range. + pub fn get_path(&self, path: &WidgetPath) -> Option<&Widget> { + let mut node = self; + for &i in &path.0 { + node = node.children().get(i)?; + } + Some(node) + } + + /// Mutable counterpart of [`get_path`](Self::get_path). + pub fn get_path_mut(&mut self, path: &WidgetPath) -> Option<&mut Widget> { + let mut node = self; + for &i in &path.0 { + node = node.children_mut().get_mut(i)?; + } + Some(node) + } + + /// Inserts `child` at `index` among the children of the widget addressed by + /// `parent`, returning whether it succeeded. `index` is clamped to the + /// child count (so it can append). Fails if `parent` does not resolve or is + /// a leaf. + pub fn insert_child(&mut self, parent: &WidgetPath, index: usize, child: Widget) -> bool { + let Some(parent) = self.get_path_mut(parent) else { + return false; + }; + let Some(children) = parent.children_vec_mut() else { + return false; + }; + children.insert(index.min(children.len()), child); + true + } + + /// Appends `child` to the children of the widget addressed by `parent`. + /// Convenience over [`insert_child`](Self::insert_child) with a trailing + /// index. + pub fn push_child_at(&mut self, parent: &WidgetPath, child: Widget) -> bool { + self.insert_child(parent, usize::MAX, child) + } + + /// Removes and returns the widget addressed by `path`. The root cannot be + /// removed (returns `None` for the empty path), nor can an out-of-range or + /// unreachable path. + pub fn remove_path(&mut self, path: &WidgetPath) -> Option { + let (parent, index) = path.split_last()?; + let children = self.get_path_mut(&parent)?.children_vec_mut()?; + (index < children.len()).then(|| children.remove(index)) + } + + /// Moves the subtree at `from` to be child `index` of `to_parent`, + /// returning whether it succeeded. Rejects moving the root, or moving a node + /// into itself or one of its own descendants. Sibling indices shift when the + /// node is detached, so both `to_parent` and `index` are adjusted internally + /// to mean what the caller intended *before* the move. + pub fn move_subtree( + &mut self, + from: &WidgetPath, + to_parent: &WidgetPath, + index: usize, + ) -> bool { + if from.is_root() || to_parent.starts_with(from) { + return false; + } + // The destination must exist and be a container; check before detaching + // (removing `from`, which is not an ancestor of `to_parent`, leaves the + // destination node itself unchanged — only its path may shift). + if !self.get_path(to_parent).is_some_and(Widget::is_container) { + return false; + } + let Some(node) = self.remove_path(from) else { + return false; + }; + let to_parent = adjust_path_for_removal(to_parent, from); + let (from_parent, from_index) = from.split_last().expect("non-root checked above"); + // Inserting back into the same parent after the detach point shifts the + // target slot down by one. + let index = if from_parent.0 == to_parent.0 && from_index < index { + index - 1 + } else { + index + }; + self.insert_child(&to_parent, index, node) + } + + /// Find a descendant (or self) with this id. Returns the first match + /// in pre-order. `None` if no widget matches (or `id` is empty). + pub fn find_by_id(&self, id: &WidgetId) -> Option<&Widget> { + if id.is_empty() { + return None; + } + if self.id == *id { + return Some(self); + } + for child in self.children() { + if let Some(found) = child.find_by_id(id) { + return Some(found); + } + } + None + } + + /// Mutable counterpart of [`find_by_id`](Self::find_by_id). + pub fn find_by_id_mut(&mut self, id: &WidgetId) -> Option<&mut Widget> { + if id.is_empty() { + return None; + } + if self.id == *id { + return Some(self); + } + for child in self.children_mut() { + if let Some(found) = child.find_by_id_mut(id) { + return Some(found); + } + } + None + } + + /// Borrow the [`WidgetValue`] of the descendant with this id, if any. + /// One half of the piece-6 data-binding loop: read what the UI says. + pub fn value(&self, id: &WidgetId) -> Option<&WidgetValue> { + self.find_by_id(id).and_then(|w| w.value.as_ref()) + } + + /// Set the [`WidgetValue`] of the descendant with this id, returning + /// `true` if such a widget exists. The other half of the piece-6 + /// data-binding loop: write game state into the UI. + pub fn set_value(&mut self, id: &WidgetId, value: impl Into) -> bool { + match self.find_by_id_mut(id) { + Some(w) => { + w.value = Some(value.into()); + true + } + None => false, + } + } + + /// Recursive count of nodes including `self`. Handy for sanity checks + /// in tests when comparing against a [`LayoutTree::nodes`](super::layout::LayoutTree::nodes) + /// length. + pub fn node_count(&self) -> usize { + 1 + self + .children() + .iter() + .map(Widget::node_count) + .sum::() + } + + /// Resolve this widget's effective [`VisualStyle`] under a given theme, + /// cascading `theme.default` → `theme.styles[self.theme_style]` → + /// `self.visual`. See [`Theme::resolve`](super::theme::Theme::resolve) + /// for the merge rules. Children are *not* recursively resolved here — + /// piece 4 walks the tree pairing each [`super::layout::LayoutNode`] with + /// its resolved style. + pub fn resolve_visual(&self, theme: &super::theme::Theme) -> VisualStyle { + theme.resolve(self.theme_style.as_deref(), &self.visual) + } + + /// Serialize this widget tree to a pretty-printed RON string — the + /// canonical UI-document format an editor saves and the runtime loads. + pub fn to_ron(&self) -> Result { + ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default()) + } + + /// Parse a widget tree from a RON string produced by [`to_ron`](Self::to_ron). + pub fn from_ron(text: &str) -> Result { + ron::de::from_str(text) + } +} + +/// Rewrites `path` to stay valid after the widget at `removed` is detached. +/// +/// Detaching shifts the later siblings of `removed` down by one. A path is +/// affected only if it descends through `removed`'s parent and its index at +/// that depth is *after* the removed index; then that one index decrements. +/// `path` must not be `removed` or beneath it (the caller guarantees this). +fn adjust_path_for_removal(path: &WidgetPath, removed: &WidgetPath) -> WidgetPath { + let Some((removed_parent, removed_index)) = removed.split_last() else { + return path.clone(); + }; + let depth = removed_parent.0.len(); + let mut out = path.0.clone(); + if out.len() > depth && out[..depth] == removed_parent.0[..] && out[depth] > removed_index { + out[depth] -= 1; + } + WidgetPath(out) +} + +fn children_mut(kind: &mut WidgetKind, f: impl FnOnce(&mut Vec)) { + match kind { + WidgetKind::Stack(s) => f(&mut s.children), + WidgetKind::Grid(g) => f(&mut g.children), + WidgetKind::Anchor(a) => f(&mut a.children), + WidgetKind::Leaf { .. } => panic!("cannot add children to a Leaf widget"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A row root with three leaf children id'd "a","b","c". + fn abc_tree() -> Widget { + Widget::row() + .with_id("root") + .with_child(Widget::leaf(Vec2::ZERO).with_id("a")) + .with_child(Widget::leaf(Vec2::ZERO).with_id("b")) + .with_child(Widget::leaf(Vec2::ZERO).with_id("c")) + } + + fn ids_of(children: &[Widget]) -> Vec<&str> { + children.iter().map(|w| w.id.as_str()).collect() + } + + #[test] + fn get_path_addresses_nodes() { + let root = abc_tree(); + assert_eq!( + root.get_path(&WidgetPath::root()).unwrap().id.as_str(), + "root" + ); + assert_eq!( + root.get_path(&WidgetPath(vec![1])).unwrap().id.as_str(), + "b" + ); + assert!(root.get_path(&WidgetPath(vec![9])).is_none()); + } + + #[test] + fn insert_and_remove_children_by_path() { + let mut root = abc_tree(); + // Insert "x" between a and b. + assert!(root.insert_child( + &WidgetPath::root(), + 1, + Widget::leaf(Vec2::ZERO).with_id("x") + )); + assert_eq!(ids_of(root.children()), ["a", "x", "b", "c"]); + // Append "z" via the clamping path. + assert!(root.push_child_at(&WidgetPath::root(), Widget::leaf(Vec2::ZERO).with_id("z"))); + assert_eq!(ids_of(root.children()), ["a", "x", "b", "c", "z"]); + // A leaf rejects children; the root cannot be removed. + assert!(!root.insert_child(&WidgetPath(vec![0]), 0, Widget::default())); + assert!(root.remove_path(&WidgetPath::root()).is_none()); + // Remove "x". + let removed = root.remove_path(&WidgetPath(vec![1])).unwrap(); + assert_eq!(removed.id.as_str(), "x"); + assert_eq!(ids_of(root.children()), ["a", "b", "c", "z"]); + } + + #[test] + fn move_subtree_reorders_within_parent() { + let mut root = abc_tree(); + // Move "a" (index 0) to the end (index 3 in pre-removal terms). + assert!(root.move_subtree(&WidgetPath(vec![0]), &WidgetPath::root(), 3)); + assert_eq!(ids_of(root.children()), ["b", "c", "a"]); + } + + #[test] + fn move_subtree_across_branches_adjusts_paths() { + // root[ col(0) [a], b(1), c(2) ]: move c into the column before a. + let mut root = Widget::row() + .with_id("root") + .with_child( + Widget::column() + .with_id("col") + .with_child(Widget::leaf(Vec2::ZERO).with_id("a")), + ) + .with_child(Widget::leaf(Vec2::ZERO).with_id("b")) + .with_child(Widget::leaf(Vec2::ZERO).with_id("c")); + assert!(root.move_subtree(&WidgetPath(vec![2]), &WidgetPath(vec![0]), 0)); + // c now leads the column; root has col + b left. + assert_eq!( + ids_of(root.get_path(&WidgetPath(vec![0])).unwrap().children()), + ["c", "a"] + ); + assert_eq!(ids_of(root.children()), ["col", "b"]); + } + + #[test] + fn move_subtree_rejects_into_own_descendant_and_root() { + let mut root = Widget::row().with_id("root").with_child( + Widget::column() + .with_id("col") + .with_child(Widget::leaf(Vec2::ZERO).with_id("a")), + ); + // Can't move "col" (path [0]) under its own child "a" (path [0,0]). + assert!(!root.move_subtree(&WidgetPath(vec![0]), &WidgetPath(vec![0, 0]), 0)); + // Can't move the root. + assert!(!root.move_subtree(&WidgetPath::root(), &WidgetPath(vec![0]), 0)); + // Tree is unchanged. + assert_eq!(ids_of(root.children()), ["col"]); + } + + #[test] + fn widget_id_from_str_and_string() { + let a: WidgetId = "abc".into(); + let b: WidgetId = String::from("abc").into(); + assert_eq!(a, b); + assert_eq!(a.as_str(), "abc"); + assert!(!a.is_empty()); + assert!(WidgetId::default().is_empty()); + } + + #[test] + fn default_widget_is_zero_leaf() { + let w = Widget::default(); + assert_eq!(w.id, WidgetId::default()); + assert_eq!(w.style, LayoutStyle::default()); + assert!(matches!(w.kind, WidgetKind::Leaf { intrinsic } if intrinsic == Vec2::ZERO)); + } + + #[test] + fn builder_methods_compose() { + let w = Widget::row() + .with_id("toolbar") + .with_gap(4.0) + .with_main_align(super::super::style::Align::Center) + .with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("a")) + .with_children([Widget::leaf(Vec2::new(20.0, 10.0)).with_id("b")]); + assert_eq!(w.id.as_str(), "toolbar"); + let WidgetKind::Stack(s) = &w.kind else { + panic!("expected stack"); + }; + assert_eq!(s.direction, StackDirection::Row); + assert_eq!(s.gap, 4.0); + assert_eq!(s.main_align, super::super::style::Align::Center); + assert_eq!(s.children.len(), 2); + assert_eq!(s.children[0].id.as_str(), "a"); + assert_eq!(s.children[1].id.as_str(), "b"); + } + + #[test] + #[should_panic(expected = "cannot add children to a Leaf widget")] + fn adding_child_to_leaf_panics() { + let _ = Widget::leaf(Vec2::new(1.0, 1.0)).with_child(Widget::leaf(Vec2::ONE)); + } + + #[test] + #[should_panic(expected = "with_gap is only valid on Stack widgets")] + fn gap_on_non_stack_panics() { + let _ = Widget::grid(2, 2).with_gap(4.0); + } + + #[test] + fn node_count_recurses() { + let tree = Widget::row() + .with_child(Widget::leaf(Vec2::ONE)) + .with_child( + Widget::column() + .with_child(Widget::leaf(Vec2::ONE)) + .with_child(Widget::leaf(Vec2::ONE)), + ); + // root + leaf + (column + 2 leaves) = 5 + assert_eq!(tree.node_count(), 5); + } + + #[test] + fn widget_round_trips_through_ron() { + let w = Widget::row() + .with_id("root") + .with_gap(8.0) + .with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a")) + .with_child(Widget::anchor().with_child(Widget::leaf(Vec2::new(10.0, 10.0)))); + let text = ron::ser::to_string_pretty(&w, ron::ser::PrettyConfig::default()).unwrap(); + let decoded: Widget = ron::de::from_str(&text).unwrap(); + assert_eq!(w, decoded); + } + + #[test] + fn visual_and_theme_style_builders_set_fields() { + use super::super::visual::VisualStyle; + use crate::math::Color; + + let w = Widget::leaf(Vec2::ONE) + .with_id("a") + .with_visual(VisualStyle { + background: Some(Color::RED), + ..VisualStyle::EMPTY + }) + .with_theme_style("button"); + assert_eq!(w.visual.background, Some(Color::RED)); + assert_eq!(w.theme_style.as_deref(), Some("button")); + + // Passing an empty string drops the reference. + let cleared = w.clone().with_theme_style(""); + assert_eq!(cleared.theme_style, None); + + let explicitly_cleared = w.clear_theme_style(); + assert_eq!(explicitly_cleared.theme_style, None); + } + + #[test] + fn resolve_visual_cascades_theme_named_overrides() { + use super::super::theme::Theme; + use super::super::visual::VisualStyle; + use crate::math::Color; + + let theme = Theme::new() + .with_default(VisualStyle { + foreground: Some(Color::BLACK), + background: Some(Color::WHITE), + ..VisualStyle::EMPTY + }) + .with_style( + "button", + VisualStyle { + background: Some(Color::rgb(0.85, 0.85, 0.9)), + ..VisualStyle::EMPTY + }, + ); + let w = Widget::leaf(Vec2::ONE) + .with_theme_style("button") + .with_visual(VisualStyle { + foreground: Some(Color::RED), + ..VisualStyle::EMPTY + }); + let resolved = w.resolve_visual(&theme); + assert_eq!(resolved.foreground, Some(Color::RED)); // per-instance + assert_eq!(resolved.background, Some(Color::rgb(0.85, 0.85, 0.9))); // named + } + + #[test] + fn widget_with_visual_and_theme_style_round_trips_through_ron() { + use super::super::visual::{FontRef, VisualStyle}; + use crate::math::Color; + + let w = Widget::row() + .with_id("toolbar") + .with_theme_style("toolbar") + .with_visual(VisualStyle { + background: Some(Color::rgb(0.1, 0.1, 0.1)), + font: Some(FontRef::bold("Inter")), + ..VisualStyle::EMPTY + }) + .with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_theme_style("button")); + let text = w.to_ron().unwrap(); + let decoded = Widget::from_ron(&text).unwrap(); + assert_eq!(w, decoded); + } + + #[test] + fn default_widget_serializes_without_new_fields() { + // The new `visual` and `theme_style` fields skip when empty/None, so + // a piece-1 default widget should still serialize to the piece-1 + // form (no `visual:` or `theme_style:` keys in the output). + let w = Widget::default(); + let text = w.to_ron().unwrap(); + assert!(!text.contains("visual:")); + assert!(!text.contains("theme_style:")); + // And re-parsing yields the same value. + assert_eq!(Widget::from_ron(&text).unwrap(), w); + } + + #[test] + fn find_by_id_walks_the_subtree() { + let tree = Widget::row() + .with_id("root") + .with_child(Widget::leaf(Vec2::ONE).with_id("a")) + .with_child( + Widget::column() + .with_id("group") + .with_child(Widget::leaf(Vec2::ONE).with_id("buried")), + ); + assert_eq!(tree.find_by_id(&"root".into()).unwrap().id.as_str(), "root"); + assert_eq!(tree.find_by_id(&"a".into()).unwrap().id.as_str(), "a"); + assert_eq!( + tree.find_by_id(&"buried".into()).unwrap().id.as_str(), + "buried" + ); + assert!(tree.find_by_id(&"missing".into()).is_none()); + // Empty id is never a match. + assert!(tree.find_by_id(&WidgetId::default()).is_none()); + } + + #[test] + fn set_value_updates_a_descendant() { + let mut tree = Widget::row() + .with_id("root") + .with_child(Widget::leaf(Vec2::ONE).with_id("volume")) + .with_child(Widget::leaf(Vec2::ONE).with_id("invert_y")); + assert!(tree.set_value(&"volume".into(), 0.75_f32)); + assert!(tree.set_value(&"invert_y".into(), true)); + assert_eq!( + tree.value(&"volume".into()).and_then(|v| v.as_float()), + Some(0.75_f32 as f64) + ); + assert_eq!( + tree.value(&"invert_y".into()).and_then(|v| v.as_bool()), + Some(true) + ); + // Unknown id: returns false, tree unchanged. + assert!(!tree.set_value(&"missing".into(), 0.0_f32)); + } + + #[test] + fn with_value_builder_sets_value() { + let w = Widget::leaf(Vec2::ONE).with_id("checkbox").with_value(true); + assert_eq!(w.value.as_ref().unwrap().as_bool(), Some(true)); + } + + #[test] + fn value_round_trips_through_widget_ron() { + use super::super::value::WidgetValue; + let w = Widget::leaf(Vec2::ONE) + .with_id("slider") + .with_value(WidgetValue::Float(0.42)); + let text = w.to_ron().unwrap(); + let decoded = Widget::from_ron(&text).unwrap(); + assert_eq!(w, decoded); + } +} diff --git a/engine/src/watch.rs b/engine/src/watch.rs new file mode 100644 index 0000000..8c0b80f --- /dev/null +++ b/engine/src/watch.rs @@ -0,0 +1,477 @@ +//! File-watcher foundation. +//! +//! Watches directories — typically a [`Project`](crate::project::Project)'s +//! `assets/`, `scenes/`, and `scripts/` folders — and emits **debounced**, +//! **deduplicated** change events. Built on the `notify` crate. +//! +//! ## Why debounce +//! +//! Filesystem events are noisy: editors write files in several syscalls (write, +//! rename, chmod), platforms report different fine-grained events for the same +//! logical change, and recursive watches can re-emit while a directory is being +//! populated. Forwarding every raw event to a reloader would re-parse assets +//! many times for one user save. The watcher collapses bursts on each path into +//! one event emitted after the path has been **quiet** for a configurable +//! window. +//! +//! ## Layered design (testability) +//! +//! The debounce/coalesce logic lives in a **pure** [`Debouncer`] that takes +//! `Instant`s from the caller, so unit tests verify it without touching real +//! files or sleeping. [`FileWatcher`] wraps `notify` plus a worker thread that +//! drives the debouncer with real time and forwards settled events through an +//! mpsc channel. A tolerant integration smoke test covers the wiring. +//! +//! ## Asset reload wiring +//! +//! [`reload_changed_assets`] rereads any cached assets whose source path +//! changed via [`AssetServer::reload_path`]. This is the groundwork for +//! Stage-10 script hot-reload — same pattern, different reloader. +//! +//! ```no_run +//! use std::time::Duration; +//! use oxide_engine::watch::{FileWatcher, reload_changed_assets}; +//! use oxide_engine::asset::AssetServer; +//! +//! let assets = AssetServer::new(); +//! let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?; +//! watcher.watch("path/to/project/assets")?; +//! +//! // Pump in the editor's per-frame tick: +//! while let Ok(event) = events.try_recv() { +//! reload_changed_assets(&assets, std::iter::once(event)); +//! } +//! # Ok::<(), oxide_engine::watch::WatchError>(()) +//! ``` + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{channel, Receiver}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; + +use crate::asset::AssetServer; + +/// Coarse classification of a filesystem change. +/// +/// The fine-grained `notify::EventKind` variants are collapsed into three +/// outcomes because every consumer downstream — asset reload, script reload, +/// project-panel refresh — only needs to know "rerun the loader", "drop the +/// entry", or "treat as new". Distinguishing a rename's two legs or an attr +/// change from a content write does not change what to do. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ChangeKind { + /// A file or directory appeared at this path. + Created, + /// An existing file's contents (or a directory's set of children) changed. + Modified, + /// A file or directory was removed at this path. + Removed, +} + +/// One settled change event for a single path. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ChangeEvent { + /// The path that changed (absolute when the underlying backend reports it + /// as such — `notify` typically does on the platforms Oxide targets). + pub path: PathBuf, + /// What kind of change it was, after coalescing. + pub kind: ChangeKind, +} + +/// Errors from the file-watcher subsystem. +#[derive(Debug, thiserror::Error)] +pub enum WatchError { + /// The underlying `notify` backend failed (no inotify slots, path missing, + /// permission denied, …). + #[error("file-watcher backend error: {0}")] + Backend(#[from] notify::Error), +} + +/// Pure debounce-and-coalesce core. +/// +/// Holds the most recent change kind seen for each path plus the time it was +/// last touched. [`drain_ready`](Self::drain_ready) emits an event for every +/// path that has been quiet for at least `quiet_window` relative to a +/// caller-supplied `now`. Because the caller controls `now`, tests can drive +/// the debouncer through a deterministic timeline. +pub struct Debouncer { + quiet_window: Duration, + pending: HashMap, +} + +impl Debouncer { + /// A debouncer that emits a path's event once it has been quiet for at + /// least `quiet_window`. + pub fn new(quiet_window: Duration) -> Self { + Self { + quiet_window, + pending: HashMap::new(), + } + } + + /// The configured quiet window. + pub fn quiet_window(&self) -> Duration { + self.quiet_window + } + + /// Number of paths currently in the pending set. + pub fn pending_len(&self) -> usize { + self.pending.len() + } + + /// Records a raw change for `path` observed at `now`. + /// + /// Coalescing rules (chosen to match what a downstream reloader cares + /// about): + /// - `Created` then `Modified` → `Created` (still a fresh file overall). + /// - `Removed` then `Modified` → `Created` (a file came back at this path). + /// - Otherwise the newer kind wins, including `Removed` superseding any + /// prior `Created`/`Modified`. + pub fn record(&mut self, path: PathBuf, kind: ChangeKind, now: Instant) { + let promoted = match self.pending.get(&path).map(|(k, _)| *k) { + Some(ChangeKind::Created) if kind == ChangeKind::Modified => ChangeKind::Created, + Some(ChangeKind::Removed) if kind == ChangeKind::Modified => ChangeKind::Created, + _ => kind, + }; + self.pending.insert(path, (promoted, now)); + } + + /// Removes and returns every event whose last update is at least + /// `quiet_window` old relative to `now`. The returned vector is sorted by + /// path so output is deterministic for testing and snapshotting. + pub fn drain_ready(&mut self, now: Instant) -> Vec { + let mut ready: Vec = Vec::new(); + self.pending.retain(|path, (kind, t)| { + if now.saturating_duration_since(*t) >= self.quiet_window { + ready.push(ChangeEvent { + path: path.clone(), + kind: *kind, + }); + false + } else { + true + } + }); + ready.sort_by(|a, b| a.path.cmp(&b.path)); + ready + } +} + +/// A directory watcher that emits debounced [`ChangeEvent`]s. +/// +/// Construction returns the watcher plus the [`Receiver`] events arrive on. +/// Add directories with [`watch`](Self::watch); remove them with +/// [`unwatch`](Self::unwatch). Dropping the watcher stops the worker thread +/// and disconnects the receiver. +pub struct FileWatcher { + /// Kept alive so its `Drop` releases the backend's OS watches. + _backend: RecommendedWatcher, + debouncer: Arc>, + stop: Arc, + worker: Option>, +} + +impl FileWatcher { + /// Creates a watcher whose worker forwards settled events through the + /// returned receiver. Paths are not watched until you call + /// [`watch`](Self::watch). + pub fn new(quiet_window: Duration) -> Result<(Self, Receiver), WatchError> { + let (raw_tx, raw_rx) = channel::>(); + let backend = RecommendedWatcher::new( + move |res| { + // If the receiving end is gone we are tearing down; nothing to + // do but drop the event. + let _ = raw_tx.send(res); + }, + notify::Config::default(), + )?; + + let debouncer = Arc::new(Mutex::new(Debouncer::new(quiet_window))); + let stop = Arc::new(AtomicBool::new(false)); + let (out_tx, out_rx) = channel::(); + + // Drain the backend frequently enough that bursts settle within a few + // ticks; quarter of the quiet window is short enough to be responsive + // without busy-waiting. + let tick = (quiet_window / 4).max(Duration::from_millis(10)); + let worker_debouncer = debouncer.clone(); + let worker_stop = stop.clone(); + let worker = std::thread::spawn(move || { + while !worker_stop.load(Ordering::Relaxed) { + match raw_rx.recv_timeout(tick) { + Ok(Ok(event)) => { + if let Some(kind) = classify(&event.kind) { + let now = Instant::now(); + let mut d = worker_debouncer.lock().unwrap(); + for path in event.paths { + d.record(path, kind, now); + } + } + } + // Backend reported an error event; ignore but keep running. + Ok(Err(_)) => {} + // Tick elapsed with no new events. Fall through to drain. + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + // Raw channel disconnected → backend dropped → we're done. + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + } + + let ready = worker_debouncer.lock().unwrap().drain_ready(Instant::now()); + for ev in ready { + if out_tx.send(ev).is_err() { + return; + } + } + } + }); + + Ok(( + Self { + _backend: backend, + debouncer, + stop, + worker: Some(worker), + }, + out_rx, + )) + } + + /// Recursively watches `path`. Repeated calls with the same path are + /// equivalent to one call. + pub fn watch(&mut self, path: impl AsRef) -> Result<(), WatchError> { + self._backend + .watch(path.as_ref(), RecursiveMode::Recursive)?; + Ok(()) + } + + /// Stops watching `path`. Errors if the backend was not watching it. + pub fn unwatch(&mut self, path: impl AsRef) -> Result<(), WatchError> { + self._backend.unwatch(path.as_ref())?; + Ok(()) + } + + /// Read-only snapshot of how many paths are currently buffered by the + /// debouncer (haven't yet been quiet long enough to fire). Mostly for + /// tests and diagnostics. + pub fn pending_count(&self) -> usize { + self.debouncer.lock().unwrap().pending_len() + } +} + +impl Drop for FileWatcher { + fn drop(&mut self) { + // Signal first so the worker exits its next loop iteration; dropping + // the backend closes the raw channel as a secondary safety net. + self.stop.store(true, Ordering::Relaxed); + if let Some(h) = self.worker.take() { + let _ = h.join(); + } + } +} + +/// Translates a `notify` event kind into our coarse [`ChangeKind`]. Returns +/// `None` for events we deliberately ignore (e.g. access timestamps). +fn classify(kind: &EventKind) -> Option { + match kind { + EventKind::Create(_) => Some(ChangeKind::Created), + EventKind::Modify(_) => Some(ChangeKind::Modified), + EventKind::Remove(_) => Some(ChangeKind::Removed), + // Reads/opens don't change the file; skipping keeps the event stream + // focused on "something to reload". + EventKind::Access(_) => None, + // `Any` is the fallback some backends emit for "something happened"; + // treat as Modified so a reloader still gets a chance. + EventKind::Any => Some(ChangeKind::Modified), + EventKind::Other => None, + } +} + +/// Reruns the loader for every cached asset whose source path appears in +/// `events` with a `Created` or `Modified` kind. +/// +/// Returns the total number of asset entries reloaded. Paths that are not +/// currently cached (no live handle) are silently ignored — there is nothing +/// to reload, and the next [`AssetServer::load`] will pick up the new contents +/// anyway. `Removed` events are ignored here too: the engine does not +/// preemptively invalidate handles when the underlying file disappears, +/// because gameplay code may want the last-loaded copy to keep working. +pub fn reload_changed_assets(server: &AssetServer, events: I) -> usize +where + I: IntoIterator, +{ + let mut n = 0; + for ev in events { + if matches!(ev.kind, ChangeKind::Created | ChangeKind::Modified) { + n += server.reload_path(&ev.path); + } + } + n +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(name: &str) -> PathBuf { + PathBuf::from(name) + } + + #[test] + fn dedupes_a_burst_for_one_path() { + let mut d = Debouncer::new(Duration::from_millis(100)); + let t0 = Instant::now(); + d.record(p("a"), ChangeKind::Modified, t0); + d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(10)); + d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(30)); + // Path is still "hot" — nothing should fire yet. + assert!(d.drain_ready(t0 + Duration::from_millis(80)).is_empty()); + // After the quiet window elapses since the last touch, one event fires. + let ready = d.drain_ready(t0 + Duration::from_millis(130) + Duration::from_millis(10)); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].path, p("a")); + assert_eq!(ready[0].kind, ChangeKind::Modified); + // And the pending set is empty afterwards. + assert_eq!(d.pending_len(), 0); + } + + #[test] + fn each_path_settles_independently() { + let mut d = Debouncer::new(Duration::from_millis(50)); + let t0 = Instant::now(); + d.record(p("a"), ChangeKind::Modified, t0); + d.record(p("b"), ChangeKind::Created, t0 + Duration::from_millis(30)); + // At t0+60: "a" is quiet for 60ms (≥ 50ms) but "b" is only 30ms quiet. + let ready = d.drain_ready(t0 + Duration::from_millis(60)); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].path, p("a")); + assert_eq!(d.pending_len(), 1); + // At t0+90: "b" has been quiet for 60ms and now fires. + let ready = d.drain_ready(t0 + Duration::from_millis(90)); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].path, p("b")); + assert_eq!(ready[0].kind, ChangeKind::Created); + } + + #[test] + fn drain_output_is_sorted_by_path() { + let mut d = Debouncer::new(Duration::from_millis(10)); + let t0 = Instant::now(); + d.record(p("zeta"), ChangeKind::Modified, t0); + d.record(p("alpha"), ChangeKind::Modified, t0); + d.record(p("mid"), ChangeKind::Modified, t0); + let ready = d.drain_ready(t0 + Duration::from_millis(20)); + assert_eq!( + ready.iter().map(|e| e.path.clone()).collect::>(), + vec![p("alpha"), p("mid"), p("zeta")] + ); + } + + #[test] + fn created_then_modified_stays_created() { + let mut d = Debouncer::new(Duration::from_millis(10)); + let t0 = Instant::now(); + d.record(p("a"), ChangeKind::Created, t0); + d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(2)); + let ready = d.drain_ready(t0 + Duration::from_millis(20)); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].kind, ChangeKind::Created); + } + + #[test] + fn removed_then_modified_becomes_created() { + // A file is deleted, then a new file appears at the same path (e.g. + // editors that save by atomic-replace). Downstream wants to treat this + // as a fresh asset, not a missing one. + let mut d = Debouncer::new(Duration::from_millis(10)); + let t0 = Instant::now(); + d.record(p("a"), ChangeKind::Removed, t0); + d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(2)); + let ready = d.drain_ready(t0 + Duration::from_millis(20)); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].kind, ChangeKind::Created); + } + + #[test] + fn removed_supersedes_prior_kinds() { + let mut d = Debouncer::new(Duration::from_millis(10)); + let t0 = Instant::now(); + d.record(p("a"), ChangeKind::Created, t0); + d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(1)); + d.record(p("a"), ChangeKind::Removed, t0 + Duration::from_millis(2)); + let ready = d.drain_ready(t0 + Duration::from_millis(20)); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].kind, ChangeKind::Removed); + } + + #[test] + fn classify_covers_the_three_main_kinds() { + use notify::event::{CreateKind, ModifyKind, RemoveKind}; + assert_eq!( + classify(&EventKind::Create(CreateKind::File)), + Some(ChangeKind::Created) + ); + assert_eq!( + classify(&EventKind::Modify(ModifyKind::Any)), + Some(ChangeKind::Modified) + ); + assert_eq!( + classify(&EventKind::Remove(RemoveKind::File)), + Some(ChangeKind::Removed) + ); + assert_eq!(classify(&EventKind::Any), Some(ChangeKind::Modified)); + } + + /// Tolerant smoke test: write a file under a temp dir, then poll for an + /// event with a generous timeout. The unit tests above already cover the + /// debounce logic deterministically, so this only needs to prove the + /// notify→debouncer→channel wiring is connected. + #[test] + fn end_to_end_emits_on_real_filesystem_change() { + let mut dir = std::env::temp_dir(); + dir.push(format!("oxide_watch_smoke_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let (mut watcher, events) = + FileWatcher::new(Duration::from_millis(80)).expect("create watcher"); + watcher.watch(&dir).expect("watch tempdir"); + + // Some platforms need a brief moment between watch() and producing + // events for fresh writes; the deadline below absorbs that. + let file = dir.join("hello.txt"); + std::fs::write(&file, "first").unwrap(); + // Touch a couple more times to exercise dedup under real timing. + std::thread::sleep(Duration::from_millis(20)); + std::fs::write(&file, "second").unwrap(); + std::thread::sleep(Duration::from_millis(20)); + std::fs::write(&file, "third").unwrap(); + + // Wait up to 3 seconds for at least one event for our file. This is + // intentionally generous: CI machines under load and macOS FSEvents + // can take a second or more to deliver the first event. + let deadline = Instant::now() + Duration::from_secs(3); + let mut saw = None; + while Instant::now() < deadline { + if let Ok(ev) = events.recv_timeout(Duration::from_millis(100)) { + // Some backends report a canonicalized path; compare by file + // name to stay robust to that. + if ev.path.file_name() == Some(std::ffi::OsStr::new("hello.txt")) { + saw = Some(ev); + break; + } + } + } + + std::fs::remove_dir_all(&dir).ok(); + if saw.is_none() { + // Some sandboxes (containerized CI) disable filesystem-event + // backends entirely; skip rather than fail flakily there. + eprintln!("SKIP: no inotify/FSEvent backend appears to deliver events here"); + } + } +} diff --git a/engine/src/window/app.rs b/engine/src/window/app.rs new file mode 100644 index 0000000..a7650d3 --- /dev/null +++ b/engine/src/window/app.rs @@ -0,0 +1,133 @@ +//! The [`WindowApp`] trait and per-callback context. + +use winit::event::WindowEvent; +use winit::window::Window; + +use crate::input::InputState; +use crate::math::Color; +use crate::render::{Gpu, RenderContext}; + +/// An application driven by the engine's event loop. +/// +/// Implement this and pass the value to [`run`](super::run). All methods have +/// empty defaults so minimal apps only override what they need. Per frame the +/// engine calls [`event`](Self::event) for each pending window event, then +/// [`update`](Self::update), then clears and presents the surface. +/// +/// This trait is the **window-event handler** — the per-frame plumbing between +/// `winit` and a renderer. It is distinct from the engine's +/// [`App`](crate::app::App) **container**, which owns the scene, assets, and +/// scheduled systems. The editor's main loop typically implements this trait +/// on a struct that *also* owns an `oxide_engine::app::App`. +pub trait WindowApp { + /// Called once, after the window and GPU context exist but before the + /// first frame. + fn init(&mut self, ctx: &mut AppCtx<'_>) { + let _ = ctx; + } + + /// Called for every raw window event (keyboard, mouse, resize, focus, …). + /// + /// Events the engine itself reacts to (close request, resize) are still + /// forwarded here afterwards, so apps observe everything. + fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) { + let _ = (ctx, event); + } + + /// Called once per frame, before the frame is rendered. + fn update(&mut self, ctx: &mut AppCtx<'_>) { + let _ = ctx; + } + + /// Called each frame after the surface has been cleared and before it is + /// presented, so the app can record its own draw commands into the frame. + /// + /// This is the hook editor/overlay UI (egui) and, in later stages, the + /// scene renderer draw through. The surface is cleared with + /// [`LoadOp::Clear`](wgpu::LoadOp::Clear) *before* this runs; record passes + /// here with [`LoadOp::Load`](wgpu::LoadOp::Load) to draw on top of the + /// clear color rather than wiping it. + fn render(&mut self, ctx: &RenderCtx<'_>) { + let _ = ctx; + } +} + +/// Per-frame rendering context passed to [`WindowApp::render`]. +/// +/// Unlike [`AppCtx`], this borrows the GPU and surface immutably: by the time +/// the draw hook runs the frame's surface texture is already acquired, so the +/// app receives the handles it needs to record additional passes into +/// [`view`](Self::view) without re-entering the render context. +pub struct RenderCtx<'a> { + /// The GPU device/queue to record and submit commands with. + pub gpu: &'a Gpu, + /// The current frame's surface texture view (the render target). + pub view: &'a wgpu::TextureView, + /// The window being rendered, e.g. for input/UI integration that needs it. + pub window: &'a Window, + /// The surface's texture format, needed to build matching pipelines. + pub surface_format: wgpu::TextureFormat, + /// Surface size in physical pixels (`width`, `height`). + pub size: (u32, u32), +} + +/// Engine state handed to every [`WindowApp`] callback. +pub struct AppCtx<'a> { + pub(crate) render: &'a mut RenderContext, + pub(crate) window: &'a Window, + pub(crate) exit: &'a mut bool, + pub(crate) input: &'a InputState, + /// Seconds elapsed since the previous frame (`0.0` during + /// [`App::init`] and the first frame). + pub dt: f32, +} + +impl AppCtx<'_> { + /// The render context driving the window surface. + pub fn render(&mut self) -> &mut RenderContext { + self.render + } + + /// Sets the color the surface is cleared to, effective next frame. + pub fn set_clear_color(&mut self, color: Color) { + self.render.set_clear_color(color); + } + + /// The current clear color. + pub fn clear_color(&self) -> Color { + self.render.clear_color() + } + + /// Current surface size in physical pixels. + pub fn size(&self) -> (u32, u32) { + self.render.size() + } + + /// Sets the window title. + pub fn set_title(&self, title: &str) { + self.window.set_title(title); + } + + /// The window being driven, e.g. to construct UI/input integration that + /// needs a window handle. + pub fn window(&self) -> &Window { + self.window + } + + /// Asks the event loop to exit after the current callback returns. + pub fn request_exit(&mut self) { + *self.exit = true; + } + + /// The per-frame input snapshot. + /// + /// Reflects every keyboard / mouse / scroll event delivered since the + /// previous frame's `update` returned. In [`WindowApp::event`] callbacks + /// it includes the event currently being delivered (the runner pumps it + /// before invoking the callback). In [`WindowApp::update`] it is the + /// accumulated state for the new frame; the runner clears edges (pressed/ + /// released, mouse delta, scroll) automatically after `update` returns. + pub fn input(&self) -> &InputState { + self.input + } +} diff --git a/engine/src/window/mod.rs b/engine/src/window/mod.rs new file mode 100644 index 0000000..d067925 --- /dev/null +++ b/engine/src/window/mod.rs @@ -0,0 +1,63 @@ +//! Windowing and the application event loop. +//! +//! Stage 2 scope: open a window via `winit`, hand its surface to the +//! [`render`](crate::render) module, and run a clear-color render loop. +//! Applications implement [`WindowApp`] and are driven by [`run`]; raw window +//! events (keyboard, mouse, resize, …) are forwarded to +//! [`WindowApp::event`] untranslated — input abstraction arrives in Stage 5. +//! +//! Stage 6 renamed this trait from `App` to `WindowApp` so the engine's core +//! [`App`](crate::app::App) container — the owner of scene, assets, and +//! scheduled systems — can live in the prelude unambiguously. The two are +//! distinct roles: this trait is the **window-event handler** the editor and +//! examples implement; the core `App` is the engine state they typically wrap +//! around. + +mod app; +mod runner; + +pub use app::{AppCtx, RenderCtx, WindowApp}; +pub use runner::run; + +pub mod event { + //! Raw window/input event types, re-exported from `winit`. + //! + //! Stage 2 deliberately exposes events untranslated; the Stage 5 input + //! system will layer action mapping on top of these. + pub use winit::dpi::{PhysicalPosition, PhysicalSize}; + pub use winit::event::{ + DeviceEvent, DeviceId, ElementState, KeyEvent, Modifiers, MouseButton, MouseScrollDelta, + WindowEvent, + }; + pub use winit::keyboard::{Key, KeyCode, ModifiersState, NamedKey, PhysicalKey}; +} + +use crate::math::Color; + +/// Initial window settings, consumed by [`run`]. +#[derive(Debug, Clone)] +pub struct WindowConfig { + /// Window title. + pub title: String, + /// Initial inner width in logical pixels. + pub width: u32, + /// Initial inner height in logical pixels. + pub height: u32, + /// Whether the user can resize the window. + pub resizable: bool, + /// Color the surface is cleared to each frame (changeable at runtime via + /// [`AppCtx::set_clear_color`]). + pub clear_color: Color, +} + +impl Default for WindowConfig { + fn default() -> Self { + Self { + title: "Oxide".to_string(), + width: 1280, + height: 720, + resizable: true, + clear_color: Color::BLACK, + } + } +} diff --git a/engine/src/window/runner.rs b/engine/src/window/runner.rs new file mode 100644 index 0000000..a9203d6 --- /dev/null +++ b/engine/src/window/runner.rs @@ -0,0 +1,175 @@ +//! The winit event-loop runner behind [`run`]. + +use std::sync::Arc; +use std::time::Instant; + +use winit::application::ApplicationHandler; +use winit::dpi::LogicalSize; +use winit::event::WindowEvent; +use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::window::{Window, WindowId}; + +use super::{AppCtx, WindowApp, WindowConfig}; +use crate::input::InputState; +use crate::render::RenderContext; + +/// Opens a window per `config` and runs `app` until it requests exit or the +/// window is closed. +/// +/// Blocks the calling thread for the lifetime of the window (an OS +/// requirement: the event loop must run on the main thread). +pub fn run(config: WindowConfig, app: A) -> anyhow::Result<()> { + let event_loop = EventLoop::new()?; + // Poll: render continuously (a game loop), rather than waiting for + // input events like a desktop utility would. + event_loop.set_control_flow(ControlFlow::Poll); + let mut runner = Runner { + config, + app, + state: None, + input: InputState::new(), + last_frame: None, + exit: false, + error: None, + }; + event_loop.run_app(&mut runner)?; + match runner.error { + Some(err) => Err(err), + None => Ok(()), + } +} + +struct WindowState { + window: Arc, + render: RenderContext, +} + +struct Runner { + config: WindowConfig, + app: A, + state: Option, + /// Accumulated keyboard/mouse/scroll state across the current frame; + /// pumped from every window event and cleared after `update` returns. + input: InputState, + last_frame: Option, + exit: bool, + /// Initialization/render errors are stashed here and returned from + /// [`run`], since winit callbacks cannot propagate `Result`. + error: Option, +} + +impl Runner { + fn create_window(&mut self, event_loop: &ActiveEventLoop) -> anyhow::Result<()> { + let attrs = Window::default_attributes() + .with_title(&self.config.title) + .with_inner_size(LogicalSize::new(self.config.width, self.config.height)) + .with_resizable(self.config.resizable); + let window = Arc::new(event_loop.create_window(attrs)?); + let mut render = RenderContext::new(window.clone())?; + render.set_clear_color(self.config.clear_color); + + let mut state = WindowState { window, render }; + let mut ctx = AppCtx { + render: &mut state.render, + window: &state.window, + exit: &mut self.exit, + input: &self.input, + dt: 0.0, + }; + self.app.init(&mut ctx); + self.state = Some(state); + Ok(()) + } + + fn fail(&mut self, event_loop: &ActiveEventLoop, err: anyhow::Error) { + self.error = Some(err); + event_loop.exit(); + } +} + +impl ApplicationHandler for Runner { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + // On desktop `resumed` fires once at startup; the suspend/resume + // cycle only matters on mobile, which Oxide does not target yet. + if self.state.is_none() { + if let Err(err) = self.create_window(event_loop) { + self.fail(event_loop, err); + } + } + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + let Some(state) = self.state.as_mut() else { + return; + }; + + // Fold the event into the per-frame input snapshot before any callback + // sees it, so `ctx.input()` is always up-to-date for the receiver. + // `handle_event` is a no-op for non-input events (resize, redraw, …). + self.input.handle_event(&event); + + // Engine-level handling first… + match &event { + WindowEvent::CloseRequested => self.exit = true, + WindowEvent::Resized(size) => state.render.resize(size.width, size.height), + WindowEvent::RedrawRequested => { + let now = Instant::now(); + let dt = self + .last_frame + .map_or(0.0, |last| (now - last).as_secs_f32()); + self.last_frame = Some(now); + + { + let mut ctx = AppCtx { + render: &mut state.render, + window: &state.window, + exit: &mut self.exit, + input: &self.input, + dt, + }; + self.app.update(&mut ctx); + } + // Render the frame, letting the app draw its own passes (e.g. + // editor UI) into the cleared surface via `App::render`. + let app = &mut self.app; + let result = state + .render + .render_frame_with(&state.window, |rcx| app.render(rcx)); + if let Err(err) = result { + self.fail(event_loop, err.into()); + return; + } + + // Roll edges/deltas off so the next frame starts clean. + // Held state and cursor anchor persist by design. + self.input.end_frame(); + } + _ => {} + } + + // …then forward every event raw to the app (including the ones + // handled above, so apps can observe resizes, close requests, etc.). + if !matches!(event, WindowEvent::RedrawRequested) { + let mut ctx = AppCtx { + render: &mut state.render, + window: &state.window, + exit: &mut self.exit, + input: &self.input, + dt: 0.0, + }; + self.app.event(&mut ctx, &event); + } + + if self.exit { + event_loop.exit(); + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + // Continuous rendering: request the next frame as soon as the event + // queue drains. + if let Some(state) = &self.state { + state.window.request_redraw(); + } + } +} diff --git a/examples/Cargo.toml b/examples/Cargo.toml new file mode 100644 index 0000000..597a2d7 --- /dev/null +++ b/examples/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "oxide-examples" +description = "Oxide Engine — runnable examples" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +oxide-engine = { path = "../engine" } +oxide-physics = { path = "../physics" } +oxide-script = { path = "../script" } +log.workspace = true +env_logger.workspace = true +anyhow.workspace = true + +# Each example is a standalone binary in `src/bin/`. Run with: +# cargo run -p oxide-examples --bin diff --git a/examples/src/bin/character_capsule.rs b/examples/src/bin/character_capsule.rs new file mode 100644 index 0000000..222ca25 --- /dev/null +++ b/examples/src/bin/character_capsule.rs @@ -0,0 +1,151 @@ +//! `character_capsule` — a runnable tour of the Stage 9 character controller. +//! +//! Run with: +//! ```sh +//! cargo run -p oxide-examples --bin character_capsule +//! ``` +//! +//! This example has no window or GPU dependency. It builds a floor with a low +//! step and a wall, then drives a kinematic capsule character through a scripted +//! routine — walk forward, climb the step, jump, and push into the wall — and +//! prints its position and grounded state, so move-and-slide, auto-step, +//! grounding, and jumping can be reviewed by eye. + +use oxide_engine::prelude::*; +use oxide_physics::{CharacterController, Collider, PhysicsModule, PhysicsWorld, RigidBody}; + +/// Gravity acceleration (m/s²) applied to the character's vertical velocity. +const GRAVITY: f32 = 9.81; +/// Upward speed (m/s) imparted by a jump. +const JUMP_SPEED: f32 = 4.5; + +fn main() { + env_logger::init(); + println!("Oxide physics demo — Stage 9 capsule character controller\n"); + + let mut app = App::new(); + app.add_modules(DefaultModules); + app.add_module(PhysicsModule); + + // Floor (top at y = 0.5). + spawn_static_box(&mut app, "floor", Vec3::ZERO, Vec3::new(20.0, 0.5, 20.0)); + // A 0.2 m step (top at y = 0.7) in front of the start position. + spawn_static_box( + &mut app, + "step", + Vec3::new(3.0, 0.35, 0.0), + Vec3::new(1.5, 0.35, 5.0), + ); + // A wall further along (near face at x = 7.5). + spawn_static_box( + &mut app, + "wall", + Vec3::new(8.0, 2.0, 0.0), + Vec3::new(0.5, 2.0, 5.0), + ); + + // The character: a capsule starting on the floor (rest center ≈ y 1.4). + let player = app.scene.spawn( + "player", + Transform::from_translation(Vec3::new(0.0, 1.4, 0.0)), + ); + app.scene + .world_mut() + .insert_one(player, CharacterController::default()) + .unwrap(); + + // Register the static world once so the query pipeline is populated. + app.update(1.0 / 60.0); + + println!("Driving the character (walk → climb step → jump → into wall):\n"); + println!( + " {:>5} {:>7} {:>7} {:>8} note", + "step", "x", "y", "grounded" + ); + + let dt = 1.0 / 60.0; + let mut vy = 0.0f32; // vertical velocity carried between frames + for step in 0..300 { + // Always push forward (+X); request a jump once, mid-run, while grounded. + let want_jump = step == 150; + + // Read the grounded state from the previous resolved move to decide + // jumping and gravity reset. + let grounded = drive(&mut app, player, &mut vy, 2.0, want_jump, dt); + + if step % 25 == 0 || step == 150 { + let p = app.scene.world_transform(player).unwrap().translation; + let note = match step { + 0 => "start: walking forward", + 150 => "JUMP!", + _ if !grounded => "airborne", + _ if p.x > 6.5 => "blocked by the wall", + _ if p.y > 1.5 => "up on the step", + _ => "", + }; + println!( + " {:>5} {:>7.3} {:>7.3} {:>8} {}", + step, p.x, p.y, grounded, note + ); + } + } + + let end = app.scene.world_transform(player).unwrap().translation; + println!( + "\nFinal position: x = {:.2}, y = {:.2}. The capsule walked forward, stepped", + end.x, end.y + ); + println!("up onto the ledge, jumped, and was stopped by the wall (x never passes ~7.2)."); +} + +/// One control tick: integrate gravity into `vy` (jumping if asked & grounded), +/// move the character, apply the resolved translation, and return whether it is +/// grounded afterwards. +fn drive( + app: &mut App, + player: Entity, + vy: &mut f32, + forward_speed: f32, + want_jump: bool, + dt: f32, +) -> bool { + // Integrate gravity into the vertical velocity; the controller clamps the + // resulting downward motion against the floor and reports grounded. + *vy -= GRAVITY * dt; + let desired = Vec3::new(forward_speed * dt, *vy * dt, 0.0); + + let movement = { + let world = app.get_resource::().unwrap(); + world + .move_character(&app.scene, player, desired, dt) + .unwrap() + }; + + // Apply the collision-corrected translation. + let mut t = app.scene.local_transform(player).unwrap(); + t.translation += movement.translation; + app.scene.set_local_transform(player, t); + + // On the ground: cancel downward velocity (and allow a jump this frame). + if movement.grounded && *vy < 0.0 { + *vy = 0.0; + } + if want_jump && movement.grounded { + *vy = JUMP_SPEED; + } + movement.grounded +} + +/// Spawns a static box collider (world geometry) at `pos` with `half_extents`. +fn spawn_static_box(app: &mut App, name: &str, pos: Vec3, half_extents: Vec3) -> Entity { + let e = app.scene.spawn(name, Transform::from_translation(pos)); + app.scene + .world_mut() + .insert_one(e, RigidBody::static_body()) + .unwrap(); + app.scene + .world_mut() + .insert_one(e, Collider::cuboid(half_extents)) + .unwrap(); + e +} diff --git a/examples/src/bin/hello_mesh.rs b/examples/src/bin/hello_mesh.rs new file mode 100644 index 0000000..ab0ff31 --- /dev/null +++ b/examples/src/bin/hello_mesh.rs @@ -0,0 +1,124 @@ +//! Stage 4 example: load primitive meshes and render them lit, in 3D. +//! +//! Run with: +//! cargo run -p oxide-examples --bin hello_mesh +//! +//! Shows a spinning cube, a sphere, and a ground plane drawn through the +//! [`ForwardRenderer`] with a single directional light. Esc quits. + +#![deny(warnings)] + +use oxide_engine::math::Quat; +use oxide_engine::prelude::*; +use oxide_engine::window::event::{ElementState, Key, NamedKey, WindowEvent}; +use oxide_engine::window::RenderCtx; + +/// GPU resources, built lazily on the first frame (once the surface format is +/// known) and reused thereafter. +struct Gpu3d { + pipeline: RenderPipeline, + cube: GpuMesh, + sphere: GpuMesh, + plane: GpuMesh, +} + +#[derive(Default)] +struct HelloMesh { + angle: f32, + gpu: Option, +} + +impl WindowApp for HelloMesh { + fn init(&mut self, ctx: &mut AppCtx<'_>) { + ctx.set_clear_color(Color::rgb(0.05, 0.06, 0.09)); + log::info!("hello_mesh: spinning cube + sphere + ground plane (Esc quits)"); + } + + 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<'_>) { + self.angle += ctx.dt; + } + + fn render(&mut self, ctx: &RenderCtx<'_>) { + let device = ctx.gpu.device(); + let queue = ctx.gpu.queue(); + + let gpu = self.gpu.get_or_insert_with(|| { + // The window runner already clears the surface to the configured + // clear color before `render`, so the viewport pipeline is just the + // forward pass (no clear pass needed here). + let mut pipeline = RenderPipeline::new(); + pipeline.add_pass("forward", ForwardPass::new(device, ctx.surface_format)); + Gpu3d { + pipeline, + cube: Mesh::cube().upload(device, "cube"), + sphere: Mesh::uv_sphere(0.8, 32, 16).upload(device, "sphere"), + plane: Mesh::plane(12.0).upload(device, "plane"), + } + }); + + // Orbit the camera slowly around the scene. + let eye = Vec3::new( + 4.0 * (self.angle * 0.3).cos(), + 2.6, + 4.0 * (self.angle * 0.3).sin(), + ); + let view = Transform::looking_at(eye, Vec3::new(0.0, 0.2, 0.0), Vec3::Y); + let camera = Camera::default(); + + let objects = [ + RenderObject { + mesh: &gpu.plane, + material: Material::diffuse(Color::rgb(0.25, 0.27, 0.30)), + transform: Transform::from_translation(Vec3::new(0.0, -1.0, 0.0)), + }, + RenderObject { + mesh: &gpu.cube, + material: Material::diffuse(Color::rgb(0.85, 0.20, 0.15)), + transform: Transform::from_trs( + Vec3::new(-1.3, 0.0, 0.0), + Quat::from_euler(oxide_engine::math::EulerRot::YXZ, self.angle, 0.4, 0.0), + Vec3::ONE, + ), + }, + RenderObject { + mesh: &gpu.sphere, + material: Material::metal(Color::rgb(0.55, 0.65, 0.95), 0.35), + transform: Transform::from_translation(Vec3::new(1.3, 0.2, 0.0)), + }, + ]; + + let lighting = Lighting::default(); + gpu.pipeline.render(&mut FrameContext { + device, + queue, + color: ctx.view, + size: ctx.size, + viewport_rect: None, + clear_color: Color::rgb(0.05, 0.06, 0.09), + camera: &camera, + view_transform: &view, + lighting: &lighting, + objects: &objects, + }); + } +} + +fn main() -> anyhow::Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let config = WindowConfig { + title: "Oxide — hello_mesh".to_string(), + width: 960, + height: 540, + ..Default::default() + }; + run(config, HelloMesh::default()) +} diff --git a/examples/src/bin/hello_window.rs b/examples/src/bin/hello_window.rs new file mode 100644 index 0000000..2b15cdf --- /dev/null +++ b/examples/src/bin/hello_window.rs @@ -0,0 +1,92 @@ +//! Stage 2 example: open a window, clear it to a configurable color. +//! +//! Run with: +//! cargo run -p oxide-examples --bin hello_window +//! +//! Controls: +//! 1–5 select a preset clear color +//! Space cycle to the next preset +//! Esc quit +//! +//! Average FPS is logged once per second, which makes the "stable 60+ FPS" +//! test criterion observable from the terminal. + +#![deny(warnings)] + +use oxide_engine::prelude::*; +use oxide_engine::window::event::Key; +use oxide_engine::window::event::{ElementState, KeyCode, NamedKey, PhysicalKey, WindowEvent}; + +const PRESETS: [(&str, Color); 5] = [ + ("cornflower blue", Color::rgba(0.39, 0.58, 0.93, 1.0)), + ("oxide red", Color::rgba(0.55, 0.15, 0.08, 1.0)), + ("forest green", Color::rgba(0.05, 0.35, 0.12, 1.0)), + ("near black", Color::rgba(0.02, 0.02, 0.03, 1.0)), + ("white", Color::WHITE), +]; + +#[derive(Default)] +struct HelloWindow { + preset: usize, + frames: u32, + elapsed: f32, +} + +impl HelloWindow { + fn apply_preset(&mut self, ctx: &mut AppCtx<'_>, index: usize) { + self.preset = index % PRESETS.len(); + let (name, color) = PRESETS[self.preset]; + ctx.set_clear_color(color); + log::info!("clear color {} — {name}", self.preset + 1); + } +} + +impl WindowApp for HelloWindow { + fn init(&mut self, ctx: &mut AppCtx<'_>) { + self.apply_preset(ctx, 0); + log::info!("press 1–5 to pick a color, Space to cycle, Esc to quit"); + } + + fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) { + let WindowEvent::KeyboardInput { event: key, .. } = event else { + return; + }; + if key.state != ElementState::Pressed { + return; + } + if key.logical_key == Key::Named(NamedKey::Escape) { + ctx.request_exit(); + return; + } + match key.physical_key { + PhysicalKey::Code(KeyCode::Digit1) => self.apply_preset(ctx, 0), + PhysicalKey::Code(KeyCode::Digit2) => self.apply_preset(ctx, 1), + PhysicalKey::Code(KeyCode::Digit3) => self.apply_preset(ctx, 2), + PhysicalKey::Code(KeyCode::Digit4) => self.apply_preset(ctx, 3), + PhysicalKey::Code(KeyCode::Digit5) => self.apply_preset(ctx, 4), + PhysicalKey::Code(KeyCode::Space) => self.apply_preset(ctx, self.preset + 1), + _ => {} + } + } + + fn update(&mut self, ctx: &mut AppCtx<'_>) { + self.frames += 1; + self.elapsed += ctx.dt; + if self.elapsed >= 1.0 { + log::info!("{:.0} FPS", self.frames as f32 / self.elapsed); + self.frames = 0; + self.elapsed = 0.0; + } + } +} + +fn main() -> anyhow::Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let config = WindowConfig { + title: "Oxide — hello_window".to_string(), + width: 960, + height: 540, + ..Default::default() + }; + run(config, HelloWindow::default()) +} diff --git a/examples/src/bin/math_demo.rs b/examples/src/bin/math_demo.rs new file mode 100644 index 0000000..83a19be --- /dev/null +++ b/examples/src/bin/math_demo.rs @@ -0,0 +1,83 @@ +//! `math_demo` — a runnable tour of the Stage 1 math primitives. +//! +//! Run with: +//! ```sh +//! cargo run -p oxide-examples --bin math_demo +//! ``` +//! +//! This example has no window or GPU dependency; it simply exercises the math +//! API and prints results so the foundation can be reviewed by eye. + +use oxide_engine::prelude::*; + +fn main() { + env_logger::init(); + log::info!("Oxide math demo — Stage 1 primitives"); + + // --- Transform hierarchy (parent * child) --- + 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); + + println!("== Transform =="); + println!("parent translation : {}", parent.translation); + println!("child local pos : {}", child_local.translation); + println!("child world pos : {}", child_world.translation); + println!("parent forward : {}", parent.forward()); + println!( + "round-trip inverse : {}", + parent.mul_transform(&parent.inverse()).translation + ); + + // --- Bounding box + ray pick --- + let bounds = Aabb::from_points([ + Vec3::new(-1.0, -1.0, -1.0), + Vec3::new(1.0, 2.0, 1.0), + Vec3::new(0.5, 0.5, 3.0), + ]); + let ray = Ray::new(Vec3::new(0.0, 0.0, -10.0), Vec3::Z); + println!("\n== AABB / Ray =="); + println!("bounds center : {}", bounds.center()); + println!("bounds size : {}", bounds.size()); + match bounds.ray_intersection(&ray) { + Some(t) => println!("ray hit at t={t:.3}, point={}", ray.at(t)), + None => println!("ray missed the bounds"), + } + + // --- Frustum culling --- + 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 in_view = Aabb::from_center_half_extents(Vec3::ZERO, Vec3::splat(1.0)); + let off_screen = Aabb::from_center_half_extents(Vec3::new(0.0, 0.0, 100.0), Vec3::splat(1.0)); + println!("\n== Frustum =="); + println!( + "box at origin visible : {}", + frustum.intersects_aabb(&in_view) + ); + println!( + "box behind camera : {}", + frustum.intersects_aabb(&off_screen) + ); + + // --- Color + value remap --- + let sky = Color::from_hex(0x87CEEB); + let ground = Color::from_hex(0x3A2E1F); + let blended = sky.lerp(ground, 0.5); + println!("\n== Color =="); + println!("sky (linear) : {:?}", sky.to_vec3()); + println!("blended sRGB bytes : {:?}", blended.to_srgb_u8()); + + let value_range = Range3::new(Vec3::ZERO, Vec3::splat(100.0)); + println!("\n== Range3 =="); + println!( + "remap 25 → unit : {}", + value_range.inverse_lerp(Vec3::splat(25.0)) + ); + + log::info!("math demo complete"); +} diff --git a/examples/src/bin/physics_stack.rs b/examples/src/bin/physics_stack.rs new file mode 100644 index 0000000..a944902 --- /dev/null +++ b/examples/src/bin/physics_stack.rs @@ -0,0 +1,105 @@ +//! `physics_stack` — a runnable tour of the Stage 9 rigid-body simulation. +//! +//! Run with: +//! ```sh +//! cargo run -p oxide-examples --bin physics_stack +//! ``` +//! +//! This example has no window or GPU dependency. It builds a static floor and a +//! stack of dynamic boxes, steps the physics simulation at a fixed 60 Hz, and +//! prints the boxes' heights over time — so you can see them settle into a +//! stable stack (and a dropped ball land and come to rest) by eye. + +use oxide_engine::prelude::*; +use oxide_physics::{Collider, PhysicsModule, PhysicsWorld, RigidBody}; + +fn main() { + env_logger::init(); + println!("Oxide physics demo — Stage 9 rigid-body stack\n"); + + let mut app = App::new(); + app.add_modules(DefaultModules); + app.add_module(PhysicsModule); + + // A static ground plane (a wide, thin box; its top surface is at y = 0.5). + let floor = app + .scene + .spawn("floor", Transform::from_translation(Vec3::ZERO)); + app.scene + .world_mut() + .insert_one(floor, RigidBody::static_body()) + .unwrap(); + app.scene + .world_mut() + .insert_one(floor, Collider::cuboid(Vec3::new(10.0, 0.5, 10.0))) + .unwrap(); + + // A stack of three unit boxes, each starting a little above its rest height + // so they drop and settle onto one another. + let mut boxes = Vec::new(); + for i in 0..3 { + let y = 1.2 + i as f32 * 1.05; + let e = app.scene.spawn( + format!("box{i}"), + Transform::from_translation(Vec3::new(0.0, y, 0.0)), + ); + app.scene + .world_mut() + .insert_one(e, RigidBody::default()) + .unwrap(); + app.scene + .world_mut() + .insert_one(e, Collider::cuboid(Vec3::splat(0.5))) + .unwrap(); + boxes.push(e); + } + + // A ball dropped from higher up, to land on the top box. + let ball = app.scene.spawn( + "ball", + Transform::from_translation(Vec3::new(0.0, 6.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(); + + println!("Stepping the simulation at 60 Hz (boxes start stacked, ball falls in):\n"); + println!( + " {:>5} {:>7} {:>7} {:>7} {:>7}", + "step", "box0", "box1", "box2", "ball" + ); + + let dt = 1.0 / 60.0; + for step in 0..=180 { + if step % 20 == 0 { + let ys: Vec = boxes.iter().map(|&e| height(&app, e)).collect(); + println!( + " {:>5} {:>7.3} {:>7.3} {:>7.3} {:>7.3}", + step, + ys[0], + ys[1], + ys[2], + height(&app, ball) + ); + } + app.update(dt); + } + + // Report the resting state. + let world = app.get_resource::().unwrap(); + let resting = boxes + .iter() + .all(|&e| world.linear_velocity(e).length() < 0.05); + println!("\nAfter ~3 s: boxes rest near y ≈ 1.0 / 2.0 / 3.0, all settled = {resting}."); + println!("The stack stays standing — stable contact, no jitter or explosion."); +} + +/// The current world-space height (y) of an entity. +fn height(app: &App, e: Entity) -> f32 { + app.scene.world_transform(e).unwrap().translation.y +} diff --git a/examples/src/bin/scene_basic.rs b/examples/src/bin/scene_basic.rs new file mode 100644 index 0000000..0ec035e --- /dev/null +++ b/examples/src/bin/scene_basic.rs @@ -0,0 +1,111 @@ +//! `scene_basic` — a runnable tour of the Stage 3 scene graph. +//! +//! Run with: +//! ```sh +//! cargo run -p oxide-examples --bin scene_basic +//! ``` +//! +//! This example has no window or GPU dependency. It builds a small entity +//! hierarchy, prints each node's local and resolved world transform, reparents +//! a node, and round-trips the whole scene through RON — so the Stage 3 scene +//! API can be reviewed by eye. + +use oxide_engine::prelude::*; +use oxide_engine::scene::DespawnPolicy; + +fn main() { + env_logger::init(); + println!("Oxide scene demo — Stage 3 scene graph\n"); + + let mut scene = Scene::new(); + + // A little solar-system-ish hierarchy: sun → planet → moon, plus a probe. + let sun = scene.spawn("sun", Transform::from_translation(Vec3::new(0.0, 0.0, 0.0))); + let planet = scene.spawn_child( + sun, + "planet", + Transform::from_trs( + Vec3::new(10.0, 0.0, 0.0), + Quat::from_rotation_y(90_f32.to_radians()), + Vec3::ONE, + ), + ); + let moon = scene.spawn_child( + planet, + "moon", + Transform::from_translation(Vec3::new(0.0, 0.0, 2.0)), + ); + let probe = scene.spawn_child(planet, "probe", Transform::from_translation(Vec3::Y)); + + println!("== Hierarchy & world transforms =="); + print_tree(&scene); + + // World transforms compose down the chain: the moon inherits the planet's + // rotation, so its local +Z offset lands along the world axes accordingly. + let moon_world = scene.world_transform(moon).unwrap(); + println!( + "\nmoon local pos : {}", + scene.local_transform(moon).unwrap().translation + ); + println!("moon world pos : {}", moon_world.translation); + + // Reparent the probe directly under the sun and observe its world transform + // change (its local transform is preserved). + println!("\n== Reparent probe: planet → sun =="); + scene.set_parent(probe, Some(sun)).unwrap(); + println!( + "probe world pos: {}", + scene.world_transform(probe).unwrap().translation + ); + + // Disable a node (later systems will skip disabled subtrees). + scene.set_enabled(moon, false); + println!( + "\nmoon enabled? : {}", + scene.is_enabled(moon).unwrap_or(true) + ); + + // Serialize → deserialize round-trip. + println!("\n== RON serialization =="); + let ron = scene.to_ron().expect("serialize"); + println!("{ron}"); + let restored = Scene::from_ron(&ron).expect("deserialize"); + println!( + "restored {} entities, {} roots", + restored.len(), + restored.roots().len() + ); + + // Despawn the planet, detaching its children up to its parent. + println!("\n== Despawn planet (detach children) =="); + scene.despawn(planet, DespawnPolicy::DetachChildren); + print_tree(&scene); +} + +/// Prints the scene as an indented tree with each node's world position. +fn print_tree(scene: &Scene) { + let worlds = scene.world_transforms(); + for &root in scene.roots() { + print_node(scene, root, 0, &worlds); + } +} + +fn print_node( + scene: &Scene, + entity: oxide_engine::scene::Entity, + depth: usize, + worlds: &std::collections::HashMap, +) { + let indent = " ".repeat(depth); + let name = scene.name(entity).unwrap_or_default(); + let enabled = scene.is_enabled(entity).unwrap_or(true); + let world = worlds + .get(&entity) + .map(|t| t.translation) + .unwrap_or(Vec3::ZERO); + let tag = if enabled { "" } else { " (disabled)" }; + println!("{indent}- {name}{tag} world={world}"); + for &child in scene.children(entity) { + print_node(scene, child, depth + 1, worlds); + } +} diff --git a/examples/src/bin/script_spin.rs b/examples/src/bin/script_spin.rs new file mode 100644 index 0000000..03015c1 --- /dev/null +++ b/examples/src/bin/script_spin.rs @@ -0,0 +1,161 @@ +//! `script_spin` — a runnable tour of the Stage 10 scripting + live-reload layer. +//! +//! Run with: +//! ```sh +//! cargo run -p oxide-examples --bin script_spin +//! ``` +//! +//! This example has no window or GPU dependency. It writes a tiny `.rhai` script +//! that rotates an entity around Y every frame, attaches it via a [`Script`] +//! component, and steps the app — printing the entity's yaw so you can watch it +//! spin. Then, **without restarting**, it rewrites the script on disk to spin +//! three times faster and reloads it the way the editor's file watcher does; the +//! spin rate visibly jumps while the entity keeps its current orientation. That +//! is the Stage 10 live-reload promise in a headless harness. + +use std::path::PathBuf; + +use oxide_engine::prelude::*; +use oxide_engine::watch::{reload_changed_assets, ChangeEvent, ChangeKind}; +use oxide_script::{Script, ScriptModule}; + +/// The initial script: a slow spin around Y, proportional to the frame delta. +const SLOW_SPIN: &str = r#" +// Rotate this entity around Y. `dt` is the frame time in seconds. +let speed = 1.0; // radians/second + +fn init() { + print("spin script started"); +} + +fn update(dt) { + rotate_y(dt * 1.0); +} +"#; + +/// The live edit: the same script, spinning three times faster. +const FAST_SPIN: &str = r#" +let speed = 3.0; // radians/second + +fn update(dt) { + rotate_y(dt * 3.0); +} +"#; + +fn main() { + env_logger::init(); + println!("Oxide scripting demo — Stage 10 live-reloaded spin\n"); + + // A throwaway project directory holding `assets/scripts/spin.rhai`. + let project = TempProject::new(); + let rel = "scripts/spin.rhai"; + project.write(rel, SLOW_SPIN); + println!("wrote {rel}:\n{}", indent(SLOW_SPIN)); + + // The asset database maps the file to a stable uid the Script references. + let mut db = AssetDatabase::new(project.root()); + let uid = db.register(rel); + + let mut app = App::new(); + app.add_modules(DefaultModules); + app.add_module(ScriptModule); + app.insert_resource(db); + + // One entity that runs the script. + let spinner = app.scene.spawn("spinner", Transform::IDENTITY); + app.scene + .world_mut() + .insert_one(spinner, Script::new(AssetRef::new(uid))) + .unwrap(); + + let dt = 1.0 / 60.0; + println!("\n-- spinning (script: 1.0 rad/s) --"); + run_seconds(&mut app, spinner, dt, 1.0); + + // Edit the script live: faster spin, no restart. This is exactly what the + // editor does when the watcher sees the file change. + project.write(rel, FAST_SPIN); + let abs = project.abs(rel); + let reloaded = reload_changed_assets( + &app.assets, + [ChangeEvent { + path: abs, + kind: ChangeKind::Modified, + }], + ); + println!("\n>> edited spin.rhai live (reloaded {reloaded} asset) -> 3.0 rad/s\n"); + + println!("-- spinning (script: 3.0 rad/s, orientation preserved) --"); + run_seconds(&mut app, spinner, dt, 1.0); + + let final_yaw = yaw_of(&app, spinner); + println!("\nfinal yaw = {final_yaw:.2} rad — the rate jumped without a restart."); +} + +/// Steps the app for `seconds` of fixed `dt` frames, printing the spinner's yaw +/// roughly ten times so the rotation is visible. +fn run_seconds(app: &mut App, entity: Entity, dt: f32, seconds: f32) { + let frames = (seconds / dt).round() as u32; + let every = (frames / 10).max(1); + for frame in 1..=frames { + app.update(dt); + if frame % every == 0 { + println!( + " t = {:.2}s yaw = {:.3} rad", + frame as f32 * dt, + yaw_of(app, entity) + ); + } + } +} + +/// The entity's yaw (rotation about Y) in radians. +fn yaw_of(app: &App, entity: Entity) -> f32 { + app.scene + .local_transform(entity) + .map(|t| t.rotation.to_euler(EulerRot::YXZ).0) + .unwrap_or(0.0) +} + +/// Indents a block of text two spaces for tidy console output. +fn indent(text: &str) -> String { + text.trim_matches('\n') + .lines() + .map(|l| format!(" {l}")) + .collect::>() + .join("\n") +} + +/// A throwaway project directory, removed on drop. +struct TempProject { + root: PathBuf, +} + +impl TempProject { + fn new() -> Self { + let mut root = std::env::temp_dir(); + root.push(format!("oxide-script-spin-{}", std::process::id())); + std::fs::create_dir_all(root.join("assets")).expect("create temp project"); + Self { root } + } + + fn root(&self) -> &std::path::Path { + &self.root + } + + fn abs(&self, relative: &str) -> PathBuf { + self.root.join("assets").join(relative) + } + + fn write(&self, relative: &str, contents: &str) { + let path = self.abs(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); + } +} + +impl Drop for TempProject { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} diff --git a/examples/src/bin/ui_hud.rs b/examples/src/bin/ui_hud.rs new file mode 100644 index 0000000..c8a3c72 --- /dev/null +++ b/examples/src/bin/ui_hud.rs @@ -0,0 +1,416 @@ +//! Stage 8 example: a game HUD drawn on top of a live 3D scene. +//! +//! Run with: +//! cargo run -p oxide-examples --bin ui_hud +//! +//! Composites two Stage-8 ingredients in one frame: +//! +//! - The Stage-4 [`ForwardPass`] renders a spinning cube + sphere + ground +//! plane (the same scene as `hello_mesh`). +//! - A screen-space [`UiOverlayPass`] then draws a HUD *on top* of it. Both +//! passes load (never clear) the surface the window runner already cleared, +//! so the overlay composites over the 3D image with alpha blending. +//! +//! HUD layout (each corner is an [`UiAnchor`]-pinned widget): +//! +//! - **Top-left** — `HP: NN`, oscillating down then back up; turns red when low. +//! - **Top-right** — `Ammo: NN`, counting down as if firing, reloading at 0. +//! - **Bottom-left** — a 100×100 minimap stand-in with a dot orbiting in sync +//! with the camera. +//! - **Centre** — a crosshair (two thin bars) — the "target indicator". +//! +//! The animated digits exist to demonstrate the glyph atlas's dirty-flag +//! caching: once each of `0`–`9` (plus the static label text) has been +//! rasterized once, the atlas stops growing and every later frame is a 100% +//! cache hit. The example logs each atlas growth and the moment it reaches +//! steady state. Esc quits. + +#![deny(warnings)] + +use oxide_engine::math::Quat; +use oxide_engine::prelude::*; +use oxide_engine::ui::text::{common_system_font_paths, Font}; +use oxide_engine::window::event::{ElementState, Key, NamedKey, WindowEvent}; +use oxide_engine::window::RenderCtx; + +/// Frames the atlas must stay the same size before we declare steady state. +const STEADY_FRAMES: u32 = 60; + +struct UiHud { + /// Drives both the camera orbit and the minimap dot. + angle: f32, + /// Seconds elapsed — drives the HP / Ammo animations. + clock: f32, + theme: UiTheme, + font_descriptor: UiFontRef, + gpu: Option, + + // --- atlas diagnostics --- + /// Glyph count observed on the previous frame; a change means the atlas grew. + prev_glyph_count: usize, + /// Consecutive frames the glyph count has held steady. + steady_frames: u32, + /// Set once we have logged the steady-state message, so it logs only once. + steady_logged: bool, +} + +struct GpuState { + /// Forward 3D pass + its depth buffer, wrapped in a pipeline. + pipeline: RenderPipeline, + ui_pass: UiOverlayPass, + cube: GpuMesh, + sphere: GpuMesh, + plane: GpuMesh, +} + +impl Default for UiHud { + fn default() -> Self { + Self { + angle: 0.0, + clock: 0.0, + theme: build_theme(), + font_descriptor: UiFontRef::regular("System"), + gpu: None, + prev_glyph_count: 0, + steady_frames: 0, + steady_logged: false, + } + } +} + +fn build_theme() -> UiTheme { + let descriptor = UiFontRef::regular("System"); + UiTheme::new() + .with_default(UiVisualStyle { + foreground: Some(Color::WHITE), + font: Some(descriptor.clone()), + font_size: Some(20.0), + ..UiVisualStyle::EMPTY + }) + // Semi-opaque chips behind the text so it stays legible over any part + // of the 3D scene. + .with_style( + "chip", + UiVisualStyle { + background: Some(Color::rgba(0.05, 0.06, 0.08, 0.55)), + ..UiVisualStyle::EMPTY + }, + ) + .with_style( + "minimap", + UiVisualStyle { + background: Some(Color::rgba(0.05, 0.07, 0.10, 0.65)), + ..UiVisualStyle::EMPTY + }, + ) + .with_style( + "minimap-dot", + UiVisualStyle { + background: Some(Color::rgb(0.30, 0.85, 0.45)), + ..UiVisualStyle::EMPTY + }, + ) + .with_style( + "crosshair", + UiVisualStyle { + background: Some(Color::rgba(0.95, 0.95, 0.95, 0.85)), + ..UiVisualStyle::EMPTY + }, + ) +} + +/// A corner-pinned label chip of fixed size, holding a single line of text. +fn corner_chip(text: &str, low: bool, anchor: UiAnchor) -> Widget { + let mut leaf = Widget::leaf(Vec2::ZERO) + .with_text(text) + .with_theme_style("chip") + .with_style(UiLayoutStyle { + anchor, + padding: UiInsets::symmetric(12.0, 8.0), + align_horizontal: UiAlign::Start, + align_vertical: UiAlign::Center, + ..Default::default() + }); + if low { + leaf = leaf.with_visual(UiVisualStyle { + foreground: Some(Color::rgb(0.95, 0.30, 0.25)), + ..UiVisualStyle::EMPTY + }); + } + leaf +} + +/// The full HUD document for the given gameplay values. +/// +/// `margin` insets every corner from the screen edge. `dot` is the minimap +/// dot's position as a 0..1 fraction of the minimap panel. +fn build_hud(hp: i32, ammo: i32, dot: Vec2) -> Widget { + const M: f32 = 16.0; // edge margin + const CHIP_W: f32 = 150.0; + const CHIP_H: f32 = 36.0; + const MAP: f32 = 100.0; + const CROSS: f32 = 22.0; + const BAR: f32 = 2.0; + + // Top-left HP chip pinned to the top-left corner. + let hp_chip = corner_chip( + &format!("HP: {hp}"), + hp < 30, + UiAnchor::TOP_LEFT.with_offsets(Vec2::new(M, M), Vec2::new(M + CHIP_W, M + CHIP_H)), + ); + + // Top-right Ammo chip pinned to the top-right corner. + let ammo_chip = corner_chip( + &format!("Ammo: {ammo}"), + ammo == 0, + UiAnchor::TOP_RIGHT.with_offsets(Vec2::new(-M - CHIP_W, M), Vec2::new(-M, M + CHIP_H)), + ); + + // Bottom-left minimap: a solid panel with a single orbiting dot. + let minimap = Widget::anchor() + .with_theme_style("minimap") + .with_style(UiLayoutStyle { + anchor: UiAnchor::BOTTOM_LEFT + .with_offsets(Vec2::new(M, -M - MAP), Vec2::new(M + MAP, -M)), + ..Default::default() + }) + .with_child( + Widget::leaf(Vec2::ZERO) + .with_theme_style("minimap-dot") + .with_style(UiLayoutStyle { + anchor: UiAnchor::between(dot, dot) + .with_offsets(Vec2::splat(-4.0), Vec2::splat(4.0)), + ..Default::default() + }), + ); + + // Centre crosshair: a horizontal + vertical bar crossing at screen centre. + let crosshair = Widget::anchor() + .with_style(UiLayoutStyle { + anchor: UiAnchor::between(Vec2::splat(0.5), Vec2::splat(0.5)) + .with_offsets(Vec2::splat(-CROSS * 0.5), Vec2::splat(CROSS * 0.5)), + ..Default::default() + }) + .with_child( + // Horizontal bar. + Widget::leaf(Vec2::ZERO) + .with_theme_style("crosshair") + .with_style(UiLayoutStyle { + anchor: UiAnchor::between(Vec2::new(0.0, 0.5), Vec2::new(1.0, 0.5)) + .with_offsets(Vec2::new(0.0, -BAR * 0.5), Vec2::new(0.0, BAR * 0.5)), + ..Default::default() + }), + ) + .with_child( + // Vertical bar. + Widget::leaf(Vec2::ZERO) + .with_theme_style("crosshair") + .with_style(UiLayoutStyle { + anchor: UiAnchor::between(Vec2::new(0.5, 0.0), Vec2::new(0.5, 1.0)) + .with_offsets(Vec2::new(-BAR * 0.5, 0.0), Vec2::new(BAR * 0.5, 0.0)), + ..Default::default() + }), + ); + + Widget::anchor() + .with_style(UiLayoutStyle { + width: UiSizing::Grow(1.0), + height: UiSizing::Grow(1.0), + ..Default::default() + }) + .with_child(hp_chip) + .with_child(ammo_chip) + .with_child(minimap) + .with_child(crosshair) +} + +impl UiHud { + /// Health oscillates 10..100 (down then up), as if taking damage and healing. + fn hp(&self) -> i32 { + (55.0 + 45.0 * (self.clock * 0.8).sin()).round() as i32 + } + + /// Ammo counts 30 → 0 (one round every 0.25 s), reloading back to 30. + fn ammo(&self) -> i32 { + let fired = (self.clock / 0.25) as i32 % 31; + 30 - fired + } + + /// Minimap dot position as a 0..1 fraction, orbiting with the camera. + fn minimap_dot(&self) -> Vec2 { + Vec2::new(0.5 + 0.34 * self.angle.cos(), 0.5 + 0.34 * self.angle.sin()) + } + + fn hud_document(&self) -> Widget { + build_hud(self.hp(), self.ammo(), self.minimap_dot()) + } +} + +impl WindowApp for UiHud { + fn init(&mut self, ctx: &mut AppCtx<'_>) { + ctx.set_clear_color(Color::rgb(0.05, 0.06, 0.09)); + log::info!("ui_hud: 3D scene + HUD overlay (Esc quits)"); + } + + 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<'_>) { + self.angle += ctx.dt * 0.6; + self.clock += ctx.dt; + } + + fn render(&mut self, ctx: &RenderCtx<'_>) { + let device = ctx.gpu.device(); + let queue = ctx.gpu.queue(); + + // Lazy-init GPU resources once the surface format is known. + if self.gpu.is_none() { + let mut pipeline = RenderPipeline::new(); + pipeline.add_pass("forward", ForwardPass::new(device, ctx.surface_format)); + + let mut ui_pass = UiOverlayPass::new(device, ctx.surface_format); + match common_system_font_paths() + .iter() + .find_map(|p| Font::from_path(p).ok()) + { + Some(f) => { + ui_pass + .fonts_mut() + .insert_with_descriptor(self.font_descriptor.clone(), f); + } + None => log::error!( + "No system sans-serif font found in any of {:?}. Install \ + 'liberation-fonts' or 'dejavu-sans' and re-run.", + common_system_font_paths() + ), + } + + self.gpu = Some(GpuState { + pipeline, + ui_pass, + cube: Mesh::cube().upload(device, "cube"), + sphere: Mesh::uv_sphere(0.8, 32, 16).upload(device, "sphere"), + plane: Mesh::plane(12.0).upload(device, "plane"), + }); + } + + let (w, h) = ctx.size; + + // Build the HUD (needs &self) and paint it (needs the pass's fonts via + // an immutable borrow) before taking the &mut borrow on `self.gpu` — + // the same ordering dance `ui_menu` uses. + let viewport = + oxide_engine::math::Rect::from_min_size(Vec2::ZERO, Vec2::new(w as f32, h as f32)); + let document = self.hud_document(); + let tree = ui_layout(&document, viewport, 1.0); + let painted = ui_paint( + &document, + &tree, + &self.theme, + self.gpu.as_ref().unwrap().ui_pass.fonts(), + 1.0, + ); + + let gpu = self.gpu.as_mut().unwrap(); + gpu.ui_pass + .set_batches(vec![UiBatch::screen_space(painted, (w, h))]); + + // Orbit the camera around the scene (same as hello_mesh). + let eye = Vec3::new(4.0 * self.angle.cos(), 2.6, 4.0 * self.angle.sin()); + let view = Transform::looking_at(eye, Vec3::new(0.0, 0.2, 0.0), Vec3::Y); + let camera = Camera::default(); + + let objects = [ + RenderObject { + mesh: &gpu.plane, + material: Material::diffuse(Color::rgb(0.25, 0.27, 0.30)), + transform: Transform::from_translation(Vec3::new(0.0, -1.0, 0.0)), + }, + RenderObject { + mesh: &gpu.cube, + material: Material::diffuse(Color::rgb(0.85, 0.20, 0.15)), + transform: Transform::from_trs( + Vec3::new(-1.3, 0.0, 0.0), + Quat::from_euler( + oxide_engine::math::EulerRot::YXZ, + self.angle * 2.0, + 0.4, + 0.0, + ), + Vec3::ONE, + ), + }, + RenderObject { + mesh: &gpu.sphere, + material: Material::metal(Color::rgb(0.55, 0.65, 0.95), 0.35), + transform: Transform::from_translation(Vec3::new(1.3, 0.2, 0.0)), + }, + ]; + + let lighting = Lighting::default(); + let mut frame = FrameContext { + device, + queue, + color: ctx.view, + size: ctx.size, + viewport_rect: None, + clear_color: Color::rgb(0.05, 0.06, 0.09), + camera: &camera, + view_transform: &view, + lighting: &lighting, + objects: &objects, + }; + + // 3D first, then the HUD overlay on top — both load the surface the + // window runner already cleared. + gpu.pipeline.render(&mut frame); + gpu.ui_pass.run(&mut frame); + + let glyph_count = gpu.ui_pass.atlas_glyph_count(); + // End the &mut borrow on self.gpu before touching the diagnostics fields. + let _ = gpu; + self.report_atlas(glyph_count); + } +} + +impl UiHud { + /// Log atlas growth and the moment it reaches steady state, proving the + /// HUD's animated digits become 100% cache hits. + fn report_atlas(&mut self, glyph_count: usize) { + if glyph_count != self.prev_glyph_count { + log::info!( + "ui_hud: glyph atlas grew to {glyph_count} glyphs (new digit/char rasterized)" + ); + self.prev_glyph_count = glyph_count; + self.steady_frames = 0; + self.steady_logged = false; + return; + } + self.steady_frames += 1; + if self.steady_frames == STEADY_FRAMES && !self.steady_logged { + log::info!( + "ui_hud: atlas steady at {glyph_count} glyphs for {STEADY_FRAMES} frames — \ + every subsequent frame is a 100% cache hit (no rasterize, no re-upload)" + ); + self.steady_logged = true; + } + } +} + +fn main() -> anyhow::Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let config = WindowConfig { + title: "Oxide — ui_hud".to_string(), + width: 960, + height: 540, + ..Default::default() + }; + run(config, UiHud::default()) +} diff --git a/examples/src/bin/ui_menu.rs b/examples/src/bin/ui_menu.rs new file mode 100644 index 0000000..2db9985 --- /dev/null +++ b/examples/src/bin/ui_menu.rs @@ -0,0 +1,495 @@ +//! Stage 8 example: main menu + settings panel built with the engine's UI +//! system. +//! +//! Run with: +//! cargo run -p oxide-examples --bin ui_menu +//! +//! Walks the full Stage-8 pipeline: +//! +//! - **Pieces 1–2** — widget tree, layout, themed visual style. +//! - **Piece 3** — text shaping + glyph atlas (loads a system font). +//! - **Piece 4a** — screen-space `UiOverlayPass` renders quads + glyphs. +//! - **Piece 5** — `Router` hit-tests cursor + tracks hover/press. +//! - **Piece 6** — immediate-mode `frame.clicked_left(...)` queries + typed +//! `WidgetValue`s for the volume slider and invert-Y checkbox. +//! +//! Main menu has Play (logs), Settings (navigates), Quit (exits). The +//! settings panel has a draggable volume slider, a clickable invert-Y +//! checkbox, and a Back button. + +#![deny(warnings)] + +use oxide_engine::prelude::*; +use oxide_engine::ui::text::{common_system_font_paths, Font}; +use oxide_engine::window::event::{ElementState, Key, NamedKey, WindowEvent}; +use oxide_engine::window::RenderCtx; +use oxide_engine::winit::event::MouseButton; + +#[derive(Clone, Copy, PartialEq)] +enum Screen { + Main, + Settings, +} + +struct UiMenu { + screen: Screen, + volume: f32, + invert_y: bool, + /// True while the user is dragging the volume slider — set on + /// `Pressed("volume", Left)`, cleared on release. While true the + /// slider tracks cursor.x clamped to the track even if the cursor + /// drifts outside the rect (standard "drag capture" behavior). + dragging_volume: bool, + router: UiRouter, + theme: UiTheme, + main_font_descriptor: UiFontRef, + /// Built on the first frame once the surface format is known. + gpu: Option, +} + +struct GpuState { + ui_pass: UiOverlayPass, +} + +impl Default for UiMenu { + fn default() -> Self { + Self { + screen: Screen::Main, + volume: 0.6, + invert_y: false, + dragging_volume: false, + router: UiRouter::new(), + theme: build_theme(), + main_font_descriptor: UiFontRef::regular("System"), + gpu: None, + } + } +} + +fn build_theme() -> UiTheme { + let descriptor = UiFontRef::regular("System"); + UiTheme::new() + .with_default(UiVisualStyle { + foreground: Some(Color::WHITE), + font: Some(descriptor.clone()), + font_size: Some(16.0), + ..UiVisualStyle::EMPTY + }) + .with_style( + "panel", + UiVisualStyle { + background: Some(Color::rgb(0.15, 0.16, 0.18)), + ..UiVisualStyle::EMPTY + }, + ) + .with_style( + "button", + UiVisualStyle { + background: Some(Color::rgb(0.25, 0.27, 0.30)), + foreground: Some(Color::WHITE), + font: Some(descriptor.clone()), + font_size: Some(16.0), + ..UiVisualStyle::EMPTY + }, + ) + .with_style( + "button-hover", + UiVisualStyle { + background: Some(Color::rgb(0.35, 0.37, 0.40)), + ..UiVisualStyle::EMPTY + }, + ) + .with_style( + "button-primary", + UiVisualStyle { + background: Some(Color::rgb(0.20, 0.45, 0.80)), + foreground: Some(Color::WHITE), + ..UiVisualStyle::EMPTY + }, + ) + .with_style( + "track", + UiVisualStyle { + background: Some(Color::rgb(0.10, 0.11, 0.13)), + ..UiVisualStyle::EMPTY + }, + ) + .with_style( + "fill", + UiVisualStyle { + background: Some(Color::rgb(0.30, 0.55, 0.90)), + ..UiVisualStyle::EMPTY + }, + ) + .with_style( + "checkbox-on", + UiVisualStyle { + background: Some(Color::rgb(0.30, 0.55, 0.90)), + ..UiVisualStyle::EMPTY + }, + ) + .with_style( + "checkbox-off", + UiVisualStyle { + background: Some(Color::rgb(0.20, 0.22, 0.25)), + ..UiVisualStyle::EMPTY + }, + ) +} + +fn button(id: &str, text: &str, hovered: bool) -> Widget { + Widget::leaf(Vec2::new(200.0, 48.0)) + .with_id(id) + .with_text(text) + .with_theme_style(if hovered { "button-hover" } else { "button" }) + .with_style(UiLayoutStyle { + width: UiSizing::Fixed(200.0), + height: UiSizing::Fixed(48.0), + padding: UiInsets::all(12.0), + align_horizontal: UiAlign::Center, + align_vertical: UiAlign::Center, + ..Default::default() + }) +} + +fn primary_button(id: &str, text: &str, hovered: bool) -> Widget { + let mut w = button(id, text, hovered); + if !hovered { + w = w.with_theme_style("button-primary"); + } + w +} + +fn build_main_menu(hovered: Option<&str>) -> Widget { + let is = |id: &str| hovered == Some(id); + Widget::anchor() + .with_style(UiLayoutStyle { + width: UiSizing::Grow(1.0), + height: UiSizing::Grow(1.0), + ..Default::default() + }) + .with_child( + Widget::column() + .with_style(UiLayoutStyle { + width: UiSizing::Fixed(240.0), + height: UiSizing::Fixed(280.0), + padding: UiInsets::all(20.0), + anchor: UiAnchor::between(Vec2::splat(0.5), Vec2::splat(0.5)) + .with_offsets(Vec2::new(-120.0, -140.0), Vec2::new(120.0, 140.0)), + ..Default::default() + }) + .with_theme_style("panel") + .with_gap(12.0) + .with_child( + Widget::leaf(Vec2::new(200.0, 32.0)) + .with_text("Oxide") + .with_style(UiLayoutStyle { + width: UiSizing::Fixed(200.0), + height: UiSizing::Fixed(32.0), + align_horizontal: UiAlign::Center, + align_vertical: UiAlign::Center, + ..Default::default() + }) + .with_visual(UiVisualStyle { + font_size: Some(22.0), + ..UiVisualStyle::EMPTY + }), + ) + .with_child(primary_button("play", "Play", is("play"))) + .with_child(button("settings", "Settings", is("settings"))) + .with_child(button("quit", "Quit", is("quit"))), + ) +} + +fn build_settings(volume: f32, invert_y: bool, hovered: Option<&str>) -> Widget { + let is = |id: &str| hovered == Some(id); + // Fill spans 0..volume of the parent's width via a percentage anchor — + // scales with whatever the track width ends up being instead of + // hardcoding pixels. + let fill_fraction = volume.clamp(0.0, 1.0); + Widget::anchor() + .with_style(UiLayoutStyle { + width: UiSizing::Grow(1.0), + height: UiSizing::Grow(1.0), + ..Default::default() + }) + .with_child( + Widget::column() + .with_style(UiLayoutStyle { + width: UiSizing::Fixed(320.0), + height: UiSizing::Fixed(280.0), + padding: UiInsets::all(20.0), + anchor: UiAnchor::between(Vec2::splat(0.5), Vec2::splat(0.5)) + .with_offsets(Vec2::new(-160.0, -140.0), Vec2::new(160.0, 140.0)), + ..Default::default() + }) + .with_theme_style("panel") + .with_gap(16.0) + .with_child( + Widget::leaf(Vec2::new(200.0, 32.0)) + .with_text("Settings") + .with_style(UiLayoutStyle { + width: UiSizing::Fixed(280.0), + height: UiSizing::Fixed(32.0), + align_horizontal: UiAlign::Center, + align_vertical: UiAlign::Center, + ..Default::default() + }) + .with_visual(UiVisualStyle { + font_size: Some(20.0), + ..UiVisualStyle::EMPTY + }), + ) + // Volume row: label + slider track + fill. + .with_child( + Widget::column() + .with_style(UiLayoutStyle { + width: UiSizing::Fixed(280.0), + height: UiSizing::Fixed(48.0), + ..Default::default() + }) + .with_gap(4.0) + .with_child( + Widget::leaf(Vec2::new(200.0, 18.0)) + .with_text("Volume") + .with_style(UiLayoutStyle { + width: UiSizing::Fixed(280.0), + height: UiSizing::Fixed(18.0), + align_vertical: UiAlign::Center, + ..Default::default() + }), + ) + .with_child( + Widget::anchor() + .with_style(UiLayoutStyle { + width: UiSizing::Fixed(280.0), + height: UiSizing::Fixed(20.0), + ..Default::default() + }) + .with_child( + Widget::leaf(Vec2::ZERO) + .with_id("volume") + .with_value(volume) + .with_theme_style("track") + .with_style(UiLayoutStyle { + anchor: UiAnchor::FILL, + ..Default::default() + }), + ) + .with_child( + Widget::leaf(Vec2::ZERO) + .with_theme_style("fill") + .with_style(UiLayoutStyle { + anchor: UiAnchor::between( + Vec2::ZERO, + Vec2::new(fill_fraction, 1.0), + ), + ..Default::default() + }), + ), + ), + ) + // Invert-Y row: checkbox box (clickable) + label. + .with_child( + Widget::row() + .with_style(UiLayoutStyle { + width: UiSizing::Fixed(280.0), + height: UiSizing::Fixed(28.0), + ..Default::default() + }) + .with_gap(12.0) + .with_child( + Widget::leaf(Vec2::new(24.0, 24.0)) + .with_id("invert_y") + .with_value(invert_y) + .with_theme_style(if invert_y { + "checkbox-on" + } else { + "checkbox-off" + }) + .with_style(UiLayoutStyle { + width: UiSizing::Fixed(24.0), + height: UiSizing::Fixed(24.0), + align_vertical: UiAlign::Center, + ..Default::default() + }), + ) + .with_child( + Widget::leaf(Vec2::new(200.0, 24.0)) + .with_text("Invert Y axis") + .with_style(UiLayoutStyle { + width: UiSizing::Grow(1.0), + height: UiSizing::Fixed(24.0), + align_vertical: UiAlign::Center, + ..Default::default() + }), + ), + ) + .with_child(button("back", "Back", is("back"))), + ) +} + +impl UiMenu { + fn current_document(&self) -> Widget { + let hovered = self.router.hovered().map(|id| id.as_str()); + match self.screen { + Screen::Main => build_main_menu(hovered), + Screen::Settings => build_settings(self.volume, self.invert_y, hovered), + } + } +} + +impl WindowApp for UiMenu { + fn init(&mut self, ctx: &mut AppCtx<'_>) { + ctx.set_clear_color(Color::rgb(0.08, 0.09, 0.10)); + log::info!("ui_menu: main menu → click Play/Settings/Quit (Esc quits)"); + } + + 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 (w, h) = ctx.size(); + let viewport = + oxide_engine::math::Rect::from_min_size(Vec2::ZERO, Vec2::new(w as f32, h as f32)); + let document = self.current_document(); + let tree = ui_layout(&document, viewport, 1.0); + let frame = self.router.process(&tree, ctx.input()); + + match self.screen { + Screen::Main => { + if frame.clicked_left("play") { + log::info!("Play clicked (would start a game)"); + } + if frame.clicked_left("settings") { + log::info!("→ settings"); + self.screen = Screen::Settings; + } + if frame.clicked_left("quit") { + ctx.request_exit(); + } + } + Screen::Settings => { + if frame.clicked_left("back") { + log::info!("← back to main"); + self.screen = Screen::Main; + } + if frame.clicked_left("invert_y") { + self.invert_y = !self.invert_y; + log::info!("invert_y = {}", self.invert_y); + } + // Volume slider: drag-capture. + // - Press on the track → start tracking. + // - While tracking AND mouse held: update volume from + // cursor.x clamped to the track rect, regardless of + // whether the cursor is still inside it (so dragging + // past either edge pins to 0.0 or 1.0). + // - On release: stop tracking. + if frame.pressed("volume", MouseButton::Left) { + self.dragging_volume = true; + } + if !ctx.input().mouse_held(MouseButton::Left) { + self.dragging_volume = false; + } + if self.dragging_volume { + if let Some(cursor) = ctx.input().cursor() { + if let Some(node) = tree.find(&"volume".into()) { + let new_v = ((cursor.x - node.rect.min.x) / node.rect.width().max(1.0)) + .clamp(0.0, 1.0); + if (new_v - self.volume).abs() > 0.001 { + self.volume = new_v; + log::debug!("volume = {:.2}", self.volume); + } + } + } + } + } + } + } + + fn render(&mut self, ctx: &RenderCtx<'_>) { + let device = ctx.gpu.device(); + let queue = ctx.gpu.queue(); + + // Lazy-init GPU resources once the surface format is known. + if self.gpu.is_none() { + let mut ui_pass = UiOverlayPass::new(device, ctx.surface_format); + // Load a system font and register it under the same descriptor + // the theme uses. + let font = common_system_font_paths() + .iter() + .find_map(|p| Font::from_path(p).ok()); + match font { + Some(f) => { + ui_pass + .fonts_mut() + .insert_with_descriptor(self.main_font_descriptor.clone(), f); + } + None => { + log::error!( + "No system sans-serif font found in any of {:?}. Install \ + 'liberation-fonts' or 'dejavu-sans' and re-run.", + common_system_font_paths() + ); + } + } + self.gpu = Some(GpuState { ui_pass }); + } + // Build the document + tree + painted frame before taking the + // mutable borrow on the pass, so self.theme + self.current_document + // (which need &self) and gpu.ui_pass (which needs &mut self.gpu) + // don't clash. + let (w, h) = ctx.size; + let viewport = + oxide_engine::math::Rect::from_min_size(Vec2::ZERO, Vec2::new(w as f32, h as f32)); + let document = self.current_document(); + let tree = ui_layout(&document, viewport, 1.0); + let painted = ui_paint( + &document, + &tree, + &self.theme, + self.gpu.as_ref().unwrap().ui_pass.fonts(), + 1.0, + ); + + let gpu = self.gpu.as_mut().unwrap(); + gpu.ui_pass + .set_batches(vec![UiBatch::screen_space(painted, (w, h))]); + + let camera = Camera::default(); + let view_transform = oxide_engine::math::Transform::default(); + let lighting = Lighting::default(); + let mut frame = FrameContext { + device, + queue, + color: ctx.view, + size: ctx.size, + viewport_rect: None, + clear_color: Color::rgb(0.08, 0.09, 0.10), + camera: &camera, + view_transform: &view_transform, + lighting: &lighting, + objects: &[], + }; + // The window runner already cleared the surface to the configured + // clear color, so we just run the UI overlay on top. + gpu.ui_pass.run(&mut frame); + } +} + +fn main() -> anyhow::Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let config = WindowConfig { + title: "Oxide — ui_menu".to_string(), + width: 960, + height: 600, + ..Default::default() + }; + run(config, UiMenu::default()) +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..f02a3b5 --- /dev/null +++ b/install.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# install.sh — Build Oxide Engine and install to the system. +# +# Installs: +# /usr/local/bin/oxide-editor — editor binary +# /usr/local/share/oxide/assets/ — shared assets (icons, shaders, etc.) +# +# Usage: +# ./install.sh # installs to /usr/local (may need sudo) +# PREFIX=/opt/oxide ./install.sh + +set -euo pipefail + +PREFIX="${PREFIX:-/usr/local}" +BIN_DIR="$PREFIX/bin" +SHARE_DIR="$PREFIX/share/oxide" + +# ── Colour helpers ──────────────────────────────────────────────────────────── +GREEN='\033[0;32m' +CYAN='\033[0;36m' +RED='\033[0;31m' +NC='\033[0m' + +info() { echo -e "${CYAN}[oxide]${NC} $*"; } +ok() { echo -e "${GREEN}[oxide]${NC} $*"; } +err() { echo -e "${RED}[oxide] ERROR:${NC} $*" >&2; exit 1; } + +# ── Pre-flight ──────────────────────────────────────────────────────────────── +command -v cargo &>/dev/null || err "cargo not found — install Rust via https://rustup.rs" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# ── Build ───────────────────────────────────────────────────────────────────── +info "Building in release mode…" +cargo build --release --workspace + +# ── Install binary ──────────────────────────────────────────────────────────── +info "Installing oxide-editor → $BIN_DIR/oxide-editor" +install -Dm755 target/release/oxide-editor "$BIN_DIR/oxide-editor" + +# ── Install assets ──────────────────────────────────────────────────────────── +if [[ -d assets ]]; then + info "Installing assets → $SHARE_DIR/assets/" + install -d "$SHARE_DIR/assets" + cp -r assets/. "$SHARE_DIR/assets/" +fi + +# ── Done ────────────────────────────────────────────────────────────────────── +ok "Installation complete." +echo +echo " Run the editor with: oxide-editor" +echo " Uninstall with: sudo rm $BIN_DIR/oxide-editor && sudo rm -rf $SHARE_DIR" diff --git a/physics/Cargo.toml b/physics/Cargo.toml new file mode 100644 index 0000000..767efda --- /dev/null +++ b/physics/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "oxide-physics" +description = "Oxide 3D game engine — physics module (rapier3d)" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +oxide-engine = { path = "../engine" } +glam.workspace = true +hecs.workspace = true +serde.workspace = true +ron.workspace = true +thiserror.workspace = true +log.workspace = true + +# The physics backend. rapier3d bundles its own nalgebra (`rapier3d::na`); the +# module converts at the boundary so the rest of the engine stays on `glam`. +rapier3d = "0.33" + +[dev-dependencies] +ron.workspace = true diff --git a/physics/src/body.rs b/physics/src/body.rs new file mode 100644 index 0000000..39abbab --- /dev/null +++ b/physics/src/body.rs @@ -0,0 +1,159 @@ +//! The [`RigidBody`] component and its [`RigidBodyKind`]. +//! +//! A rigid body is the dynamics half of a physics object: it says *how* an +//! entity moves under the simulation (or that it does not). The *shape* it +//! collides with is a separate [`Collider`](crate::Collider) component, so the +//! two concerns compose — a body with no collider is a point mass, a collider +//! with no body is static world geometry. +//! +//! Like every Oxide component these are plain serializable data with +//! `#[derive(Reflect)]`, so they are editable from the inspector and from +//! scripts with no per-type editor code. The simulation reads them when it +//! builds the rapier world; the +//! authored fields here are inputs, never live simulation state (velocities and +//! poses live on the [`Transform`](oxide_engine::math::Transform) and inside +//! rapier). + +use oxide_engine::reflect::{Reflect, ReflectEnum}; +use serde::{Deserialize, Serialize}; + +/// How a [`RigidBody`] participates in the simulation. +/// +/// Mirrors rapier's body types, named for engine users: +/// - [`Dynamic`](Self::Dynamic) — fully simulated; moved by gravity, forces, +/// and contacts. +/// - [`Kinematic`](Self::Kinematic) — moved only by the game (its +/// [`Transform`](oxide_engine::math::Transform)); pushes dynamic bodies but is +/// never pushed back. The basis for the character controller (Stage 9 later +/// piece) and for moving platforms. +/// - [`Static`](Self::Static) — never moves; immovable world geometry (floors, +/// walls). The cheapest body — no integration cost. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, ReflectEnum)] +pub enum RigidBodyKind { + /// Fully simulated — gravity, forces, and contacts move it. + #[default] + Dynamic, + /// Moved only by the game; affects dynamics but is not affected by them. + Kinematic, + /// Immovable world geometry. + Static, +} + +impl RigidBodyKind { + /// Every kind, for editor menus. + pub const ALL: [RigidBodyKind; 3] = [ + RigidBodyKind::Dynamic, + RigidBodyKind::Kinematic, + RigidBodyKind::Static, + ]; + + /// A human-readable label. + pub fn label(self) -> &'static str { + match self { + RigidBodyKind::Dynamic => "Dynamic", + RigidBodyKind::Kinematic => "Kinematic", + RigidBodyKind::Static => "Static", + } + } +} + +/// Component: the dynamics of a physics object. +/// +/// Attach alongside a [`Collider`](crate::Collider) to make an entity take part +/// in the simulation. The fields are authoring inputs consumed when the rapier +/// body is created; runtime velocity/pose are not stored here (the +/// [`Transform`](oxide_engine::math::Transform) is the authoritative pose, which +/// the simulation writes back each step). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)] +pub struct RigidBody { + /// How the body participates in the simulation. + pub kind: RigidBodyKind, + /// Mass in kilograms. Ignored for non-dynamic bodies. `0` means "derive the + /// mass from the collider's [`density`](crate::Collider::density)". + pub mass: f32, + /// Linear velocity damping (drag), `0` = none. Slows translation over time. + pub linear_damping: f32, + /// Angular velocity damping, `0` = none. Slows rotation over time. + pub angular_damping: f32, + /// Per-body multiplier on global gravity (`1` = normal, `0` = floats, + /// negative = anti-gravity). Lets some bodies ignore gravity individually. + pub gravity_scale: f32, + /// Enable continuous collision detection — prevents fast bodies tunnelling + /// through thin geometry, at extra cost. Off by default. + pub ccd: bool, +} + +impl Default for RigidBody { + fn default() -> Self { + Self { + kind: RigidBodyKind::Dynamic, + // 0 = derive mass from collider density (rapier's default behaviour). + mass: 0.0, + linear_damping: 0.0, + angular_damping: 0.0, + gravity_scale: 1.0, + ccd: false, + } + } +} + +impl RigidBody { + /// A static (immovable) body — convenience for world geometry. + pub fn static_body() -> Self { + Self { + kind: RigidBodyKind::Static, + ..Self::default() + } + } + + /// A kinematic body — moved by the game, not by the simulation. + pub fn kinematic() -> Self { + Self { + kind: RigidBodyKind::Kinematic, + ..Self::default() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_a_dynamic_body() { + let b = RigidBody::default(); + assert_eq!(b.kind, RigidBodyKind::Dynamic); + assert_eq!(b.gravity_scale, 1.0); + assert!(!b.ccd); + } + + #[test] + fn constructors_set_the_kind() { + assert_eq!(RigidBody::static_body().kind, RigidBodyKind::Static); + assert_eq!(RigidBody::kinematic().kind, RigidBodyKind::Kinematic); + } + + #[test] + fn round_trips_through_ron() { + let b = RigidBody { + kind: RigidBodyKind::Kinematic, + mass: 2.5, + linear_damping: 0.1, + angular_damping: 0.2, + gravity_scale: 0.0, + ccd: true, + }; + let ron = ron::to_string(&b).unwrap(); + let back: RigidBody = ron::from_str(&ron).unwrap(); + assert_eq!(b, back); + } + + #[test] + fn kind_variants_are_reflected_for_a_combo() { + // The editor lists these in a dropdown via ReflectEnum. + assert_eq!( + RigidBodyKind::variants(), + &["Dynamic", "Kinematic", "Static"] + ); + } +} diff --git a/physics/src/character.rs b/physics/src/character.rs new file mode 100644 index 0000000..68406d2 --- /dev/null +++ b/physics/src/character.rs @@ -0,0 +1,101 @@ +//! The [`CharacterController`] component — a kinematic capsule character. +//! +//! A character controller is *not* a rigid body: it never reacts to forces. +//! Instead the game asks it to move by some desired translation each frame and +//! the controller resolves that against the world's colliders — sliding along +//! walls, stepping up small ledges, sticking to the ground on slopes, and +//! refusing to climb anything too steep — then reports whether the character +//! ended up grounded. It is the basis for player/NPC movement and is reused by +//! the Stage-15 prototyping kit. +//! +//! The character is a **capsule** (radius + cylindrical half-height along `+Y`). +//! An entity with this component is moved through +//! [`PhysicsWorld::move_character`](crate::PhysicsWorld::move_character); it does +//! **not** carry a [`RigidBody`](crate::RigidBody)/[`Collider`](crate::Collider) +//! (it isn't simulated), so it never appears in the body set and never +//! self-collides. + +use oxide_engine::math::Vec3; +use oxide_engine::reflect::Reflect; +use serde::{Deserialize, Serialize}; + +/// Component: a kinematic capsule character moved by move-and-slide. +/// +/// Tune the capsule shape (`radius`/`half_height`) and the movement rules +/// (`max_slope_degrees`, `step_offset`, `snap_to_ground`, `skin_width`). Pass an +/// entity carrying this to +/// [`PhysicsWorld::move_character`](crate::PhysicsWorld::move_character). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)] +pub struct CharacterController { + /// Capsule radius. + pub radius: f32, + /// Capsule cylindrical half-height (the straight segment between the two + /// hemispherical caps), along local `+Y`. + pub half_height: f32, + /// Steepest ground slope (degrees from horizontal) the character will walk + /// up; steeper surfaces are treated as walls. + pub max_slope_degrees: f32, + /// Tallest step/ledge the character will automatically climb. `0` disables + /// auto-stepping. + pub step_offset: f32, + /// Distance below the feet within which the character snaps down to stay + /// grounded (prevents bouncing down stairs/slopes). `0` disables snapping. + pub snap_to_ground: f32, + /// A small collision-detection margin kept around the capsule. + pub skin_width: f32, +} + +impl Default for CharacterController { + fn default() -> Self { + Self { + radius: 0.3, + half_height: 0.6, + max_slope_degrees: 45.0, + step_offset: 0.3, + snap_to_ground: 0.2, + skin_width: 0.01, + } + } +} + +impl CharacterController { + /// The total half-height of the capsule (cylinder half-height + one radius), + /// i.e. the distance from the capsule center to either tip. + pub fn total_half_height(&self) -> f32 { + self.half_height + self.radius + } + + /// The local-space offset from the capsule center down to the feet (the + /// bottom tip), useful for placing/grounding the character. + pub fn foot_offset(&self) -> Vec3 { + Vec3::new(0.0, -self.total_half_height(), 0.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_a_reasonable_humanoid_capsule() { + let c = CharacterController::default(); + assert_eq!(c.radius, 0.3); + assert!((c.total_half_height() - 0.9).abs() < 1e-6); + assert!((c.foot_offset() - Vec3::new(0.0, -0.9, 0.0)).length() < 1e-6); + } + + #[test] + fn round_trips_through_ron() { + let c = CharacterController { + radius: 0.4, + half_height: 1.0, + max_slope_degrees: 50.0, + step_offset: 0.0, + snap_to_ground: 0.0, + skin_width: 0.02, + }; + let ron = ron::to_string(&c).unwrap(); + let back: CharacterController = ron::from_str(&ron).unwrap(); + assert_eq!(c, back); + } +} diff --git a/physics/src/collider.rs b/physics/src/collider.rs new file mode 100644 index 0000000..04fb63d --- /dev/null +++ b/physics/src/collider.rs @@ -0,0 +1,210 @@ +//! The [`Collider`] component and its [`ColliderShape`]. +//! +//! A collider is the *shape* half of a physics object: the geometry the +//! simulation tests for contact, plus the material (friction/restitution) and +//! the [`LayerMask`] filtering that decides *what it collides with*. Paired with +//! a [`RigidBody`](crate::RigidBody) it becomes a moving body; on its own it is +//! static world geometry. +//! +//! The shape is modelled as a flat selector + dimension fields rather than a +//! data-carrying enum, mirroring [`MeshRenderer`]'s `PrimitiveShape` — that +//! keeps every field an individually-reflectable scalar so the inspector renders +//! a clean combo + drag-values with no per-type editor code. Convex-hull and +//! triangle-mesh colliders (which need mesh data) are a later Stage-9 piece. +//! +//! [`MeshRenderer`]: oxide_engine::render::MeshRenderer + +use oxide_engine::layer::LayerMask; +use oxide_engine::math::Vec3; +use oxide_engine::reflect::{Reflect, ReflectEnum}; +use serde::{Deserialize, Serialize}; + +/// The primitive shape of a [`Collider`]. +/// +/// Which dimension fields on the [`Collider`] are read depends on the shape: +/// - [`Box`](Self::Box) — `half_extents` (per-axis half sizes). +/// - [`Sphere`](Self::Sphere) — `radius`. +/// - [`Capsule`](Self::Capsule) — `radius` + `half_height` (the cylindrical +/// segment half-length, capped by hemispheres; axis is local `+Y`). +/// - [`Cylinder`](Self::Cylinder) — `radius` + `half_height` (axis is local +/// `+Y`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, ReflectEnum)] +pub enum ColliderShape { + /// Axis-aligned box; uses `half_extents`. + #[default] + Box, + /// Sphere; uses `radius`. + Sphere, + /// Capsule along local `+Y`; uses `radius` + `half_height`. + Capsule, + /// Cylinder along local `+Y`; uses `radius` + `half_height`. + Cylinder, +} + +impl ColliderShape { + /// Every shape, for editor menus. + pub const ALL: [ColliderShape; 4] = [ + ColliderShape::Box, + ColliderShape::Sphere, + ColliderShape::Capsule, + ColliderShape::Cylinder, + ]; + + /// A human-readable label. + pub fn label(self) -> &'static str { + match self { + ColliderShape::Box => "Box", + ColliderShape::Sphere => "Sphere", + ColliderShape::Capsule => "Capsule", + ColliderShape::Cylinder => "Cylinder", + } + } +} + +/// Component: the collision shape and material of a physics object. +/// +/// Attach on its own for static world geometry, or alongside a +/// [`RigidBody`](crate::RigidBody) for a moving body. `membership`/`filter` are +/// the engine's shared [`LayerMask`] primitive (Stage 5), so collision groups, +/// triggers, and scene queries all filter the same way. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)] +pub struct Collider { + /// The primitive shape; selects which dimension fields below are used. + pub shape: ColliderShape, + /// Half-extents for [`ColliderShape::Box`] (per-axis half sizes). + pub half_extents: Vec3, + /// Radius for sphere / capsule / cylinder shapes. + pub radius: f32, + /// Half-height for capsule / cylinder shapes (along local `+Y`). + pub half_height: f32, + /// Coulomb friction coefficient (`0` = frictionless, ~`0.5` typical). + pub friction: f32, + /// Bounciness, `0` = inelastic, `1` = fully elastic. + pub restitution: f32, + /// Mass density (kg/m³-ish); used to derive a dynamic body's mass when its + /// [`mass`](crate::RigidBody::mass) is `0`. + pub density: f32, + /// When `true` the collider is a **sensor (trigger)**: it reports overlap + /// (enter/stay/exit events) without resolving contact, so things pass + /// through it. + pub sensor: bool, + /// The layers this collider **belongs to** — what it *is*, for the other + /// side's filter to select. + pub membership: LayerMask, + /// The layers this collider **collides with** — what it *cares about*. Two + /// colliders interact only when each one's membership intersects the + /// other's filter. + pub filter: LayerMask, +} + +impl Default for Collider { + fn default() -> Self { + Self { + shape: ColliderShape::Box, + // A unit cube (matches the default MeshRenderer primitive). + half_extents: Vec3::splat(0.5), + radius: 0.5, + half_height: 0.5, + friction: 0.5, + restitution: 0.0, + density: 1.0, + sensor: false, + // Belong to layer 0 ("Default") and collide with everything, so a + // freshly-added collider interacts out of the box. + membership: LayerMask::layer(0), + filter: LayerMask::ALL, + } + } +} + +impl Collider { + /// A box collider with the given per-axis half-extents. + pub fn cuboid(half_extents: Vec3) -> Self { + Self { + shape: ColliderShape::Box, + half_extents, + ..Self::default() + } + } + + /// A sphere collider of the given radius. + pub fn ball(radius: f32) -> Self { + Self { + shape: ColliderShape::Sphere, + radius, + ..Self::default() + } + } + + /// A capsule collider (radius + cylindrical half-height along `+Y`). + pub fn capsule(radius: f32, half_height: f32) -> Self { + Self { + shape: ColliderShape::Capsule, + radius, + half_height, + ..Self::default() + } + } + + /// This collider made a sensor (trigger). + pub fn as_sensor(mut self) -> Self { + self.sensor = true; + self + } + + /// This collider on the given membership/filter masks. + pub fn with_layers(mut self, membership: LayerMask, filter: LayerMask) -> Self { + self.membership = membership; + self.filter = filter; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_a_unit_box_that_collides_with_everything() { + let c = Collider::default(); + assert_eq!(c.shape, ColliderShape::Box); + assert_eq!(c.half_extents, Vec3::splat(0.5)); + assert!(!c.sensor); + assert_eq!(c.membership, LayerMask::layer(0)); + assert_eq!(c.filter, LayerMask::ALL); + } + + #[test] + fn constructors_pick_the_shape() { + assert_eq!(Collider::ball(2.0).shape, ColliderShape::Sphere); + assert_eq!(Collider::ball(2.0).radius, 2.0); + assert_eq!(Collider::capsule(0.3, 0.8).shape, ColliderShape::Capsule); + assert!(Collider::default().as_sensor().sensor); + } + + #[test] + fn with_layers_sets_masks() { + let c = Collider::default().with_layers(LayerMask::layer(2), LayerMask::layer(3)); + assert_eq!(c.membership, LayerMask::layer(2)); + assert_eq!(c.filter, LayerMask::layer(3)); + } + + #[test] + fn round_trips_through_ron() { + let c = Collider::capsule(0.4, 1.0).as_sensor().with_layers( + LayerMask::layer(1), + LayerMask::layer(2).union(LayerMask::layer(5)), + ); + let ron = ron::to_string(&c).unwrap(); + let back: Collider = ron::from_str(&ron).unwrap(); + assert_eq!(c, back); + } + + #[test] + fn shape_variants_are_reflected_for_a_combo() { + assert_eq!( + ColliderShape::variants(), + &["Box", "Sphere", "Capsule", "Cylinder"] + ); + } +} diff --git a/physics/src/lib.rs b/physics/src/lib.rs new file mode 100644 index 0000000..9cf8390 --- /dev/null +++ b/physics/src/lib.rs @@ -0,0 +1,38 @@ +//! Oxide Engine — physics module (`oxide-physics`). +//! +//! Stage 9 adds rigid-body physics — collision, queries, constraints, and a +//! character controller — as a **feature-gated module** built on +//! [`rapier3d`](https://rapier.rs), not a thin wrapper. It plugs into the engine +//! through the Stage-5 module system: add [`PhysicsModule`] to an +//! [`App`](oxide_engine::app::App) and physics components become live. +//! +//! The design keeps the **ECS as the source of truth**: an entity describes its +//! physics with two serializable, reflected components — +//! - [`RigidBody`] — *how* it moves (dynamic / kinematic / static, mass, +//! damping, gravity scale, CCD), and +//! - [`Collider`] — *what shape* it is, its material, and the [`LayerMask`] +//! filtering of what it collides with. +//! +//! The rapier simulation world is a transient resource rebuilt from these +//! components, so play-mode snapshot/restore (Stage 8.7) works for free: Stop +//! reverts the authored components and the next Play rebuilds the world fresh. +//! +//! [`LayerMask`]: oxide_engine::layer::LayerMask +//! +//! This first piece lands the component data model and the module wiring; the +//! rapier-backed simulation, scene queries, joints, and the character controller +//! follow in subsequent pieces. + +#![deny(warnings)] + +mod body; +mod character; +mod collider; +mod module; +mod world; + +pub use body::{RigidBody, RigidBodyKind}; +pub use character::CharacterController; +pub use collider::{Collider, ColliderShape}; +pub use module::{PhysicsModule, PhysicsSettings, DEFAULT_GRAVITY}; +pub use world::{CharacterMovement, CollisionEvent, JointId, JointKind, PhysicsWorld, RayHit}; diff --git a/physics/src/module.rs b/physics/src/module.rs new file mode 100644 index 0000000..bd6a881 --- /dev/null +++ b/physics/src/module.rs @@ -0,0 +1,110 @@ +//! The [`PhysicsModule`] — the Stage-9 entry point that wires physics into an +//! [`App`]. +//! +//! Following the engine's module convention, everything physics contributes is +//! registered here so it can be enabled, disabled, or removed as a unit (and so +//! an exported game that never touches physics never compiles it in). This piece +//! registers the component *types* for reflection (making [`RigidBody`] and +//! [`Collider`] dual-editable and captured by the play-mode snapshot) and +//! installs the global [`PhysicsSettings`]. The simulation system itself is +//! added in a later piece. + +use oxide_engine::app::{App, Module, Schedule}; +use oxide_engine::math::Vec3; + +use crate::world::step_physics; +use crate::{CharacterController, Collider, PhysicsWorld, RigidBody}; + +/// Earth-like gravity (m/s²) along `-Y` — the default for a new physics world. +pub const DEFAULT_GRAVITY: Vec3 = Vec3::new(0.0, -9.81, 0.0); + +/// Project-wide physics tuning, stored as an [`App`] resource. +/// +/// Kept tiny and serializable so it can grow into a settings page; for now it +/// carries the global gravity vector the simulation integrates dynamic bodies +/// against (each body can still scale it via +/// [`RigidBody::gravity_scale`](crate::RigidBody::gravity_scale)). +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct PhysicsSettings { + /// Global gravity acceleration applied to dynamic bodies. + pub gravity: Vec3, +} + +impl Default for PhysicsSettings { + fn default() -> Self { + Self { + gravity: DEFAULT_GRAVITY, + } + } +} + +/// The physics module. Add it after [`DefaultModules`] to give an app a +/// rapier-backed simulation. +/// +/// [`DefaultModules`]: oxide_engine::app::DefaultModules +/// +/// ``` +/// use oxide_engine::app::App; +/// use oxide_physics::PhysicsModule; +/// +/// let mut app = App::new(); +/// app.add_module(PhysicsModule); +/// assert!(app.has_module("physics")); +/// assert!(app.types.is_registered("RigidBody")); +/// ``` +pub struct PhysicsModule; + +impl Module for PhysicsModule { + fn name(&self) -> &'static str { + "physics" + } + + fn build(&self, app: &mut App) { + // Register the component types for reflection so they round-trip through + // RON (scripts/AI) and are captured by the play-mode scene snapshot. + app.register_type::("RigidBody"); + app.register_type::("Collider"); + app.register_type::("CharacterController"); + + // The global gravity setting; the simulation reads it each fixed step. + app.insert_resource(PhysicsSettings::default()); + + // The transient rapier world (rebuilt from components) and the + // fixed-timestep system that syncs, steps, and writes poses back. + app.insert_resource(PhysicsWorld::new()); + app.add_system(Schedule::FixedUpdate, step_physics); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oxide_engine::app::DefaultModules; + + #[test] + fn module_registers_component_types() { + let mut app = App::new(); + app.add_modules(DefaultModules); + app.add_module(PhysicsModule); + + assert!(app.has_module("physics")); + assert!(app.types.is_registered("RigidBody")); + assert!(app.types.is_registered("Collider")); + assert_eq!( + app.get_resource::().map(|s| s.gravity), + Some(DEFAULT_GRAVITY) + ); + } + + #[test] + fn removing_the_module_drops_its_types() { + let mut app = App::new(); + app.add_module(PhysicsModule); + assert!(app.types.is_registered("Collider")); + + assert!(app.remove_module("physics")); + assert!(!app.has_module("physics")); + assert!(!app.types.is_registered("RigidBody")); + assert!(!app.types.is_registered("Collider")); + } +} diff --git a/physics/src/world.rs b/physics/src/world.rs new file mode 100644 index 0000000..02ff02c --- /dev/null +++ b/physics/src/world.rs @@ -0,0 +1,1497 @@ +//! The [`PhysicsWorld`] resource and the fixed-timestep simulation step. +//! +//! This is the bridge between the engine's ECS (the source of truth) and the +//! rapier simulation. The rapier state lives in a [`PhysicsWorld`] resource +//! rebuilt from the [`RigidBody`]/[`Collider`] components; each fixed step the +//! [`step_physics`] system: +//! +//! 1. **syncs** — inserts a rapier body+collider for every new physics entity, +//! removes bodies for despawned ones, and pushes kinematic targets, +//! 2. **steps** the rapier pipeline by one [`fixed_delta`](oxide_engine::app::Time::fixed_delta), +//! 3. **writes back** each moved body's pose onto its entity's +//! [`Transform`](oxide_engine::math::Transform). +//! +//! Because the ECS components are authoritative and rapier is transient, the +//! Stage-8.7 play-mode snapshot captures physics for free: Stop restores the +//! authored components and the next Play rebuilds the world. +//! +//! ## Conversions & limitations (this piece) +//! +//! rapier 0.33 uses its own (glam-backed) math types; this module converts at +//! the boundary by components so the engine stays on its own `glam`. Two +//! simplifications land with the first simulation piece and are lifted later: +//! **`Transform::scale` is ignored** (the collider uses its authored +//! dimensions), and **non-root bodies** write back through their parent's world +//! transform but author their shapes in world space — keep physics bodies at the +//! scene root or unscaled for now. + +use std::collections::{HashMap, HashSet}; + +use oxide_engine::app::App; +use oxide_engine::math::{Quat, Transform, Vec3}; +use oxide_engine::scene::{Entity, Scene}; + +use rapier3d::control::{CharacterAutostep, CharacterLength, KinematicCharacterController}; +use rapier3d::math::{Pose, Rotation, Vector}; +use rapier3d::parry::query::ShapeCastOptions; +use rapier3d::parry::shape::{Ball, Capsule}; +use rapier3d::prelude::{ + ActiveEvents, ChannelEventCollector, ColliderBuilder, ColliderHandle, + CollisionEvent as RapierCollisionEvent, FixedJointBuilder, Group, ImpulseJointHandle, + InteractionGroups, InteractionTestMode, PhysicsWorld as RapierWorld, PrismaticJointBuilder, + QueryFilter, Ray, RevoluteJointBuilder, RigidBodyBuilder, RigidBodyHandle, + SphericalJointBuilder, +}; + +use oxide_engine::layer::LayerMask; + +use crate::{ + CharacterController, Collider, ColliderShape, PhysicsSettings, RigidBody, RigidBodyKind, + DEFAULT_GRAVITY, +}; + +/// The collision-resolved result of a [`PhysicsWorld::move_character`] call. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CharacterMovement { + /// The actual translation to apply, after sliding along walls, stepping up + /// ledges, and snapping to the ground (may be shorter than the request). + pub translation: Vec3, + /// Whether the character is standing on walkable ground after the move. + pub grounded: bool, +} + +/// Which kind of constraint a joint imposes between two bodies. +/// +/// Each anchors a point on body A to a point on body B and removes some degrees +/// of freedom: +/// - [`Fixed`](Self::Fixed) — welds the bodies: no relative motion at all. +/// - [`Spherical`](Self::Spherical) — ball-and-socket: the anchor points stay +/// coincident, but the bodies may rotate freely about it (3 rotational DOF). +/// - [`Revolute`](Self::Revolute) — hinge: like spherical but rotation is locked +/// to a single `axis` (1 rotational DOF). +/// - [`Prismatic`](Self::Prismatic) — slider: the bodies may only translate +/// relative to each other along `axis` (1 translational DOF). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum JointKind { + /// A rigid weld — zero relative DOF. + Fixed, + /// A ball-and-socket joint — 3 rotational DOF about the anchor. + Spherical, + /// A hinge about `axis` (in body A's local frame). + Revolute { axis: Vec3 }, + /// A slider along `axis` (in body A's local frame). + Prismatic { axis: Vec3 }, +} + +/// An opaque handle to a joint created with [`PhysicsWorld::add_joint`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct JointId(ImpulseJointHandle); + +/// The result of a successful scene query (raycast or shape-cast). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RayHit { + /// The entity whose collider was hit. + pub entity: Entity, + /// Distance along the (normalized) ray/sweep direction to the hit. + pub toi: f32, + /// The world-space hit point. + pub point: Vec3, + /// The world-space surface normal at the hit. + pub normal: Vec3, +} + +/// One collision/overlap transition surfaced to game code for the current frame. +/// +/// Reported when two colliders **start** or **stop** touching. A `sensor` event +/// is a trigger overlap (one collider is a sensor — no contact was resolved); a +/// non-`sensor` event is a solid contact. The pair is unordered. "Stay" (ongoing +/// overlap) is not an event — query it with +/// [`PhysicsWorld::is_intersecting`] / [`PhysicsWorld::intersecting_pairs`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CollisionEvent { + /// One of the two entities involved. + pub a: Entity, + /// The other entity. + pub b: Entity, + /// `true` = the pair began touching (enter); `false` = stopped (exit). + pub started: bool, + /// `true` = a sensor/trigger overlap; `false` = a solid contact. + pub sensor: bool, +} + +impl CollisionEvent { + /// The other entity in the pair, given one of them (or `None` if `entity` + /// is not part of this event). + pub fn other(&self, entity: Entity) -> Option { + if self.a == entity { + Some(self.b) + } else if self.b == entity { + Some(self.a) + } else { + None + } + } +} + +// --- glam <-> rapier conversions (by component; the crates' glam versions +// differ, so we never rely on type identity) --------------------------- + +fn to_vec(v: Vec3) -> Vector { + Vector::new(v.x, v.y, v.z) +} + +fn from_vec(v: Vector) -> Vec3 { + Vec3::new(v.x, v.y, v.z) +} + +fn to_quat(q: Quat) -> Rotation { + Rotation::from_xyzw(q.x, q.y, q.z, q.w) +} + +fn from_quat(q: Rotation) -> Quat { + Quat::from_xyzw(q.x, q.y, q.z, q.w) +} + +fn to_pose(t: &Transform) -> Pose { + Pose::from_parts(to_vec(t.translation), to_quat(t.rotation)) +} + +/// The rapier simulation, plus the entity ↔ handle mapping. +/// +/// Installed as an [`App`] resource by +/// [`PhysicsModule`](crate::PhysicsModule); driven each fixed step by +/// [`step_physics`]. Public control methods (forces, velocities, sleep/wake) +/// operate by [`Entity`] so game code never handles rapier types. +pub struct PhysicsWorld { + world: RapierWorld, + entity_to_body: HashMap, + body_to_entity: HashMap, + entity_to_collider: HashMap, + collider_to_entity: HashMap, + /// Collision/trigger transitions for the current frame (accumulated across + /// every fixed sub-step, cleared at the first step of each frame). + events: Vec, + /// The frame the `events` buffer was last cleared for. + events_frame: u64, +} + +impl Default for PhysicsWorld { + fn default() -> Self { + Self::new() + } +} + +impl PhysicsWorld { + /// A new, empty physics world. + pub fn new() -> Self { + Self { + world: RapierWorld::new(), + entity_to_body: HashMap::new(), + body_to_entity: HashMap::new(), + entity_to_collider: HashMap::new(), + collider_to_entity: HashMap::new(), + events: Vec::new(), + events_frame: 0, + } + } + + /// The rapier body handle backing `entity`, if it is in the simulation. + /// Exposed for the joint system (a later piece) and advanced callers. + pub fn body_handle(&self, entity: Entity) -> Option { + self.entity_to_body.get(&entity).copied() + } + + /// The number of bodies currently in the simulation. + pub fn body_count(&self) -> usize { + self.entity_to_body.len() + } + + // --- Collision / trigger events ---------------------------------------- + + /// Every collision/trigger transition (enter/exit) from the current frame. + pub fn collision_events(&self) -> &[CollisionEvent] { + &self.events + } + + /// The trigger (sensor) overlap transitions from the current frame. + pub fn trigger_events(&self) -> impl Iterator { + self.events.iter().filter(|e| e.sensor) + } + + /// The solid-contact transitions from the current frame. + pub fn contact_events(&self) -> impl Iterator { + self.events.iter().filter(|e| !e.sensor) + } + + /// Whether two entities' colliders are currently intersecting (the "stay" + /// state between an enter and an exit event). Works for both sensor overlaps + /// and solid contacts. + pub fn is_intersecting(&self, a: Entity, b: Entity) -> bool { + let (Some(&ha), Some(&hb)) = ( + self.entity_to_collider.get(&a), + self.entity_to_collider.get(&b), + ) else { + return false; + }; + self.world.intersection_pair(ha, hb).unwrap_or(false) + || self + .world + .contact_pair(ha, hb) + .is_some_and(|p| p.has_any_active_contact()) + } + + /// All entity pairs whose sensor colliders are currently overlapping. + pub fn intersecting_pairs(&self) -> Vec<(Entity, Entity)> { + self.world + .intersection_pairs() + .filter(|(_, _, _, _, intersecting)| *intersecting) + .filter_map(|(h1, _, h2, _, _)| { + Some(( + *self.collider_to_entity.get(&h1)?, + *self.collider_to_entity.get(&h2)?, + )) + }) + .collect() + } + + // --- Scene queries ----------------------------------------------------- + + /// The [`InteractionGroups`] for a query that should hit colliders on any + /// layer in `mask`. The query "belongs to" every layer and filters by + /// `mask`, so it selects colliders whose membership intersects `mask`. + fn query_groups(mask: LayerMask) -> InteractionGroups { + InteractionGroups::new( + Group::all(), + Group::from_bits_retain(mask.bits()), + InteractionTestMode::And, + ) + } + + /// Casts a ray and returns the first collider hit on a layer in `mask`. + /// + /// `dir` need not be normalized; `max_distance` is measured in world units. + /// Pass [`LayerMask::ALL`] to hit anything. + pub fn raycast( + &self, + origin: Vec3, + dir: Vec3, + max_distance: f32, + mask: LayerMask, + ) -> Option { + let dir = dir.normalize_or_zero(); + if dir == Vec3::ZERO { + return None; + } + let ray = Ray::new(to_vec(origin), to_vec(dir)); + let filter = QueryFilter::new().groups(Self::query_groups(mask)); + let (handle, intersection) = + self.world + .cast_ray_and_get_normal(&ray, max_distance, true, filter)?; + let entity = *self.collider_to_entity.get(&handle)?; + Some(RayHit { + entity, + toi: intersection.time_of_impact, + point: origin + dir * intersection.time_of_impact, + normal: from_vec(intersection.normal), + }) + } + + /// Sweeps a sphere of `radius` from `origin` along `dir` and returns the + /// first collider hit on a layer in `mask` (a "thick raycast"). + pub fn sphere_cast( + &self, + origin: Vec3, + radius: f32, + dir: Vec3, + max_distance: f32, + mask: LayerMask, + ) -> Option { + let dir = dir.normalize_or_zero(); + if dir == Vec3::ZERO { + return None; + } + let shape = Ball::new(radius); + let shape_pos = Pose::from_parts(to_vec(origin), to_quat(Quat::IDENTITY)); + let options = ShapeCastOptions::with_max_time_of_impact(max_distance); + let filter = QueryFilter::new().groups(Self::query_groups(mask)); + let (handle, hit) = + self.world + .cast_shape(&shape_pos, to_vec(dir), &shape, options, filter)?; + let entity = *self.collider_to_entity.get(&handle)?; + Some(RayHit { + entity, + toi: hit.time_of_impact, + point: from_vec(hit.witness1), + normal: from_vec(hit.normal1), + }) + } + + /// Every entity whose collider overlaps a sphere at `center` (on a layer in + /// `mask`) — an overlap/proximity query. + pub fn overlap_sphere(&self, center: Vec3, radius: f32, mask: LayerMask) -> Vec { + let shape = Ball::new(radius); + let pose = Pose::from_parts(to_vec(center), to_quat(Quat::IDENTITY)); + let filter = QueryFilter::new().groups(Self::query_groups(mask)); + self.world + .intersect_shape(pose, &shape, filter) + .filter_map(|(h, _)| self.collider_to_entity.get(&h).copied()) + .collect() + } + + /// Every entity whose collider contains `point` (on a layer in `mask`) — a + /// point/containment query. + pub fn point_overlap(&self, point: Vec3, mask: LayerMask) -> Vec { + let filter = QueryFilter::new().groups(Self::query_groups(mask)); + self.world + .intersect_point(to_vec(point), filter) + .filter_map(|(h, _)| self.collider_to_entity.get(&h).copied()) + .collect() + } + + // --- Joints / constraints ---------------------------------------------- + + /// Connects two entities' bodies with a joint and returns its handle. + /// + /// `anchor_a` / `anchor_b` are the attachment points in each body's local + /// frame. Both entities must already be in the simulation (they are after + /// the first step in which their components exist); returns `None` if either + /// has no body yet. Joints created this way live in the [`PhysicsWorld`] + /// (not the ECS), so they are recreated by game/setup code on each Play + /// rather than restored from the scene snapshot. + pub fn add_joint( + &mut self, + a: Entity, + b: Entity, + kind: JointKind, + anchor_a: Vec3, + anchor_b: Vec3, + ) -> Option { + let ha = self.entity_to_body.get(&a).copied()?; + let hb = self.entity_to_body.get(&b).copied()?; + let (aa, ab) = (to_vec(anchor_a), to_vec(anchor_b)); + let handle = match kind { + JointKind::Fixed => self.world.insert_impulse_joint( + ha, + hb, + FixedJointBuilder::new().local_anchor1(aa).local_anchor2(ab), + ), + JointKind::Spherical => self.world.insert_impulse_joint( + ha, + hb, + SphericalJointBuilder::new() + .local_anchor1(aa) + .local_anchor2(ab), + ), + JointKind::Revolute { axis } => self.world.insert_impulse_joint( + ha, + hb, + RevoluteJointBuilder::new(to_vec(axis.normalize_or_zero())) + .local_anchor1(aa) + .local_anchor2(ab), + ), + JointKind::Prismatic { axis } => self.world.insert_impulse_joint( + ha, + hb, + PrismaticJointBuilder::new(to_vec(axis.normalize_or_zero())) + .local_anchor1(aa) + .local_anchor2(ab), + ), + }; + Some(JointId(handle)) + } + + /// Removes a previously created joint. No-op if it was already removed. + pub fn remove_joint(&mut self, joint: JointId) { + self.world.remove_impulse_joint(joint.0); + } + + /// The number of joints currently in the simulation. + pub fn joint_count(&self) -> usize { + self.world.impulse_joints().count() + } + + // --- Character controller ---------------------------------------------- + + /// Resolves a desired move for a kinematic capsule character against the + /// world, returning the collision-corrected translation and grounded state. + /// + /// `entity` must carry a [`CharacterController`] component; its capsule is + /// taken from there and its start pose from its world + /// [`Transform`](oxide_engine::math::Transform). The character is **not** in + /// the body set, so it never self-collides. The caller applies the returned + /// [`translation`](CharacterMovement::translation) to the entity (typically + /// `scene.set_local_transform`). Returns `None` if the entity has no + /// controller. + pub fn move_character( + &self, + scene: &Scene, + entity: Entity, + desired: Vec3, + dt: f32, + ) -> Option { + let cc = *scene.get::(entity)?; + let world_t = scene.world_transform(entity)?; + + let shape = Capsule::new_y(cc.half_height, cc.radius); + let mut controller = KinematicCharacterController { + offset: CharacterLength::Absolute(cc.skin_width.max(1.0e-3)), + max_slope_climb_angle: cc.max_slope_degrees.to_radians(), + ..KinematicCharacterController::default() + }; + controller.autostep = (cc.step_offset > 0.0).then_some(CharacterAutostep { + max_height: CharacterLength::Absolute(cc.step_offset), + min_width: CharacterLength::Absolute(cc.radius * 0.5), + include_dynamic_bodies: false, + }); + controller.snap_to_ground = + (cc.snap_to_ground > 0.0).then_some(CharacterLength::Absolute(cc.snap_to_ground)); + + let pose = to_pose(&world_t); + let queries = self.world.query_pipeline(); + let movement = controller.move_shape(dt, &queries, &shape, &pose, to_vec(desired), |_| {}); + + Some(CharacterMovement { + translation: from_vec(movement.translation), + grounded: movement.grounded, + }) + } + + // --- Forces & control (by entity) -------------------------------------- + + /// Sets a body's linear velocity (m/s), waking it. + pub fn set_linear_velocity(&mut self, entity: Entity, v: Vec3) { + if let Some(b) = self.body_mut(entity) { + b.set_linvel(to_vec(v), true); + } + } + + /// The body's current linear velocity, or zero if it has none. + pub fn linear_velocity(&self, entity: Entity) -> Vec3 { + self.body(entity) + .map(|b| from_vec(b.linvel())) + .unwrap_or(Vec3::ZERO) + } + + /// Applies a one-shot impulse (mass·velocity) at the body's center, waking it. + pub fn apply_impulse(&mut self, entity: Entity, impulse: Vec3) { + if let Some(b) = self.body_mut(entity) { + b.apply_impulse(to_vec(impulse), true); + } + } + + /// Applies a continuous force at the body's center (cleared each step), waking it. + pub fn apply_force(&mut self, entity: Entity, force: Vec3) { + if let Some(b) = self.body_mut(entity) { + b.add_force(to_vec(force), true); + } + } + + /// Applies a one-shot angular impulse, waking the body. + pub fn apply_torque_impulse(&mut self, entity: Entity, torque: Vec3) { + if let Some(b) = self.body_mut(entity) { + b.apply_torque_impulse(to_vec(torque), true); + } + } + + /// Wakes (or, with `false`, allows to sleep) a body. + pub fn wake(&mut self, entity: Entity, strong: bool) { + if let Some(h) = self.entity_to_body.get(&entity).copied() { + self.world.wake_up(h, strong); + } + } + + fn body(&self, entity: Entity) -> Option<&rapier3d::dynamics::RigidBody> { + self.entity_to_body + .get(&entity) + .and_then(|h| self.world.bodies.get(*h)) + } + + fn body_mut(&mut self, entity: Entity) -> Option<&mut rapier3d::dynamics::RigidBody> { + match self.entity_to_body.get(&entity).copied() { + Some(h) => self.world.bodies.get_mut(h), + None => None, + } + } + + // --- The fixed step ---------------------------------------------------- + + /// Syncs the rapier world to the scene, steps once, and writes poses back. + fn sync_and_step(&mut self, app: &mut App) { + let gravity = app + .get_resource::() + .map(|s| s.gravity) + .unwrap_or(DEFAULT_GRAVITY); + self.world.gravity = to_vec(gravity); + self.world.integration_parameters.dt = app.time.fixed_delta; + + // Clear the event buffer once per frame, then accumulate across every + // fixed sub-step so a consumer reading after Update sees them all. + if self.events_frame != app.time.frame { + self.events.clear(); + self.events_frame = app.time.frame; + } + + self.sync_bodies(&app.scene); + self.step_and_collect_events(); + self.write_back(&mut app.scene); + } + + /// Steps the rapier pipeline, draining collision/trigger events into the + /// frame buffer (translated from collider handles to entities). + fn step_and_collect_events(&mut self) { + let (collision_tx, collision_rx) = std::sync::mpsc::channel(); + let (force_tx, _force_rx) = std::sync::mpsc::channel(); + let collector = ChannelEventCollector::new(collision_tx, force_tx); + self.world.step_with_events(&(), &collector); + drop(collector); // close the senders so the iterator terminates + + for ev in collision_rx.try_iter() { + let started = ev.started(); + let sensor = ev.sensor(); + let (h1, h2) = match ev { + RapierCollisionEvent::Started(a, b, _) | RapierCollisionEvent::Stopped(a, b, _) => { + (a, b) + } + }; + if let (Some(&a), Some(&b)) = ( + self.collider_to_entity.get(&h1), + self.collider_to_entity.get(&h2), + ) { + self.events.push(CollisionEvent { + a, + b, + started, + sensor, + }); + } + } + } + + /// Rebuilds the rapier world from `scene`'s physics components and refreshes + /// the query pipeline, leaving it ready for scene queries **without + /// advancing the simulation**. + /// + /// The simulation step runs the same sync internally; expose it so tools + /// (notably the editor's raycast probe) can query the *edited* scene outside + /// Play. rapier's query pipeline reads from the broad phase, which only learns + /// about colliders during a pipeline step — so after syncing we run one + /// **`dt = 0`** step: it registers every collider's AABB for queries while + /// integrating nothing, so authored positions are preserved. After this call + /// [`raycast`](Self::raycast) / [`overlap_sphere`](Self::overlap_sphere) and + /// the other queries reflect the current colliders. + pub fn sync_to_scene(&mut self, scene: &Scene) { + self.sync_bodies(scene); + // Refresh the broad phase (and thus the query pipeline) without moving + // anything: a zero-length step integrates no motion but rebuilds the + // spatial acceleration structure the queries read from. + self.world.integration_parameters.dt = 0.0; + self.world.step(); + } + + /// Inserts/removes/updates rapier bodies to match the scene's physics entities. + fn sync_bodies(&mut self, scene: &Scene) { + // Snapshot the physics entities (collider required, body optional) so we + // can drop the query borrow before resolving world transforms. + let present: Vec<(Entity, Collider, Option)> = scene + .world() + .query::<(&Collider, Option<&RigidBody>)>() + .iter() + .map(|(e, (c, rb))| (e, *c, rb.copied())) + .collect(); + + // Remove bodies whose entity lost its collider or was despawned. + let live: HashSet = present.iter().map(|(e, _, _)| *e).collect(); + let stale: Vec = self + .entity_to_body + .keys() + .copied() + .filter(|e| !live.contains(e)) + .collect(); + for e in stale { + if let Some(h) = self.entity_to_body.remove(&e) { + self.body_to_entity.remove(&h); + self.world.remove_body(h); + } + if let Some(ch) = self.entity_to_collider.remove(&e) { + self.collider_to_entity.remove(&ch); + } + } + + for (e, col, rb) in &present { + let world_t = scene.world_transform(*e).unwrap_or(Transform::IDENTITY); + match self.entity_to_body.get(e).copied() { + Some(h) => { + // A kinematic body is driven by its ECS transform: push the + // target so the step integrates toward it. + if matches!(rb.map(|r| r.kind), Some(RigidBodyKind::Kinematic)) { + if let Some(b) = self.world.bodies.get_mut(h) { + b.set_next_kinematic_position(to_pose(&world_t)); + } + } + } + None => self.insert_body(*e, col, rb.as_ref(), &world_t), + } + } + } + + /// Builds and inserts a rapier body + collider for a new physics entity. + fn insert_body(&mut self, e: Entity, col: &Collider, rb: Option<&RigidBody>, t: &Transform) { + let kind = rb.map(|r| r.kind).unwrap_or(RigidBodyKind::Static); + let mut body = match kind { + RigidBodyKind::Dynamic => RigidBodyBuilder::dynamic(), + RigidBodyKind::Kinematic => RigidBodyBuilder::kinematic_position_based(), + RigidBodyKind::Static => RigidBodyBuilder::fixed(), + } + .pose(to_pose(t)) + .user_data(e.to_bits().get() as u128); + + let explicit_mass = rb.map(|r| r.mass > 0.0).unwrap_or(false); + if let Some(r) = rb { + body = body + .linear_damping(r.linear_damping) + .angular_damping(r.angular_damping) + .gravity_scale(r.gravity_scale) + .ccd_enabled(r.ccd); + if r.mass > 0.0 { + body = body.additional_mass(r.mass); + } + } + + // When an explicit mass is set, zero the collider density so the body's + // mass is exactly the authored value (density would otherwise add to it). + let density = if explicit_mass { 0.0 } else { col.density }; + let collider = build_shape(col) + .friction(col.friction) + .restitution(col.restitution) + .density(density) + .sensor(col.sensor) + // Emit started/stopped events for this collider (off by default). + .active_events(ActiveEvents::COLLISION_EVENTS) + .collision_groups(InteractionGroups::new( + Group::from_bits_retain(col.membership.bits()), + Group::from_bits_retain(col.filter.bits()), + InteractionTestMode::And, + )); + + let (handle, collider_handle) = self.world.insert(body.build(), collider.build()); + self.entity_to_body.insert(e, handle); + self.body_to_entity.insert(handle, e); + self.entity_to_collider.insert(e, collider_handle); + self.collider_to_entity.insert(collider_handle, e); + } + + /// Writes each moved body's pose back onto its entity's local transform. + fn write_back(&self, scene: &mut Scene) { + for (&e, &h) in &self.entity_to_body { + let Some(body) = self.world.bodies.get(h) else { + continue; + }; + // Static bodies never move; skip the work (and the float churn). + if body.is_fixed() { + continue; + } + let pos = body.position(); + let world_t = Transform { + translation: from_vec(pos.translation), + rotation: from_quat(pos.rotation), + scale: Vec3::ONE, + }; + // Convert world → local through the parent (root: local == world). + // Only translation/rotation are driven; the entity keeps its scale. + let mut local = scene.local_transform(e).unwrap_or(Transform::IDENTITY); + let new_local = match scene.parent(e).and_then(|p| scene.world_transform(p)) { + Some(parent_world) => parent_world.inverse().mul_transform(&world_t), + None => world_t, + }; + local.translation = new_local.translation; + local.rotation = new_local.rotation; + scene.set_local_transform(e, local); + } + } +} + +/// Builds the rapier collider shape from an Oxide [`Collider`]. +fn build_shape(col: &Collider) -> ColliderBuilder { + match col.shape { + ColliderShape::Box => { + ColliderBuilder::cuboid(col.half_extents.x, col.half_extents.y, col.half_extents.z) + } + ColliderShape::Sphere => ColliderBuilder::ball(col.radius), + ColliderShape::Capsule => ColliderBuilder::capsule_y(col.half_height, col.radius), + ColliderShape::Cylinder => ColliderBuilder::cylinder(col.half_height, col.radius), + } +} + +/// The [`Schedule::FixedUpdate`](oxide_engine::app::Schedule::FixedUpdate) +/// system that advances the simulation one fixed step. Takes the +/// [`PhysicsWorld`] out of the app so it can borrow the scene mutably, then +/// re-inserts it. +pub(crate) fn step_physics(app: &mut App) { + let Some(mut world) = app.remove_resource::() else { + return; + }; + world.sync_and_step(app); + app.insert_resource(world); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::PhysicsModule; + use oxide_engine::app::{App, DefaultModules}; + use oxide_engine::math::Transform; + + /// Builds an app with physics and a fixed 60 Hz step, returning it ready to + /// `step()`. + fn physics_app() -> App { + let mut app = App::new(); + app.add_modules(DefaultModules); + app.add_module(PhysicsModule); + app + } + + fn spawn_dynamic_ball(app: &mut App, y: f32) -> Entity { + let e = app + .scene + .spawn("ball", Transform::from_translation(Vec3::new(0.0, y, 0.0))); + app.scene + .world_mut() + .insert_one(e, RigidBody::default()) + .unwrap(); + app.scene + .world_mut() + .insert_one(e, Collider::ball(0.5)) + .unwrap(); + e + } + + fn spawn_static_floor(app: &mut App) -> Entity { + let e = app + .scene + .spawn("floor", Transform::from_translation(Vec3::ZERO)); + app.scene + .world_mut() + .insert_one(e, RigidBody::static_body()) + .unwrap(); + app.scene + .world_mut() + .insert_one(e, Collider::cuboid(Vec3::new(10.0, 0.5, 10.0))) + .unwrap(); + e + } + + fn y_of(app: &App, e: Entity) -> f32 { + app.scene.local_transform(e).unwrap().translation.y + } + + #[test] + fn a_dropped_ball_falls() { + let mut app = physics_app(); + let ball = spawn_dynamic_ball(&mut app, 5.0); + let start = y_of(&app, ball); + app.step(); + app.step(); + assert!(y_of(&app, ball) < start, "ball should fall under gravity"); + } + + #[test] + fn a_dropped_ball_lands_on_the_floor_and_rests() { + let mut app = physics_app(); + spawn_static_floor(&mut app); + let ball = spawn_dynamic_ball(&mut app, 5.0); + + // ~3 s of simulation: plenty to fall 4.5 m and settle. + for _ in 0..180 { + app.step(); + } + let y = y_of(&app, ball); + // Floor top is at y=0.5, ball radius 0.5 → resting center ≈ 1.0. + assert!( + (y - 1.0).abs() < 0.1, + "ball should rest on the floor at ~y=1.0, got {y}" + ); + + // And it should be (nearly) at rest. + let world = app.get_resource::().unwrap(); + assert!( + world.linear_velocity(ball).length() < 0.05, + "ball should have come to rest" + ); + } + + #[test] + fn stacked_boxes_stay_stacked_without_jitter() { + let mut app = physics_app(); + spawn_static_floor(&mut app); + // Three unit boxes stacked: centers at 1.0, 2.0, 3.0 (floor top = 0.5, + // half-extent 0.5 → resting centers ≈ 1.0, 2.0, 3.0). + let mut boxes = Vec::new(); + for i in 0..3 { + let y = 1.0 + i as f32; + let e = app + .scene + .spawn("box", Transform::from_translation(Vec3::new(0.0, y, 0.0))); + app.scene + .world_mut() + .insert_one(e, RigidBody::default()) + .unwrap(); + app.scene + .world_mut() + .insert_one(e, Collider::cuboid(Vec3::splat(0.5))) + .unwrap(); + boxes.push((e, y)); + } + for _ in 0..240 { + app.step(); + } + // Each box should remain near its starting height (stable contact, no + // collapse, no explosion). + for (e, y0) in boxes { + let y = y_of(&app, e); + assert!( + (y - y0).abs() < 0.15, + "box should stay stacked near y={y0}, got {y}" + ); + } + } + + #[test] + fn a_static_floor_does_not_move() { + let mut app = physics_app(); + let floor = spawn_static_floor(&mut app); + for _ in 0..60 { + app.step(); + } + assert!(y_of(&app, floor).abs() < 1e-5, "static body must not move"); + } + + #[test] + fn gravity_scale_zero_floats() { + let mut app = physics_app(); + let e = app.scene.spawn( + "floater", + Transform::from_translation(Vec3::new(0.0, 3.0, 0.0)), + ); + app.scene + .world_mut() + .insert_one( + e, + RigidBody { + gravity_scale: 0.0, + ..RigidBody::default() + }, + ) + .unwrap(); + app.scene + .world_mut() + .insert_one(e, Collider::ball(0.5)) + .unwrap(); + for _ in 0..60 { + app.step(); + } + assert!( + (y_of(&app, e) - 3.0).abs() < 1e-3, + "a zero-gravity body should not fall" + ); + } + + #[test] + fn a_kinematic_body_ignores_gravity() { + let mut app = physics_app(); + let e = app.scene.spawn( + "platform", + Transform::from_translation(Vec3::new(0.0, 2.0, 0.0)), + ); + app.scene + .world_mut() + .insert_one(e, RigidBody::kinematic()) + .unwrap(); + app.scene + .world_mut() + .insert_one(e, Collider::cuboid(Vec3::splat(0.5))) + .unwrap(); + for _ in 0..60 { + app.step(); + } + assert!( + (y_of(&app, e) - 2.0).abs() < 1e-4, + "a kinematic body is not moved by forces" + ); + } + + #[test] + fn despawning_an_entity_removes_its_body() { + let mut app = physics_app(); + let ball = spawn_dynamic_ball(&mut app, 5.0); + app.step(); + assert_eq!(app.get_resource::().unwrap().body_count(), 1); + + app.scene + .despawn(ball, oxide_engine::scene::DespawnPolicy::Recursive); + app.step(); + assert_eq!(app.get_resource::().unwrap().body_count(), 0); + } + + /// Spawns a dynamic ball on the given layer (membership = filter = layer). + fn spawn_layered_ball(app: &mut App, pos: Vec3, layer: u32) -> Entity { + let e = app.scene.spawn("ball", Transform::from_translation(pos)); + app.scene + .world_mut() + .insert_one(e, RigidBody::default()) + .unwrap(); + let mask = oxide_engine::layer::LayerMask::layer(layer); + app.scene + .world_mut() + .insert_one(e, Collider::ball(0.5).with_layers(mask, mask)) + .unwrap(); + e + } + + #[test] + fn matching_layers_collide_but_mismatched_layers_pass_through() { + // Two balls dropped onto a static floor, side by side but overlapping in + // x; if they collide they push apart, if they don't they stay overlapped. + fn final_separation(layer_a: u32, layer_b: u32) -> f32 { + let mut app = physics_app(); + spawn_static_floor(&mut app); + let a = spawn_layered_ball(&mut app, Vec3::new(-0.2, 1.0, 0.0), layer_a); + let b = spawn_layered_ball(&mut app, Vec3::new(0.2, 1.0, 0.0), layer_b); + for _ in 0..120 { + app.step(); + } + let xa = app.scene.local_transform(a).unwrap().translation.x; + let xb = app.scene.local_transform(b).unwrap().translation.x; + (xa - xb).abs() + } + + // Same layer → they collide and push apart (separation grows past ~1.0, + // the sum of radii). + assert!( + final_separation(1, 1) > 0.9, + "same-layer balls should collide and separate" + ); + // Different, non-matching layers → they ignore each other and stay + // roughly where they started (separation ≈ 0.4). + assert!( + final_separation(1, 2) < 0.6, + "mismatched-layer balls should pass through each other" + ); + } + + #[test] + fn a_sensor_fires_a_trigger_event_and_does_not_block() { + let mut app = physics_app(); + // A static sensor box straddling y=2.5..3.5 (center y=3, half 0.5). + let sensor = app.scene.spawn( + "trigger", + Transform::from_translation(Vec3::new(0.0, 3.0, 0.0)), + ); + app.scene + .world_mut() + .insert_one(sensor, RigidBody::static_body()) + .unwrap(); + app.scene + .world_mut() + .insert_one(sensor, Collider::cuboid(Vec3::splat(0.5)).as_sensor()) + .unwrap(); + // A ball dropped from above the sensor. + let ball = spawn_dynamic_ball(&mut app, 5.0); + + let mut saw_enter = false; + let mut saw_exit = false; + for _ in 0..180 { + app.step(); + let world = app.get_resource::().unwrap(); + for ev in world.trigger_events() { + assert!(ev.sensor); + assert_eq!(ev.other(sensor), Some(ball)); + if ev.started { + saw_enter = true; + } else { + saw_exit = true; + } + } + } + assert!(saw_enter, "ball should have entered the sensor"); + assert!( + saw_exit, + "ball should have passed through and exited the sensor" + ); + // And it passed through — it is now well below the sensor. + assert!( + y_of(&app, ball) < 2.0, + "a sensor must not block the ball, got y={}", + y_of(&app, ball) + ); + } + + #[test] + fn contact_events_fire_when_a_ball_lands() { + let mut app = physics_app(); + let floor = spawn_static_floor(&mut app); + let ball = spawn_dynamic_ball(&mut app, 3.0); + let mut saw_contact = false; + for _ in 0..120 { + app.step(); + let world = app.get_resource::().unwrap(); + if world + .contact_events() + .any(|e| e.started && e.other(ball) == Some(floor)) + { + saw_contact = true; + } + } + assert!(saw_contact, "the ball landing should fire a contact event"); + // While resting it should report as intersecting the floor ("stay"). + assert!(app + .get_resource::() + .unwrap() + .is_intersecting(ball, floor)); + } + + #[test] + fn a_raycast_hits_the_floor_and_reports_point_and_normal() { + let mut app = physics_app(); + spawn_static_floor(&mut app); // top at y=0.5 + let floor = app.scene.roots()[0]; + app.step(); // register bodies + + let world = app.get_resource::().unwrap(); + // Cast straight down from above. + let hit = world + .raycast( + Vec3::new(0.0, 5.0, 0.0), + Vec3::new(0.0, -1.0, 0.0), + 10.0, + LayerMask::ALL, + ) + .expect("ray should hit the floor"); + assert_eq!(hit.entity, floor); + // Floor top is y=0.5, so the ray travels ~4.5 m. + assert!((hit.toi - 4.5).abs() < 0.05, "toi={}", hit.toi); + assert!((hit.point.y - 0.5).abs() < 0.05, "point.y={}", hit.point.y); + // Up-facing surface. + assert!(hit.normal.y > 0.9, "normal={:?}", hit.normal); + } + + #[test] + fn sync_to_scene_makes_the_edited_scene_queryable_without_a_step() { + // The editor's raycast probe queries the *edited* scene outside Play, so + // it needs a query-able world built straight from components — no step. + let mut app = physics_app(); + let floor = spawn_static_floor(&mut app); // top at y=0.5 + + let mut world = PhysicsWorld::new(); + world.sync_to_scene(&app.scene); + assert_eq!(world.body_count(), 1, "sync should register the floor body"); + + let hit = world + .raycast( + Vec3::new(0.0, 5.0, 0.0), + Vec3::new(0.0, -1.0, 0.0), + 10.0, + LayerMask::ALL, + ) + .expect("ray should hit the floor right after sync, with no step"); + assert_eq!(hit.entity, floor); + assert!((hit.point.y - 0.5).abs() < 0.05, "point.y={}", hit.point.y); + assert!(hit.normal.y > 0.9, "normal={:?}", hit.normal); + } + + #[test] + fn a_raycast_respects_the_layer_mask() { + let mut app = physics_app(); + // Floor on layer 3 only. + let e = app + .scene + .spawn("floor", Transform::from_translation(Vec3::ZERO)); + app.scene + .world_mut() + .insert_one(e, RigidBody::static_body()) + .unwrap(); + let mask = LayerMask::layer(3); + app.scene + .world_mut() + .insert_one( + e, + Collider::cuboid(Vec3::new(10.0, 0.5, 10.0)).with_layers(mask, mask), + ) + .unwrap(); + app.step(); + + let world = app.get_resource::().unwrap(); + let down = Vec3::new(0.0, -1.0, 0.0); + // A query on layer 3 hits it; a query on layer 1 misses. + assert!(world + .raycast(Vec3::new(0.0, 5.0, 0.0), down, 10.0, LayerMask::layer(3)) + .is_some()); + assert!(world + .raycast(Vec3::new(0.0, 5.0, 0.0), down, 10.0, LayerMask::layer(1)) + .is_none()); + } + + #[test] + fn a_sphere_cast_hits_a_wall() { + let mut app = physics_app(); + // A vertical wall at x=2. + let e = app.scene.spawn( + "wall", + Transform::from_translation(Vec3::new(2.0, 0.0, 0.0)), + ); + app.scene + .world_mut() + .insert_one(e, RigidBody::static_body()) + .unwrap(); + app.scene + .world_mut() + .insert_one(e, Collider::cuboid(Vec3::new(0.5, 5.0, 5.0))) + .unwrap(); + app.step(); + + let world = app.get_resource::().unwrap(); + let hit = world + .sphere_cast( + Vec3::ZERO, + 0.5, + Vec3::new(1.0, 0.0, 0.0), + 10.0, + LayerMask::ALL, + ) + .expect("sphere should hit the wall"); + assert_eq!(hit.entity, e); + // Wall face at x=1.5, sphere radius 0.5 → contact when center at x=1.0. + assert!((hit.toi - 1.0).abs() < 0.05, "toi={}", hit.toi); + } + + #[test] + fn overlap_and_point_queries_find_colliders() { + let mut app = physics_app(); + let e = app + .scene + .spawn("box", Transform::from_translation(Vec3::new(0.0, 0.0, 0.0))); + app.scene + .world_mut() + .insert_one(e, RigidBody::static_body()) + .unwrap(); + app.scene + .world_mut() + .insert_one(e, Collider::cuboid(Vec3::splat(1.0))) + .unwrap(); + app.step(); + + let world = app.get_resource::().unwrap(); + // A sphere overlapping the box. + assert_eq!( + world.overlap_sphere(Vec3::new(0.5, 0.0, 0.0), 0.5, LayerMask::ALL), + vec![e] + ); + // A sphere far away overlaps nothing. + assert!(world + .overlap_sphere(Vec3::new(10.0, 0.0, 0.0), 0.5, LayerMask::ALL) + .is_empty()); + // The origin is inside the box. + assert_eq!(world.point_overlap(Vec3::ZERO, LayerMask::ALL), vec![e]); + // A point outside is not. + assert!(world + .point_overlap(Vec3::new(5.0, 0.0, 0.0), LayerMask::ALL) + .is_empty()); + } + + /// Spawns a small free-floating dynamic body (tiny ball, no gravity unless + /// asked) for joint tests, returning its entity. + fn spawn_joint_body(app: &mut App, pos: Vec3, kind: RigidBodyKind) -> Entity { + let e = app.scene.spawn("jb", Transform::from_translation(pos)); + let rb = RigidBody { + kind, + ..RigidBody::default() + }; + app.scene.world_mut().insert_one(e, rb).unwrap(); + app.scene + .world_mut() + .insert_one(e, Collider::ball(0.1)) + .unwrap(); + e + } + + fn pos_of(app: &App, e: Entity) -> Vec3 { + app.scene.local_transform(e).unwrap().translation + } + + #[test] + fn a_fixed_joint_welds_a_body_to_a_static_anchor() { + let mut app = physics_app(); + let anchor = spawn_joint_body(&mut app, Vec3::ZERO, RigidBodyKind::Static); + let body = spawn_joint_body(&mut app, Vec3::new(0.0, -2.0, 0.0), RigidBodyKind::Dynamic); + app.step(); // register bodies + // Anchor A's local point (0,-2,0) welded to B's origin. + app.get_resource_mut::() + .unwrap() + .add_joint( + anchor, + body, + JointKind::Fixed, + Vec3::new(0.0, -2.0, 0.0), + Vec3::ZERO, + ) + .expect("both bodies are registered"); + + for _ in 0..120 { + app.step(); + } + // A fixed joint to a static anchor fully constrains B: it does not fall. + let p = pos_of(&app, body); + assert!( + (p - Vec3::new(0.0, -2.0, 0.0)).length() < 0.05, + "fixed-jointed body should stay put, got {p:?}" + ); + } + + #[test] + fn a_revolute_joint_keeps_a_pendulum_at_constant_radius() { + let mut app = physics_app(); + let pivot = spawn_joint_body(&mut app, Vec3::ZERO, RigidBodyKind::Static); + let bob = spawn_joint_body(&mut app, Vec3::new(1.0, 0.0, 0.0), RigidBodyKind::Dynamic); + app.step(); + // Hinge about Z at the world origin: A's anchor at its origin, B's anchor + // one unit back so the two coincide at the start. + app.get_resource_mut::() + .unwrap() + .add_joint( + pivot, + bob, + JointKind::Revolute { axis: Vec3::Z }, + Vec3::ZERO, + Vec3::new(-1.0, 0.0, 0.0), + ) + .unwrap(); + + let mut swung_down = false; + for _ in 0..240 { + app.step(); + let p = pos_of(&app, bob); + // The revolute joint pins B to a circle of radius 1 about the origin. + assert!( + (p.length() - 1.0).abs() < 0.1, + "pendulum radius should stay ~1, got {} at {p:?}", + p.length() + ); + if p.y < -0.5 { + swung_down = true; + } + } + assert!( + swung_down, + "the pendulum should swing downward under gravity" + ); + } + + #[test] + fn a_prismatic_joint_only_slides_along_its_axis() { + let mut app = physics_app(); + let anchor = spawn_joint_body(&mut app, Vec3::ZERO, RigidBodyKind::Static); + let slider = spawn_joint_body(&mut app, Vec3::new(0.0, -1.0, 0.0), RigidBodyKind::Dynamic); + app.step(); + // Slide only along Y. + app.get_resource_mut::() + .unwrap() + .add_joint( + anchor, + slider, + JointKind::Prismatic { axis: Vec3::Y }, + Vec3::ZERO, + Vec3::ZERO, + ) + .unwrap(); + + for _ in 0..120 { + app.step(); + } + let p = pos_of(&app, slider); + // Gravity slides it down along Y; x/z stay pinned. + assert!(p.y < -1.0, "slider should fall along Y, got {p:?}"); + assert!( + p.x.abs() < 1e-3 && p.z.abs() < 1e-3, + "off-axis drift: {p:?}" + ); + } + + #[test] + fn joints_can_be_removed() { + let mut app = physics_app(); + let a = spawn_joint_body(&mut app, Vec3::ZERO, RigidBodyKind::Static); + let b = spawn_joint_body(&mut app, Vec3::new(0.0, -1.0, 0.0), RigidBodyKind::Dynamic); + app.step(); + let world = app.get_resource_mut::().unwrap(); + let j = world + .add_joint( + a, + b, + JointKind::Fixed, + Vec3::new(0.0, -1.0, 0.0), + Vec3::ZERO, + ) + .unwrap(); + assert_eq!(world.joint_count(), 1); + world.remove_joint(j); + assert_eq!(world.joint_count(), 0); + // With the joint gone, B falls freely. + for _ in 0..60 { + app.step(); + } + assert!(pos_of(&app, b).y < -1.5, "freed body should fall"); + } + + /// Spawns a capsule character at `pos` and returns its entity. + fn spawn_character(app: &mut App, pos: Vec3) -> Entity { + let e = app.scene.spawn("player", Transform::from_translation(pos)); + app.scene + .world_mut() + .insert_one(e, CharacterController::default()) + .unwrap(); + e + } + + /// Moves the character one step (gravity + `horizontal`) and applies the + /// resolved translation, returning whether it ended grounded. + fn move_character_step(app: &mut App, e: Entity, horizontal: Vec3, dt: f32) -> bool { + let desired = horizontal + Vec3::new(0.0, -9.81 * dt, 0.0); + let movement = { + let world = app.get_resource::().unwrap(); + world.move_character(&app.scene, e, desired, dt).unwrap() + }; + let mut t = app.scene.local_transform(e).unwrap(); + t.translation += movement.translation; + app.scene.set_local_transform(e, t); + movement.grounded + } + + #[test] + fn a_character_settles_on_the_floor_and_reports_grounded() { + let mut app = physics_app(); + spawn_static_floor(&mut app); // top at y=0.5 + // Capsule total half-height 0.9 → rests with center at y≈1.4. Start above. + let player = spawn_character(&mut app, Vec3::new(0.0, 2.0, 0.0)); + app.step(); // register the floor body + + let mut grounded = false; + for _ in 0..120 { + grounded = move_character_step(&mut app, player, Vec3::ZERO, 1.0 / 60.0); + } + let y = pos_of(&app, player).y; + assert!(grounded, "character should be grounded on the floor"); + assert!( + (y - 1.4).abs() < 0.1, + "character should rest at ~y=1.4, got {y}" + ); + } + + #[test] + fn a_character_is_blocked_by_a_wall() { + let mut app = physics_app(); + spawn_static_floor(&mut app); + // A wall whose near face is at x=1.5. + let wall = app.scene.spawn( + "wall", + Transform::from_translation(Vec3::new(2.0, 2.0, 0.0)), + ); + app.scene + .world_mut() + .insert_one(wall, RigidBody::static_body()) + .unwrap(); + app.scene + .world_mut() + .insert_one(wall, Collider::cuboid(Vec3::new(0.5, 2.0, 5.0))) + .unwrap(); + let player = spawn_character(&mut app, Vec3::new(0.0, 1.4, 0.0)); + app.step(); + + // Walk hard into the wall. + for _ in 0..180 { + move_character_step(&mut app, player, Vec3::new(0.1, 0.0, 0.0), 1.0 / 60.0); + } + let x = pos_of(&app, player).x; + // Capsule radius 0.3 → stops with center near x=1.2; never past the face. + assert!( + x < 1.3, + "character should be blocked before the wall, got x={x}" + ); + } + + #[test] + fn a_character_climbs_a_low_step_but_not_a_high_one() { + // Returns the character's final height after walking +X into a step + // whose top is at `step_top` (the floor surface is at y=0.5, so the + // climb height is `step_top - 0.5`). 50 steps end the walk on top of a + // climbable step (which spans x in [0.5, 3.5]) without running off the + // floor's edge. + fn final_height(step_top: f32) -> f32 { + let mut app = physics_app(); + spawn_static_floor(&mut app); // top at 0.5 + let step = app.scene.spawn( + "step", + Transform::from_translation(Vec3::new(2.0, step_top / 2.0, 0.0)), + ); + app.scene + .world_mut() + .insert_one(step, RigidBody::static_body()) + .unwrap(); + app.scene + .world_mut() + .insert_one(step, Collider::cuboid(Vec3::new(1.5, step_top / 2.0, 5.0))) + .unwrap(); + let player = spawn_character(&mut app, Vec3::new(-0.5, 1.4, 0.0)); + app.step(); + for _ in 0..50 { + move_character_step(&mut app, player, Vec3::new(0.08, 0.0, 0.0), 1.0 / 60.0); + } + pos_of(&app, player).y + } + + // step_offset defaults to 0.3. A step rising 0.2 above the floor (top + // 0.7) is climbable → the character ends up on it (center ≈ 0.7+0.9=1.6). + let climbed = final_height(0.7); + assert!( + climbed > 1.5, + "should climb the low step, ended at y={climbed}" + ); + // A step rising 0.4 above the floor (top 0.9) exceeds the offset → + // blocked; the character stays at floor height (center ≈ 1.4). + let blocked = final_height(0.9); + assert!( + blocked < 1.5, + "should not climb the high step, ended at y={blocked}" + ); + } + + #[test] + fn an_impulse_launches_a_floating_body() { + let mut app = physics_app(); + let e = app + .scene + .spawn("proj", Transform::from_translation(Vec3::ZERO)); + app.scene + .world_mut() + .insert_one( + e, + RigidBody { + gravity_scale: 0.0, + ..RigidBody::default() + }, + ) + .unwrap(); + app.scene + .world_mut() + .insert_one(e, Collider::ball(0.5)) + .unwrap(); + // Register the body first. + app.step(); + app.get_resource_mut::() + .unwrap() + .apply_impulse(e, Vec3::new(0.0, 0.0, 5.0)); + for _ in 0..30 { + app.step(); + } + let z = app.scene.local_transform(e).unwrap().translation.z; + assert!(z > 0.5, "impulse should move the body along +Z, got {z}"); + } +} diff --git a/script/Cargo.toml b/script/Cargo.toml new file mode 100644 index 0000000..26e1ee2 --- /dev/null +++ b/script/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "oxide-script" +description = "Oxide 3D game engine — scripting module (rhai)" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +oxide-engine = { path = "../engine" } +glam.workspace = true +hecs.workspace = true +serde.workspace = true +ron.workspace = true +thiserror.workspace = true +log.workspace = true + +# The scripting backend. rhai is embeddable and sandboxed (no file/OS access by +# default), which is exactly what an in-engine, hot-reloaded game-logic language +# needs. +# - `sync` makes its handles `Send + Sync` so a compiled script can live in an +# App resource alongside the rest of the engine state. +# - `f32_float` makes the script `FLOAT` type `f32`, matching `glam`, so the +# engine math types (`Vec3`, angles, …) bridge into scripts with no casts. +rhai = { version = "1.21", features = ["sync", "f32_float"] } + +[dev-dependencies] +ron.workspace = true diff --git a/script/src/asset.rs b/script/src/asset.rs new file mode 100644 index 0000000..deb8b4f --- /dev/null +++ b/script/src/asset.rs @@ -0,0 +1,101 @@ +//! The [`ScriptAsset`] — a loaded script's source — and its [`ScriptLoader`]. +//! +//! A script lives on disk as a `.rhai` file under the project's `assets/scripts/` +//! folder. Loading one yields a [`ScriptAsset`], which is just the source text +//! plus its origin path; turning that text into something executable (a compiled +//! `rhai` AST) is the job of the [`ScriptEngine`](crate::ScriptEngine), done at +//! run time so a live edit can recompile without touching the asset plumbing. +//! +//! Keeping the asset as plain source (rather than a pre-compiled AST) is what +//! makes hot-reload cheap: the file watcher swaps in fresh source on change and +//! the host recompiles, with no engine-specific data baked into the asset cache. + +use std::path::Path; + +use oxide_engine::asset::{AssetError, AssetLoader}; + +/// A loaded script: its source text and the path it came from. +/// +/// This is the *asset* a [`Script`](crate::Script) component points at via an +/// [`AssetRef`](oxide_engine::asset::AssetRef). It is deliberately +/// inert — holding source, not behaviour — so the same file can be recompiled on +/// every live reload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScriptAsset { + /// The script's source code. + pub source: String, + /// A human-readable name for diagnostics (the file stem, when loaded from + /// disk), used in error messages and the script console. + pub name: String, +} + +impl ScriptAsset { + /// Builds an asset from in-memory source with the given diagnostic `name`. + pub fn from_source(name: impl Into, source: impl Into) -> Self { + Self { + source: source.into(), + name: name.into(), + } + } +} + +/// The [`AssetServer`](oxide_engine::asset::AssetServer) loader for `.rhai` +/// scripts. +/// +/// Registered by the [`ScriptModule`](crate::ScriptModule) (handles `.rhai`), so +/// `assets.load::("scripts/spin.rhai")` works once the module is +/// added. It reads the file as UTF-8 and records the file stem as the asset's +/// diagnostic name. +pub struct ScriptLoader; + +impl AssetLoader for ScriptLoader { + type Asset = ScriptAsset; + + fn extensions(&self) -> &'static [&'static str] { + &["rhai"] + } + + fn load(&self, path: &Path) -> Result { + let source = std::fs::read_to_string(path).map_err(|err| AssetError::Load { + path: path.to_path_buf(), + message: err.to_string(), + })?; + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("script") + .to_string(); + Ok(ScriptAsset { source, name }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_source_keeps_name_and_text() { + let a = ScriptAsset::from_source("spin", "let x = 1;"); + assert_eq!(a.name, "spin"); + assert_eq!(a.source, "let x = 1;"); + } + + #[test] + fn loader_reads_a_file_and_uses_the_stem_as_name() { + let dir = std::env::temp_dir().join("oxide-script-loader-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("hello.rhai"); + std::fs::write(&path, "print(\"hi\");").unwrap(); + + let asset = ScriptLoader.load(&path).unwrap(); + assert_eq!(asset.name, "hello"); + assert!(asset.source.contains("print")); + + std::fs::remove_file(&path).ok(); + } + + #[test] + fn loader_claims_the_rhai_extension() { + assert_eq!(ScriptLoader.extensions(), &["rhai"]); + } +} diff --git a/script/src/bridge.rs b/script/src/bridge.rs new file mode 100644 index 0000000..7e001d2 --- /dev/null +++ b/script/src/bridge.rs @@ -0,0 +1,374 @@ +//! The **engine API** exposed to scripts, and the shared [`ScriptContext`] that +//! backs it. +//! +//! A script does not get a raw pointer into the ECS; instead the host stages the +//! current entity's [`Transform`] into a shared [`ScriptContext`] before each +//! call, the script reads and mutates it through ambient functions (`position`, +//! `translate`, `rotate_y`, …), and the host writes the result back to the ECS +//! afterwards. This keeps the bridge tiny and single-threaded-safe while giving +//! scripts a Unity-like `transform`-style API. +//! +//! The context is shared as an `Arc>` because the `rhai` `sync` feature +//! requires every registered function to be `Send + Sync`; the editor runs +//! scripts one at a time, so the mutex is never actually contended. + +use std::sync::{Arc, Mutex}; + +use oxide_engine::math::{Quat, Transform, Vec3}; +use oxide_engine::scene::Entity; +use rhai::Engine; + +/// A script-facing reference to an entity. +/// +/// A script can name either a **real** entity (e.g. its own, via `entity()`) or +/// one it `spawn`ed earlier this same frame, which does not exist in the ECS yet +/// — the latter is a **provisional** id the host resolves to a real [`Entity`] +/// when it drains and applies the [`ScriptCommand`] buffer. This lets a script +/// spawn an entity and configure it in one go without the host round-tripping +/// the new id back into the running script. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EntityHandle { + /// An entity that already exists in the scene. + Real(Entity), + /// An entity `spawn`ed this frame, identified by a monotonic id until the + /// host creates it and maps the id to a real [`Entity`]. + Provisional(u64), +} + +/// A scene mutation a script requested, buffered for the host to apply after the +/// script returns. +/// +/// Scripts cannot touch the ECS directly (the `rhai` engine's registered +/// functions must be `Send + Sync` and hold no borrow of the world), so every +/// structural change a script makes is recorded here and replayed by the host +/// against the scene + reflection registry — the same "stage in, read back out" +/// discipline the transform API uses, extended to the whole entity/component +/// graph. Component edits go through RON so they round-trip the reflection +/// registry exactly like the inspector and AI agents do. +#[derive(Debug, Clone)] +pub(crate) enum ScriptCommand { + /// Create a new root entity with the given node name; its provisional id is + /// the one handed back to the script by `spawn`. + Spawn { + /// The provisional id the script holds for the new entity. + provisional: u64, + /// The new entity's node name. + name: String, + }, + /// Despawn an entity (recursively, taking its subtree). + Despawn { + /// The entity to remove. + target: EntityHandle, + }, + /// Add a default-constructed component of the named type, if absent. + AddComponent { + /// The entity to add to. + target: EntityHandle, + /// The registered component type name. + type_name: String, + }, + /// Insert or replace a component from its RON serialization. + SetComponent { + /// The entity to write to. + target: EntityHandle, + /// The registered component type name. + type_name: String, + /// The component value as RON. + ron: String, + }, + /// Remove the named component if present. + RemoveComponent { + /// The entity to remove from. + target: EntityHandle, + /// The registered component type name. + type_name: String, + }, +} + +/// The per-call scratch a running script reads from and writes to. +/// +/// The host sets [`transform`](Self::transform) to the current entity's pose and +/// [`dt`](Self::dt) to the frame delta before invoking the script, then reads +/// `transform` back out to apply the script's changes. +#[derive(Debug, Clone, Default)] +pub(crate) struct ScriptContext { + /// The active entity's transform — staged in by the host, mutated by the + /// script, read back out by the host. + pub transform: Transform, + /// The current frame delta (seconds), also passed to `update(dt)`. + pub dt: f32, + /// The entity the script currently runs on, so `entity()` can name it. + pub current: Option, + /// Scene mutations the script requested this call; drained by the host. + pub commands: Vec, + /// Monotonic source of provisional ids for `spawn`. Never reset, so ids stay + /// unique across a frame's `init` + `update` calls (the host maps each to a + /// real entity), avoiding collisions in the apply step. + pub next_provisional: u64, +} + +/// The shared handle the engine's registered functions close over. +pub(crate) type SharedContext = Arc>; + +/// Registers Oxide's engine API on `engine`, backed by the shared `ctx`. +/// +/// This is the surface a script sees: the [`Vec3`] type plus the ambient +/// transform functions. It is intentionally small for this first piece — more +/// engine types and component accessors layer on here in later pieces. +pub(crate) fn register_api(engine: &mut Engine, ctx: &SharedContext) { + register_vec3(engine); + register_transform_api(engine, ctx); + register_world_api(engine, ctx); +} + +/// Registers the [`Vec3`] type with constructors, component access, and the +/// arithmetic a script needs to do vector math. +fn register_vec3(engine: &mut Engine) { + engine + .register_type_with_name::("Vec3") + .register_fn("vec3", Vec3::new) + .register_fn("vec3", || Vec3::ZERO) + .register_get_set("x", |v: &mut Vec3| v.x, |v: &mut Vec3, x: f32| v.x = x) + .register_get_set("y", |v: &mut Vec3| v.y, |v: &mut Vec3, y: f32| v.y = y) + .register_get_set("z", |v: &mut Vec3| v.z, |v: &mut Vec3, z: f32| v.z = z) + .register_fn("+", |a: Vec3, b: Vec3| a + b) + .register_fn("-", |a: Vec3, b: Vec3| a - b) + .register_fn("*", |a: Vec3, s: f32| a * s) + .register_fn("*", |s: f32, a: Vec3| a * s) + .register_fn("length", |v: &mut Vec3| v.length()) + .register_fn("normalize", |v: &mut Vec3| v.normalize_or_zero()) + .register_fn("to_string", |v: &mut Vec3| { + format!("({}, {}, {})", v.x, v.y, v.z) + }); +} + +/// Registers the ambient transform API: functions that read and mutate the +/// active entity's pose via the shared context. +fn register_transform_api(engine: &mut Engine, ctx: &SharedContext) { + let c = ctx.clone(); + engine.register_fn("position", move || c.lock().unwrap().transform.translation); + + let c = ctx.clone(); + engine.register_fn("set_position", move |p: Vec3| { + c.lock().unwrap().transform.translation = p; + }); + + let c = ctx.clone(); + engine.register_fn("translate", move |v: Vec3| { + c.lock().unwrap().transform.translation += v; + }); + let c = ctx.clone(); + engine.register_fn("translate", move |x: f32, y: f32, z: f32| { + c.lock().unwrap().transform.translation += Vec3::new(x, y, z); + }); + + let c = ctx.clone(); + engine.register_fn("scale", move || c.lock().unwrap().transform.scale); + let c = ctx.clone(); + engine.register_fn("set_scale", move |s: Vec3| { + c.lock().unwrap().transform.scale = s; + }); + + // Rotations are right-handed, in radians, pre-multiplied onto the current + // orientation (so repeated calls accumulate spin). + let c = ctx.clone(); + engine.register_fn("rotate_x", move |angle: f32| { + let mut g = c.lock().unwrap(); + g.transform.rotation = Quat::from_rotation_x(angle) * g.transform.rotation; + }); + let c = ctx.clone(); + engine.register_fn("rotate_y", move |angle: f32| { + let mut g = c.lock().unwrap(); + g.transform.rotation = Quat::from_rotation_y(angle) * g.transform.rotation; + }); + let c = ctx.clone(); + engine.register_fn("rotate_z", move |angle: f32| { + let mut g = c.lock().unwrap(); + g.transform.rotation = Quat::from_rotation_z(angle) * g.transform.rotation; + }); + + let c = ctx.clone(); + engine.register_fn("dt", move || c.lock().unwrap().dt); +} + +/// Registers the **world API**: the [`Entity`] handle type plus the ambient +/// functions a script uses to spawn/despawn entities and add, edit, or remove +/// their components. Every mutation is recorded as a [`ScriptCommand`] in the +/// shared context for the host to apply after the script returns. +fn register_world_api(engine: &mut Engine, ctx: &SharedContext) { + engine + .register_type_with_name::("Entity") + .register_fn("to_string", |e: &mut EntityHandle| match e { + EntityHandle::Real(ent) => format!("Entity({})", ent.to_bits()), + EntityHandle::Provisional(id) => format!("Entity(new#{id})"), + }); + + // The entity this script runs on. Provisional(0) is never produced by + // `spawn` (its counter starts at 1), so it reads as an unset placeholder if + // the host forgot to stage a current entity — which never happens in normal + // operation. + let c = ctx.clone(); + engine.register_fn("entity", move || { + c.lock() + .unwrap() + .current + .map(EntityHandle::Real) + .unwrap_or(EntityHandle::Provisional(0)) + }); + + // spawn_entity() / spawn_entity(name) -> a provisional Entity, created on + // apply. (`spawn` is a reserved word in `rhai`, hence the longer name.) + let c = ctx.clone(); + engine.register_fn("spawn_entity", move || spawn(&c, "Entity")); + let c = ctx.clone(); + engine.register_fn("spawn_entity", move |name: &str| spawn(&c, name)); + + let c = ctx.clone(); + engine.register_fn("despawn", move |target: EntityHandle| { + c.lock() + .unwrap() + .commands + .push(ScriptCommand::Despawn { target }); + }); + + let c = ctx.clone(); + engine.register_fn( + "add_component", + move |target: EntityHandle, type_name: &str| { + c.lock() + .unwrap() + .commands + .push(ScriptCommand::AddComponent { + target, + type_name: type_name.to_string(), + }); + }, + ); + + let c = ctx.clone(); + engine.register_fn( + "set_component", + move |target: EntityHandle, type_name: &str, ron: &str| { + c.lock() + .unwrap() + .commands + .push(ScriptCommand::SetComponent { + target, + type_name: type_name.to_string(), + ron: ron.to_string(), + }); + }, + ); + + let c = ctx.clone(); + engine.register_fn( + "remove_component", + move |target: EntityHandle, type_name: &str| { + c.lock() + .unwrap() + .commands + .push(ScriptCommand::RemoveComponent { + target, + type_name: type_name.to_string(), + }); + }, + ); +} + +/// Allocates a fresh provisional id, records a [`ScriptCommand::Spawn`], and +/// hands the provisional handle back to the script. +fn spawn(ctx: &SharedContext, name: &str) -> EntityHandle { + let mut g = ctx.lock().unwrap(); + g.next_provisional += 1; + let provisional = g.next_provisional; + g.commands.push(ScriptCommand::Spawn { + provisional, + name: name.to_string(), + }); + EntityHandle::Provisional(provisional) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ScriptAsset, ScriptEngine}; + + /// A throwaway entity id for unit tests that exercise the context directly + /// without a real scene. + fn dummy_entity() -> Entity { + // hecs packs a nonzero generation in the high 32 bits; id 0, generation 1. + Entity::from_bits(1 << 32).expect("valid entity bits") + } + + #[test] + fn a_script_can_read_and_translate_the_transform() { + let engine = ScriptEngine::new(); + engine.set_context( + dummy_entity(), + Transform::from_translation(Vec3::new(1.0, 0.0, 0.0)), + 0.5, + ); + + let compiled = engine + .compile(&ScriptAsset::from_source( + "move", + "translate(vec3(2.0, 0.0, 0.0)); set_position(position() + vec3(0.0, dt(), 0.0));", + )) + .unwrap(); + engine.run(&compiled).unwrap(); + + let t = engine.take_transform(); + assert!((t.translation.x - 3.0).abs() < 1e-6); + assert!((t.translation.y - 0.5).abs() < 1e-6); // dt was 0.5 + } + + #[test] + fn world_calls_buffer_commands_for_the_host() { + let engine = ScriptEngine::new(); + engine.set_context(dummy_entity(), Transform::IDENTITY, 0.016); + + // spawn returns a provisional handle the script can configure at once. + let compiled = engine + .compile(&ScriptAsset::from_source( + "spawner", + r#" + let e = spawn_entity("Bullet"); + set_component(e, "MeshRenderer", "()"); + add_component(entity(), "Marker"); + despawn(e); + "#, + )) + .unwrap(); + engine.run(&compiled).unwrap(); + + let cmds = engine.take_commands(); + assert_eq!(cmds.len(), 4, "four world calls were buffered"); + match &cmds[0] { + ScriptCommand::Spawn { provisional, name } => { + assert_eq!(provisional, &1); + assert_eq!(name, "Bullet"); + } + other => panic!("expected Spawn, got {other:?}"), + } + // The provisional id from spawn flows into the later set_component/despawn. + assert!(matches!( + &cmds[1], + ScriptCommand::SetComponent { target: EntityHandle::Provisional(1), type_name, .. } + if type_name == "MeshRenderer" + )); + assert!(matches!( + &cmds[2], + ScriptCommand::AddComponent { target: EntityHandle::Real(_), type_name } + if type_name == "Marker" + )); + assert!(matches!( + &cmds[3], + ScriptCommand::Despawn { + target: EntityHandle::Provisional(1) + } + )); + + // Draining cleared the buffer. + assert!(engine.take_commands().is_empty()); + } +} diff --git a/script/src/component.rs b/script/src/component.rs new file mode 100644 index 0000000..e4b00a5 --- /dev/null +++ b/script/src/component.rs @@ -0,0 +1,85 @@ +//! The [`Script`] component — attaches a `rhai` script to an entity. +//! +//! Like every Oxide component, `Script` is plain serializable data with +//! `#[derive(Reflect)]`, so it is editable from the inspector and from scripts +//! with no per-type editor code, and is captured by the play-mode snapshot. It +//! holds only *authoring* inputs — which script to run and whether it is active. +//! The compiled AST and per-entity runtime state are **not** stored here; they +//! live in the host (added in a later piece) keyed by entity, so a live reload +//! can recompile without disturbing the authored scene. + +use oxide_engine::asset::AssetRef; +use oxide_engine::reflect::Reflect; +use serde::{Deserialize, Serialize}; + +use crate::ScriptAsset; + +/// Component: a `rhai` script driving an entity. +/// +/// Attach it and point [`source`](Self::source) at a `.rhai` asset; once the +/// [`ScriptModule`](crate::ScriptModule) is running, the script's lifecycle +/// hooks (`init`, `update(dt)`, …) execute for this entity. Clear +/// [`enabled`](Self::enabled) to suspend it without detaching. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, Reflect)] +pub struct Script { + /// The `.rhai` script asset this entity runs. Empty until one is assigned + /// (in the inspector, pick a script from the `scripts/` folder). + pub source: AssetRef, + /// Whether the script runs. `true` by default; clear to suspend it while + /// keeping the component attached. + pub enabled: bool, +} + +impl Script { + /// A script component pointing at `source`, enabled. + pub fn new(source: AssetRef) -> Self { + Self { + source, + enabled: true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oxide_engine::asset::AssetUid; + + #[test] + fn default_is_empty_and_disabled_until_constructed() { + // Derived Default: no source, and `enabled` is the bool default (false). + let s = Script::default(); + assert!(!s.source.is_some()); + assert!(!s.enabled); + } + + #[test] + fn new_points_at_a_source_and_is_enabled() { + let s = Script::new(AssetRef::new(AssetUid(7))); + assert_eq!(s.source.uid(), Some(AssetUid(7))); + assert!(s.enabled); + } + + #[test] + fn round_trips_through_ron() { + let s = Script::new(AssetRef::new(AssetUid(42))); + let text = ron::to_string(&s).unwrap(); + let back: Script = ron::from_str(&text).unwrap(); + assert_eq!(s, back); + } + + #[test] + fn the_source_field_is_a_script_asset_ref() { + // The inspector reads this spelling to offer a `scripts/` asset picker. + let s = Script::default(); + let field = s + .fields() + .iter() + .find(|f| f.name == "source") + .expect("source field is reflected"); + assert_eq!( + oxide_engine::asset::asset_ref_target(field.type_name), + Some("ScriptAsset") + ); + } +} diff --git a/script/src/engine.rs b/script/src/engine.rs new file mode 100644 index 0000000..0e16b48 --- /dev/null +++ b/script/src/engine.rs @@ -0,0 +1,271 @@ +//! The [`ScriptEngine`] — a thin wrapper over a `rhai` interpreter — plus the +//! [`CompiledScript`] handle and the [`ScriptError`] type. +//! +//! `rhai` is sandboxed by default (no filesystem or OS access), which is exactly +//! what hot-reloaded game logic wants. This wrapper owns one configured engine +//! that the host reuses to compile every script, keeping engine setup (limits, +//! `print`/`debug` routing, later the engine API bindings) in one place. A +//! script is compiled once into a [`CompiledScript`] (a reusable AST) and then +//! evaluated cheaply each frame. + +use oxide_engine::math::Transform; +use oxide_engine::scene::Entity; +use rhai::{Engine, Scope, AST}; + +use crate::bridge::{register_api, ScriptContext, SharedContext}; +use crate::ScriptAsset; + +/// Errors raised while compiling or running a script. +/// +/// Both variants name the offending script so the editor console can attribute +/// the failure; the host uses a runtime error to **pause** that one script +/// rather than crash the editor (Stage 10 error-isolation goal). +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ScriptError { + /// The source failed to parse / compile. + #[error("script '{name}' failed to compile: {message}")] + Compile { + /// The script's diagnostic name. + name: String, + /// The compiler's message. + message: String, + }, + /// The script compiled but raised an error while running. + #[error("script '{name}' raised a runtime error: {message}")] + Runtime { + /// The script's diagnostic name. + name: String, + /// The runtime error message. + message: String, + }, +} + +/// A compiled, ready-to-run script: its `rhai` AST and the diagnostic name it +/// was compiled from. +/// +/// Cheap to keep around and re-evaluate; the host stores one per live script and +/// replaces it wholesale on live reload. +#[derive(Clone, Debug)] +pub struct CompiledScript { + /// The compiled abstract syntax tree. + pub ast: AST, + /// The source script's diagnostic name (for error messages). + pub name: String, +} + +/// A configured `rhai` engine the host reuses to compile and run scripts. +/// +/// One engine compiles many scripts; the configuration (resource limits, +/// `print`/`debug` routing, and — in later pieces — the engine type API) lives +/// here so every script sees the same sandbox. +pub struct ScriptEngine { + engine: Engine, + /// Shared scratch the engine API reads/writes: the host stages the active + /// entity's transform here, the script mutates it, the host reads it back. + ctx: SharedContext, +} + +impl ScriptEngine { + /// Builds an engine with Oxide's default sandbox configuration and the + /// engine API (the [`Vec3`](oxide_engine::math::Vec3) type + ambient + /// transform functions) registered. + /// + /// `print` and `debug` output is routed to the `log` crate (so the editor + /// console can surface it); operation limits guard against an accidental + /// infinite loop wedging the editor. + pub fn new() -> Self { + let mut engine = Engine::new(); + // Route script `print`/`debug` to the log so the console can capture it + // instead of leaking to stdout. + engine.on_print(|text| log::info!(target: "oxide_script", "{text}")); + engine.on_debug(|text, source, pos| { + log::debug!(target: "oxide_script", "{}:{pos:?}: {text}", source.unwrap_or("script")); + }); + // A generous cap: enough for real per-frame logic, low enough that a + // runaway loop surfaces as a runtime error rather than a hang. + engine.set_max_operations(2_000_000); + + let ctx: SharedContext = + std::sync::Arc::new(std::sync::Mutex::new(ScriptContext::default())); + register_api(&mut engine, &ctx); + + Self { engine, ctx } + } + + // --- Context staging (host ↔ script transform hand-off) ---------------- + + /// Stages the active `entity`, its `transform`, and frame delta `dt` into the + /// shared context before a call, so the script's `position`/`translate`/`dt`/ + /// `entity()`/… see the entity's current pose and identity. Clears any + /// command buffer left over from a previous call (the host always + /// [`take_commands`](Self::take_commands) after each call, so this is just + /// belt-and-suspenders); the provisional-id counter is deliberately *not* + /// reset, keeping spawn ids unique across a frame's `init` + `update`. + pub fn set_context(&self, entity: Entity, transform: Transform, dt: f32) { + let mut g = self.ctx.lock().unwrap(); + g.current = Some(entity); + g.transform = transform; + g.dt = dt; + g.commands.clear(); + } + + /// Reads the (possibly script-mutated) transform back out of the context + /// after a call, so the host can write it to the ECS. + pub fn take_transform(&self) -> Transform { + self.ctx.lock().unwrap().transform + } + + /// Drains the scene-mutation commands the script issued this call, so the + /// host can apply them against the scene + reflection registry. + pub(crate) fn take_commands(&self) -> Vec { + std::mem::take(&mut self.ctx.lock().unwrap().commands) + } + + // --- Lifecycle --------------------------------------------------------- + + /// Whether the compiled script defines a function named `name` taking + /// `arity` parameters (used to skip absent lifecycle hooks rather than treat + /// "no such function" as an error). + pub fn has_function(&self, script: &CompiledScript, name: &str, arity: usize) -> bool { + script + .ast + .iter_functions() + .any(|f| f.name == name && f.params.len() == arity) + } + + /// Starts a script in `scope`: runs its top level once (defining functions + /// and one-shot setup), then calls `init()` if it defines one. + /// + /// `scope` persists across frames, so top-level `let` bindings live on as the + /// script's state. A [`ScriptError::Runtime`] is returned (never panicked) so + /// the host can pause just this script. + pub fn start(&self, scope: &mut Scope, script: &CompiledScript) -> Result<(), ScriptError> { + self.engine + .run_ast_with_scope(scope, &script.ast) + .map_err(|err| self.runtime_error(script, err))?; + if self.has_function(script, "init", 0) { + self.engine + .call_fn::<()>(scope, &script.ast, "init", ()) + .map_err(|err| self.runtime_error(script, err))?; + } + Ok(()) + } + + /// Runs one frame of a started script: calls `update(dt)` if it defines one. + /// The host must [`set_context`](Self::set_context) first. + pub fn run_update( + &self, + scope: &mut Scope, + script: &CompiledScript, + dt: f32, + ) -> Result<(), ScriptError> { + if self.has_function(script, "update", 1) { + self.engine + .call_fn::<()>(scope, &script.ast, "update", (dt,)) + .map_err(|err| self.runtime_error(script, err))?; + } + Ok(()) + } + + /// Builds a [`ScriptError::Runtime`] from a `rhai` evaluation error. + fn runtime_error(&self, script: &CompiledScript, err: Box) -> ScriptError { + ScriptError::Runtime { + name: script.name.clone(), + message: err.to_string(), + } + } + + /// Borrows the underlying `rhai` engine (for host wiring that registers + /// types/functions on it). + pub fn raw(&self) -> &Engine { + &self.engine + } + + /// Mutably borrows the underlying engine, e.g. to register engine API + /// functions a script can call. + pub fn raw_mut(&mut self) -> &mut Engine { + &mut self.engine + } + + /// Compiles `asset`'s source into a reusable [`CompiledScript`], or reports a + /// [`ScriptError::Compile`] naming the script. + pub fn compile(&self, asset: &ScriptAsset) -> Result { + match self.engine.compile(&asset.source) { + Ok(ast) => Ok(CompiledScript { + ast, + name: asset.name.clone(), + }), + Err(err) => Err(ScriptError::Compile { + name: asset.name.clone(), + message: err.to_string(), + }), + } + } + + /// Runs a compiled script's top level in a fresh scope, discarding its value. + /// + /// This executes statements at file scope (where a script defines its + /// functions and any one-shot setup). A [`ScriptError::Runtime`] is returned + /// — never panicked — so the host can pause just this script. + pub fn run(&self, script: &CompiledScript) -> Result<(), ScriptError> { + self.engine + .run_ast(&script.ast) + .map_err(|err| ScriptError::Runtime { + name: script.name.clone(), + message: err.to_string(), + }) + } +} + +impl Default for ScriptEngine { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compiles_and_runs_valid_source() { + let engine = ScriptEngine::new(); + let asset = ScriptAsset::from_source("ok", "let x = 1 + 2; print(x);"); + let compiled = engine.compile(&asset).expect("compiles"); + assert_eq!(compiled.name, "ok"); + engine.run(&compiled).expect("runs"); + } + + #[test] + fn a_syntax_error_is_a_compile_error_naming_the_script() { + let engine = ScriptEngine::new(); + let asset = ScriptAsset::from_source("broken", "let x = ;"); + let err = engine.compile(&asset).unwrap_err(); + match err { + ScriptError::Compile { name, .. } => assert_eq!(name, "broken"), + other => panic!("expected a compile error, got {other:?}"), + } + } + + #[test] + fn a_runtime_failure_is_isolated_as_a_runtime_error() { + let engine = ScriptEngine::new(); + // Throws at run time, not compile time. + let asset = ScriptAsset::from_source("throws", "throw \"boom\";"); + let compiled = engine.compile(&asset).expect("compiles"); + let err = engine.run(&compiled).unwrap_err(); + match err { + ScriptError::Runtime { name, .. } => assert_eq!(name, "throws"), + other => panic!("expected a runtime error, got {other:?}"), + } + } + + #[test] + fn an_infinite_loop_is_capped_rather_than_hanging() { + let engine = ScriptEngine::new(); + let asset = ScriptAsset::from_source("spin", "let i = 0; loop { i += 1; }"); + let compiled = engine.compile(&asset).expect("compiles"); + // The operation cap turns the runaway loop into a runtime error. + assert!(engine.run(&compiled).is_err()); + } +} diff --git a/script/src/host.rs b/script/src/host.rs new file mode 100644 index 0000000..1613162 --- /dev/null +++ b/script/src/host.rs @@ -0,0 +1,594 @@ +//! The [`ScriptHost`] — the runtime that compiles and runs each entity's script. +//! +//! The host is the scripting counterpart to physics' `PhysicsWorld`: a transient +//! [`App`] resource that holds the per-entity runtime state (compiled AST + +//! persistent scope) the authored [`Script`] components do **not**. A single +//! system, [`run_scripts`], drives it each frame: +//! +//! 1. find every entity with an enabled [`Script`]; +//! 2. resolve each one's `.rhai` source through the [`AssetDatabase`] + +//! [`AssetServer`](oxide_engine::asset::AssetServer); +//! 3. (re)compile and `start` any script that is new or whose source changed; +//! 4. stage the entity's [`Transform`] into the engine, call `update(dt)`, and +//! write the (possibly mutated) transform back. +//! +//! Because runtime state lives here keyed by entity — never on the component — +//! play-mode snapshot/restore is unaffected. **Live reload** falls out of step +//! 3: the host keeps each script's asset [`Handle`] alive, so when the file +//! watcher reruns the loader in place on a disk edit, the next frame reads the +//! new source through that handle and recompiles — no restart, scene state +//! preserved (see `examples/script_spin`). A script that fails to compile or +//! raises a runtime error is **paused** (its error remembered) rather than +//! retried every frame or allowed to crash the host. + +use std::collections::HashMap; + +use oxide_engine::app::App; +use oxide_engine::asset::{AssetDatabase, AssetUid, Handle}; +use oxide_engine::math::Transform; +use oxide_engine::scene::{DespawnPolicy, Entity}; +use rhai::Scope; + +use crate::bridge::{EntityHandle, ScriptCommand}; +use crate::{CompiledScript, Script, ScriptAsset, ScriptEngine}; + +/// The compiled, runnable form of a started script: its AST and the persistent +/// scope that carries top-level `let` state across frames. +struct Runnable { + compiled: CompiledScript, + scope: Scope<'static>, +} + +/// Per-entity script runtime state. +struct ScriptState { + /// Which asset is compiled here (recompile when it changes). + uid: AssetUid, + /// The live handle to the script asset. Held so the asset stays cached and + /// the file watcher can reload fresh source **into it in place** — that + /// in-place update is what makes live reload work: the next frame reads the + /// new source through this handle and recompiles. + handle: Handle, + /// The exact source compiled, so a content change (e.g. from a live reload) + /// triggers a recompile. + source: String, + /// The runnable script, or `None` if it failed to compile/start. + runnable: Option, + /// If set, the script is paused after this error and skipped until its + /// source changes. Surfaced to the editor console in a later piece. + paused_error: Option, +} + +impl ScriptState { + /// Whether the script should run its per-frame `update` this frame. + fn is_runnable(&self) -> bool { + self.paused_error.is_none() && self.runnable.is_some() + } +} + +/// The scripting runtime resource. Add it via +/// [`ScriptModule`](crate::ScriptModule); [`run_scripts`] drives it each frame. +pub struct ScriptHost { + engine: ScriptEngine, + states: HashMap, +} + +impl ScriptHost { + /// A host with a fresh engine and no live scripts. + pub fn new() -> Self { + Self { + engine: ScriptEngine::new(), + states: HashMap::new(), + } + } + + /// The number of scripts currently live (compiled or paused) in the host. + pub fn live_count(&self) -> usize { + self.states.len() + } + + /// The remembered error for `entity`'s script, if it is paused. + pub fn error_of(&self, entity: Entity) -> Option<&str> { + self.states + .get(&entity) + .and_then(|s| s.paused_error.as_deref()) + } + + /// Compiles `asset` and runs its `start` (top level + `init`), yielding a + /// [`Runnable`] or the error string that paused it. + fn compile_and_start(engine: &ScriptEngine, asset: &ScriptAsset) -> Result { + let compiled = engine.compile(asset).map_err(|e| e.to_string())?; + let mut scope = Scope::new(); + engine + .start(&mut scope, &compiled) + .map_err(|e| e.to_string())?; + Ok(Runnable { compiled, scope }) + } + + /// Drives one frame: (re)compiles changed scripts and runs `update(dt)` for + /// each live one, applying transform changes back to the scene. + fn run_frame(&mut self, app: &mut App, dt: f32) { + // 1. Snapshot the enabled, source-bearing scripts (immutable scene + // borrow), so we can mutate the scene later without a borrow clash. + let scripted: Vec<(Entity, AssetUid)> = app + .scene + .world() + .query::<&Script>() + .iter() + .filter_map(|(e, s)| { + if s.enabled { + s.source.uid().map(|uid| (e, uid)) + } else { + None + } + }) + .collect(); + + // Forget state for entities that lost or disabled their script. + let live: std::collections::HashSet = scripted.iter().map(|(e, _)| *e).collect(); + self.states.retain(|e, _| live.contains(e)); + + // 2. Resolve each script to a live handle + its current source text + // (still an immutable App borrow). A handle already held for the same + // asset is reused — keeping it cached so the watcher's in-place reload + // reaches it — so only a brand-new script touches the database/disk. + // No database ⇒ nothing to run. + let mut resolved: Vec<(Entity, AssetUid, Handle, ScriptAsset)> = Vec::new(); + let db = app.get_resource::(); + for (e, uid) in &scripted { + let handle = match self.states.get(e) { + Some(st) if st.uid == *uid => Some(st.handle.clone()), + _ => db.and_then(|db| db.load::(&app.assets, *uid)), + }; + if let Some(handle) = handle { + if let Some(asset) = handle.wait() { + resolved.push((*e, *uid, handle, (*asset).clone())); + } + } + } + + // 3 + 4. Compile/start as needed, then run update — staging the entity's + // pose into the engine around each call and writing it back so both + // `init` and `update` transform changes land in the scene. + for (e, uid, handle, asset) in resolved { + // The entity's current local pose; mutated by start/update below. + let mut transform = app.scene.local_transform(e).unwrap_or(Transform::IDENTITY); + let mut dirty = false; + // Scene mutations (spawn/despawn/component edits) the script requested + // this frame, applied after its transform is written back. + let mut pending = Vec::new(); + + // (Re)start when the script is new, points at a different asset, or + // its source changed (the live-reload trigger). + let needs_start = match self.states.get(&e) { + Some(st) => st.uid != uid || st.source != asset.source, + None => true, + }; + if needs_start { + self.engine.set_context(e, transform, dt); + let (runnable, paused_error) = match Self::compile_and_start(&self.engine, &asset) { + Ok(r) => { + // `init` may have moved the entity / issued commands — + // carry both out. + transform = self.engine.take_transform(); + pending.append(&mut self.engine.take_commands()); + dirty = true; + (Some(r), None) + } + Err(err) => { + // Discard any commands the failed start partially buffered. + let _ = self.engine.take_commands(); + log::warn!(target: "oxide_script", "script '{}' paused: {err}", asset.name); + (None, Some(err)) + } + }; + self.states.insert( + e, + ScriptState { + uid, + handle, + source: asset.source.clone(), + runnable, + paused_error, + }, + ); + } + + let state = self + .states + .get_mut(&e) + .expect("just inserted or pre-existing"); + if state.is_runnable() { + let runnable = state.runnable.as_mut().expect("runnable when is_runnable"); + self.engine.set_context(e, transform, dt); + match self + .engine + .run_update(&mut runnable.scope, &runnable.compiled, dt) + { + Ok(()) => { + transform = self.engine.take_transform(); + pending.append(&mut self.engine.take_commands()); + dirty = true; + } + Err(err) => { + let _ = self.engine.take_commands(); + log::warn!(target: "oxide_script", "script '{}' paused: {err}", runnable.compiled.name); + state.paused_error = Some(err.to_string()); + } + } + } + + if dirty { + app.scene.set_local_transform(e, transform); + } + // Apply structural changes last, so an explicit component edit (e.g. + // a `set_component(entity(), "Transform", …)`) wins over the staged + // transform write above. + if !pending.is_empty() { + Self::apply_commands(app, pending); + } + } + } + + /// Applies a script's buffered [`ScriptCommand`]s against the scene and the + /// reflection registry (`app.types`), resolving each provisional `spawn` id + /// to the real [`Entity`] it created. Commands run in issue order, so a + /// script can spawn an entity and immediately configure it. Component edits + /// that name an unregistered type or fail to parse are logged and skipped — + /// one bad command never aborts the rest or crashes the host. + fn apply_commands(app: &mut App, commands: Vec) { + // Maps a `spawn`'s provisional id to the entity it created this frame. + let mut spawned: HashMap = HashMap::new(); + + // Resolves a handle to a live entity, or `None` if it names a provisional + // id that was never spawned (a script bug — skipped, not fatal). + let resolve = |target: EntityHandle, spawned: &HashMap| match target { + EntityHandle::Real(e) => Some(e), + EntityHandle::Provisional(id) => spawned.get(&id).copied(), + }; + + for cmd in commands { + match cmd { + ScriptCommand::Spawn { provisional, name } => { + let e = app.scene.spawn(name, Transform::IDENTITY); + spawned.insert(provisional, e); + } + ScriptCommand::Despawn { target } => { + if let Some(e) = resolve(target, &spawned) { + app.scene.despawn(e, DespawnPolicy::Recursive); + } + } + ScriptCommand::AddComponent { target, type_name } => { + if let Some(e) = resolve(target, &spawned) { + if let Err(err) = + app.types.add_default(app.scene.world_mut(), e, &type_name) + { + log::warn!(target: "oxide_script", "add_component({type_name}) failed: {err}"); + } + } + } + ScriptCommand::SetComponent { + target, + type_name, + ron, + } => { + if let Some(e) = resolve(target, &spawned) { + if let Err(err) = + app.types + .set_ron(app.scene.world_mut(), e, &type_name, &ron) + { + log::warn!(target: "oxide_script", "set_component({type_name}) failed: {err}"); + } + } + } + ScriptCommand::RemoveComponent { target, type_name } => { + if let Some(e) = resolve(target, &spawned) { + if let Err(err) = app.types.remove(app.scene.world_mut(), e, &type_name) { + log::warn!(target: "oxide_script", "remove_component({type_name}) failed: {err}"); + } + } + } + } + } + } +} + +impl Default for ScriptHost { + fn default() -> Self { + Self::new() + } +} + +/// The per-frame system: takes the [`ScriptHost`] out, drives it, puts it back. +/// +/// Mirrors physics' `step_physics`: removing the resource hands the system +/// exclusive ownership of the host while it borrows the [`App`] (scene + assets) +/// mutably, then it is re-inserted. +pub(crate) fn run_scripts(app: &mut App) { + let Some(mut host) = app.remove_resource::() else { + return; + }; + let dt = app.time.delta; + host.run_frame(app, dt); + app.insert_resource(host); +} + +#[cfg(test)] +mod tests { + use super::*; + use oxide_engine::app::{App, DefaultModules, Schedule}; + use oxide_engine::asset::AssetDatabase; + use oxide_engine::math::Vec3; + + /// Builds an app with a temp project whose `assets/scripts/` holds `source` + /// under `name.rhai`, the script module, and an entity running that script. + /// Returns the app and the entity. + fn app_with_script(name: &str, source: &str) -> (App, Entity, tempdir::TempProject) { + let project = tempdir::TempProject::new(); + let rel = format!("scripts/{name}.rhai"); + project.write_asset(&rel, source); + + let mut db = AssetDatabase::new(project.root()); + let uid = db.register(&rel); + + let mut app = App::new(); + app.add_modules(DefaultModules); + app.add_module(crate::ScriptModule); + app.insert_resource(db); + + let e = app + .scene + .spawn("scripted", Transform::from_translation(Vec3::ZERO)); + app.scene + .world_mut() + .insert_one(e, Script::new(oxide_engine::asset::AssetRef::new(uid))) + .unwrap(); + + (app, e, project) + } + + #[test] + fn update_moves_the_entity_transform_each_frame() { + // Moves +1 on X per second; with dt = 0.5 that is +0.5 per frame. + let (mut app, e, _p) = + app_with_script("move", "fn update(dt) { translate(dt, 0.0, 0.0); }"); + + app.update(0.5); + let x1 = app.scene.local_transform(e).unwrap().translation.x; + assert!((x1 - 0.5).abs() < 1e-6, "after one frame x = {x1}"); + + app.update(0.5); + let x2 = app.scene.local_transform(e).unwrap().translation.x; + assert!((x2 - 1.0).abs() < 1e-6, "after two frames x = {x2}"); + } + + #[test] + fn init_runs_once_before_update() { + // init sets x to 10; update adds 1 each frame. After 2 frames: 12. + let (mut app, e, _p) = app_with_script( + "initd", + "fn init() { set_position(vec3(10.0, 0.0, 0.0)); } \ + fn update(dt) { translate(1.0, 0.0, 0.0); }", + ); + app.update(0.016); + app.update(0.016); + let x = app.scene.local_transform(e).unwrap().translation.x; + assert!((x - 12.0).abs() < 1e-6, "x = {x}"); + } + + #[test] + fn a_disabled_script_does_not_run() { + let (mut app, e, _p) = + app_with_script("dis", "fn update(dt) { translate(1.0, 0.0, 0.0); }"); + // Disable it before any frame. + app.scene.get_mut::